diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c501493 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,89 @@ +name: Build + +on: + push: + branches: ['**'] + # Changes that cannot affect the build output. + paths-ignore: + - '**.md' + - 'other/**' + - 'LICENSE' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to the same branch makes the running build irrelevant. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Assemble and lint + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + # The project compiles against Java 21 (app/build.gradle.kts). + java-version: '21' + + - name: Validate the Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + # Only the default branch writes to the cache; other branches read it. + cache-read-only: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} + + - name: Set up the Android SDK + uses: android-actions/setup-android@v3 + with: + # Only what the action itself needs. The platform and build-tools are not pinned + # here: AGP derives them from compileSdk and downloads them, and it wants the + # minor-versioned package (platforms;android-37.0), which is easy to get wrong. + # This step's real job is accepting the SDK licenses so that download can happen. + packages: 'platform-tools' + + - name: Assemble + run: ./gradlew assembleDebug assembleRelease --stacktrace + + - name: Upload the debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug-${{ github.sha }} + path: app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + retention-days: 14 + + - name: Upload the release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-${{ github.sha }} + path: app/build/outputs/apk/release/app-release.apk + if-no-files-found: error + retention-days: 14 + + # After the uploads, so a lint regression still leaves you an installable APK. + - name: Lint + run: ./gradlew lintDebug --stacktrace + + - name: Upload the lint report + # Most useful precisely when the step above failed. + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: lint-report-${{ github.sha }} + path: | + app/build/reports/lint-results-debug.html + app/build/reports/lint-results-debug.sarif + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5826aac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,140 @@ +name: Release + +on: + push: + tags: ['v*'] + # Builds and uploads a signed APK without publishing anything, so the signing + # setup can be checked before a tag is pushed. + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Build and publish + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Validate the Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + - name: Set up the Android SDK + uses: android-actions/setup-android@v3 + with: + # See build.yml: AGP downloads the platform and build-tools it needs; this step + # is here to accept the SDK licenses. + packages: 'platform-tools' + + - name: Check the tag against versionName + if: startsWith(github.ref, 'refs/tags/v') + run: | + declared=$(sed -n 's/.*versionName = "\(.*\)".*/\1/p' app/build.gradle.kts) + tagged="${GITHUB_REF_NAME#v}" + if [ "$declared" != "$tagged" ]; then + echo "::warning::Tag $GITHUB_REF_NAME does not match versionName '$declared' in app/build.gradle.kts. The APK will report $declared." + fi + + - name: Decode the signing keystore + id: keystore + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + run: | + if [ -z "$KEYSTORE_BASE64" ]; then + echo "::warning::No KEYSTORE_BASE64 secret is set. The APK will be signed with the Android debug key, which cannot upgrade an install signed with a real release key." + echo 'signed=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + echo 'signed=true' >> "$GITHUB_OUTPUT" + + - name: Assemble the release APK + env: + # Empty when there is no keystore, which makes the build fall back to the + # debug signing config rather than fail. + KEYSTORE_FILE: ${{ steps.keystore.outputs.signed == 'true' && format('{0}/release.jks', runner.temp) || '' }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: ./gradlew assembleRelease --stacktrace + + - name: Remove the keystore + if: ${{ always() && steps.keystore.outputs.signed == 'true' }} + run: rm -f "$RUNNER_TEMP/release.jks" + + - name: Stage the APK + id: apk + run: | + if [ "${{ github.event_name }}" = 'workflow_dispatch' ]; then + version="${GITHUB_SHA::7}" + else + version="${GITHUB_REF_NAME}" + fi + name="language-selector-${version}.apk" + mkdir -p dist + cp app/build/outputs/apk/release/app-release.apk "dist/${name}" + echo "name=${name}" >> "$GITHUB_OUTPUT" + echo "sha256=$(sha256sum "dist/${name}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Upload the APK as an artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.apk.outputs.name }} + path: dist/${{ steps.apk.outputs.name }} + if-no-files-found: error + retention-days: 30 + + - name: Publish the GitHub release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + SIGNED: ${{ steps.keystore.outputs.signed }} + SHA256: ${{ steps.apk.outputs.sha256 }} + APK: ${{ steps.apk.outputs.name }} + run: | + notes="$RUNNER_TEMP/notes.md" + { + echo '### Verify the download' + echo + echo '```' + echo "SHA-256 ${SHA256}" + echo '```' + if [ "$SIGNED" != 'true' ]; then + echo + echo '> [!WARNING]' + echo '> This build was produced without a release keystore, so it is signed with the' + echo '> Android debug key. Use it for testing only: it cannot upgrade an install' + echo '> signed with a real release key.' + fi + echo + } > "$notes" + + # A tag such as v1.05-beta1 is published as a pre-release. + prerelease='' + case "$GITHUB_REF_NAME" in *-*) prerelease='--prerelease' ;; esac + + # --generate-notes appends the commit/PR changelog after the notes above. + gh release create "$GITHUB_REF_NAME" "dist/${APK}" \ + --title "$GITHUB_REF_NAME" \ + --notes-file "$notes" \ + --generate-notes \ + --verify-tag \ + $prerelease diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3264ae3..36217dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,9 +27,22 @@ android { } } + // Set by CI from repository secrets. Without them the release build keeps falling back to + // the debug key, so local builds behave exactly as before. + val releaseKeystore = System.getenv("KEYSTORE_FILE")?.takeIf { it.isNotBlank() }?.let(::file) + if (releaseKeystore != null) { + signingConfigs.create("release") { + storeFile = releaseKeystore + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + buildTypes { release { - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.findByName("release") + ?: signingConfigs.getByName("debug") isMinifyEnabled = true isShrinkResources = true proguardFiles( diff --git a/app/src/main/java/vegabobo/languageselector/MainActivity.kt b/app/src/main/java/vegabobo/languageselector/MainActivity.kt index 7279dcc..e3c0f4b 100644 --- a/app/src/main/java/vegabobo/languageselector/MainActivity.kt +++ b/app/src/main/java/vegabobo/languageselector/MainActivity.kt @@ -39,9 +39,12 @@ class MainActivity : ComponentActivity(), Shizuku.OnRequestPermissionResultListe Shell.setDefaultBuilder(Shell.Builder.create().setTimeout(10)) } - val acRequestCode = 1 + private val shizukuRequestCode = 1 - fun bindShizuku() { + /** Kept so a dialog the user declined is not thrown at them again on every resume. */ + private var hasRequestedShizukuPermission = false + + private fun bindShizuku() { Shizuku.bindUserService(ShizukuArgs.userServiceArgs, UserServiceProvider.connection) } @@ -52,32 +55,47 @@ class MainActivity : ComponentActivity(), Shizuku.OnRequestPermissionResultListe bindShizuku() } - private fun checkPermission(code: Int): Boolean { - return if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { + /** + * Shizuku is a separate app that may not have been running when this activity was created, + * so this runs on every resume rather than once in onCreate. It used to run only in + * onCreate and only when the binder was already alive, which meant that starting Shizuku + * after opening this app left it with no way to ever ask for permission. + * + * @param userInitiated the user asked for this from the permission dialog, so ask again + * even if we already have. + */ + private fun connectShizukuIfPossible(userInitiated: Boolean = false) { + if (!Shizuku.pingBinder() || UserServiceProvider.isConnected()) return + + if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { bindShizuku() - true - } else if (Shizuku.shouldShowRequestPermissionRationale()) { - false - } else { - Shizuku.requestPermission(code) - false + return + } + + if (userInitiated || !hasRequestedShizukuPermission) { + hasRequestedShizukuPermission = true + Shizuku.requestPermission(shizukuRequestCode) } } + /** Called from the "permissions required" dialog. */ + fun requestShizukuAccess() = connectShizukuIfPossible(userInitiated = true) + override fun onCreate(savedInstanceState: Bundle?) { // Drives the system bar icon colours from the theme, so they stay legible when the // system switches between light and dark mode. enableEdgeToEdge() super.onCreate(savedInstanceState) + + // On a configuration change the request was already made by the previous instance. + hasRequestedShizukuPermission = savedInstanceState != null + + Shizuku.addRequestPermissionResultListener(REQUEST_PERMISSION_RESULT_LISTENER) + setContent { LanguageSelectorTheme { Navigation() } } - if (Shizuku.pingBinder() && savedInstanceState == null) { - Shizuku.addRequestPermissionResultListener(REQUEST_PERMISSION_RESULT_LISTENER) - checkPermission(acRequestCode) - } - RootReceivedListener.setListener(object : IRootListener { override fun onRootReceived() { val intent = Intent(application, RootUserService::class.java) @@ -88,13 +106,7 @@ class MainActivity : ComponentActivity(), Shizuku.OnRequestPermissionResultListe override fun onResume() { super.onResume() - if ( - Shizuku.pingBinder() && - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED && - !UserServiceProvider.isConnected() - ) { - bindShizuku() - } + connectShizukuIfPossible() } override fun onDestroy() { diff --git a/app/src/main/java/vegabobo/languageselector/service/Connection.kt b/app/src/main/java/vegabobo/languageselector/service/Connection.kt index a22933e..200ce82 100644 --- a/app/src/main/java/vegabobo/languageselector/service/Connection.kt +++ b/app/src/main/java/vegabobo/languageselector/service/Connection.kt @@ -16,9 +16,11 @@ class Connection : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { set(IUserService.Stub.asInterface(service)) + UserServiceProvider.onConnectionChanged() } override fun onServiceDisconnected(name: ComponentName?) { SERVICE = null + UserServiceProvider.onConnectionChanged() } } \ No newline at end of file diff --git a/app/src/main/java/vegabobo/languageselector/service/UserServiceProvider.kt b/app/src/main/java/vegabobo/languageselector/service/UserServiceProvider.kt index b295e91..7596e32 100644 --- a/app/src/main/java/vegabobo/languageselector/service/UserServiceProvider.kt +++ b/app/src/main/java/vegabobo/languageselector/service/UserServiceProvider.kt @@ -4,6 +4,9 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import vegabobo.languageselector.IUserService import vegabobo.languageselector.ui.screen.main.OperationMode @@ -15,6 +18,22 @@ object UserServiceProvider { var connection = Connection() var opMode = OperationMode.NONE + private val _isServiceConnected = MutableStateFlow(false) + + /** + * Whether the privileged service is currently bound. + * + * Permission is granted outside the app — Shizuku shows its own dialog, root is granted by + * the superuser manager — so nothing calls back into the UI when it happens. Screens + * observe this instead of waiting for the user to restart the app. + */ + val isServiceConnected: StateFlow = _isServiceConnected.asStateFlow() + + /** Called by [Connection] whenever the binder is handed over or lost. */ + fun onConnectionChanged() { + _isServiceConnected.value = isConnected() + } + // Blocking fun getService(): IUserService { var timeout = 0 diff --git a/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreen.kt b/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreen.kt index c05db62..3e41cec 100644 --- a/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreen.kt +++ b/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreen.kt @@ -1,5 +1,6 @@ package vegabobo.languageselector.ui.screen.main +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,6 +32,9 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopSearchBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -49,6 +53,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch +import vegabobo.languageselector.MainActivity import vegabobo.languageselector.R import vegabobo.languageselector.ui.components.AppListItem import vegabobo.languageselector.ui.components.FilterLabel @@ -67,6 +72,7 @@ fun MainScreen( val searchBarState = rememberSearchBarState() val textFieldState = rememberTextFieldState() + val pullToRefreshState = rememberPullToRefreshState() val searchBarScrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() var showSystemAppsInSearch by rememberSaveable { mutableStateOf(false) } var onlyModifiedInSearch by rememberSaveable { mutableStateOf(false) } @@ -160,32 +166,55 @@ fun MainScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { innerPadding -> - when { - uiState.isLoading -> - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + if (uiState.isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { mainScreenVm.onRefresh() }, + state = pullToRefreshState, + modifier = Modifier.fillMaxSize(), + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = uiState.isRefreshing, + // Below the search bar instead of behind it. + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = innerPadding.calculateTopPadding()) + ) } - - homeApps.isEmpty() -> - CenteredMessage( - text = stringResource(R.string.no_apps), - modifier = Modifier.padding(innerPadding) - ) - - else -> + ) { LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), contentPadding = innerPadding ) { - items( - count = homeApps.size, - key = { homeApps[it].pkg } - ) { index -> - val app = homeApps[index] - AppListItem(app = app, onClickApp = { openApp(app) }) + if (homeApps.isEmpty()) { + // A full-height item keeps the list scrollable, which is what makes the + // empty state pullable: this is exactly the state you are in before + // access has been granted. + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center + ) { + CenteredMessage(stringResource(R.string.no_apps)) + } + } + } else { + items( + count = homeApps.size, + key = { homeApps[it].pkg } + ) { index -> + val app = homeApps[index] + AppListItem(app = app, onClickApp = { openApp(app) }) + } } } + } } } @@ -206,8 +235,15 @@ fun MainScreen( ) } - if (uiState.operationMode == OperationMode.NONE && !uiState.isLoading) - ShizukuRequiredWarning { mainScreenVm.onClickProceedShizuku() } + if (uiState.operationMode == OperationMode.NONE && !uiState.isLoading) { + val activity = LocalActivity.current + ShizukuRequiredWarning { + // Ask Shizuku for permission, in case it was started after this app was opened; + // re-detecting on its own would find nothing to detect. + (activity as? MainActivity)?.requestShizukuAccess() + mainScreenVm.onClickProceedShizuku() + } + } } @Composable diff --git a/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenState.kt b/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenState.kt index 3ffccb5..56036ec 100644 --- a/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenState.kt +++ b/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenState.kt @@ -19,6 +19,7 @@ data class MainScreenState( val operationMode: OperationMode = OperationMode.NONE, val isDropdownVisible: Boolean = false, val isLoading: Boolean = true, + val isRefreshing: Boolean = false, val isShowSystemAppsHome: Boolean = false, val snackBarDisplay: SnackBarDisplay = SnackBarDisplay.NONE, ) diff --git a/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenVm.kt b/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenVm.kt index 3511072..5c377f8 100644 --- a/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenVm.kt +++ b/app/src/main/java/vegabobo/languageselector/ui/screen/main/MainScreenVm.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import rikka.shizuku.Shizuku @@ -55,6 +57,27 @@ class MainScreenVm @Inject constructor( init { fillListOfApps() + reloadWhenServiceConnects() + } + + /** + * Permission is granted outside this screen, so nothing tells us when it happens. Watch the + * service instead: as soon as it binds, load the list the first attempt had to give up on. + * + * The current value is dropped because it needs no handling — if the service was already + * connected when this view model was created, the load started in [init] succeeds on its own. + */ + private fun reloadWhenServiceConnects() { + viewModelScope.launch { + UserServiceProvider.isServiceConnected + .drop(1) + .filter { isConnected -> isConnected } + .collect { + if (_uiState.value.operationMode != OperationMode.NONE) return@collect + _uiState.update { it.copy(isLoading = true) } + fillListOfApps() + } + } } fun parseAppInfo(a: ApplicationInfo): AppInfo { @@ -83,7 +106,10 @@ class MainScreenVm @Inject constructor( // Nothing can be read without Shizuku or root. Stop here so the UI leaves the // loading state and shows the permission dialog; parseAppInfo() would otherwise // block for 20s waiting for a service that will never connect, and then throw. - _uiState.update { it.copy(listOfApps = emptyList(), isLoading = false) } + // reloadWhenServiceConnects() picks things up once access is granted. + _uiState.update { + it.copy(listOfApps = emptyList(), isLoading = false, isRefreshing = false) + } return@launch } @@ -93,10 +119,21 @@ class MainScreenVm @Inject constructor( Log.e(BuildConfig.APPLICATION_ID, "Could not read the installed apps", it) emptyList() } - _uiState.update { it.copy(listOfApps = sortedList, isLoading = false) } + _uiState.update { + it.copy(listOfApps = sortedList, isLoading = false, isRefreshing = false) + } } } + /** + * Pull to refresh. Re-detects the operation mode when there is none, so this doubles as the + * manual retry when the automatic reload did not catch the permission being granted. + */ + fun onRefresh() { + _uiState.update { it.copy(isRefreshing = true) } + fillListOfApps() + } + /** Modified apps first, alphabetically within each group. */ private fun List.sortedApps(): List = sortedWith(compareBy({ !it.isModified() }, { it.name.lowercase() }))