From bf2990ca51268f46cfead295b42947c51a204896 Mon Sep 17 00:00:00 2001 From: Zel Date: Sun, 26 Jul 2026 21:26:02 +0800 Subject: [PATCH 1/4] ci: build the APKs on every push Assembles both variants, uploads them as artifacts, then runs lint. - JDK 21, to match the project's compileOptions. - The Android SDK packages are pinned to platform 37 / build-tools 36.0.0, so a runner image update cannot silently change what we compile against. The step also accepts the SDK licenses, which is what lets AGP fetch anything else it needs. - The wrapper is checksum-validated before it is executed. - Lint runs after the artifact uploads, so a lint regression still leaves an installable APK attached to the run. - Concurrency is per branch with cancel-in-progress: a newer push makes the running build irrelevant. The release variant is signed with the debug key, as the project is set up today (app/build.gradle.kts), so these artifacts are for testing and cannot upgrade an install signed with a real release key. --- .github/workflows/build.yml | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad43562 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,87 @@ +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: + # Pinned so a runner image update cannot silently change what we build against. + # Anything else AGP needs it downloads itself; this step accepts the licenses. + packages: 'platforms;android-37 build-tools;36.0.0 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 From c326c5e0fc1ffb992b8af00f0be1b1d2653b6834 Mon Sep 17 00:00:00 2001 From: Zel Date: Sun, 26 Jul 2026 21:36:48 +0800 Subject: [PATCH 2/4] ci: publish a signed release APK on version tags Triggered by a v* tag: builds the release variant, attaches the APK to a GitHub release with generated notes, and marks tags containing a hyphen (v1.05-beta1) as pre-releases. workflow_dispatch runs the same build and uploads the APK as an artifact without publishing, so the signing setup can be checked before a tag is pushed. Release signing comes from repository secrets, which required a change to app/build.gradle.kts: the release buildType hardcoded the debug signing config. It now uses a release config built from KEYSTORE_FILE and friends, falling back to the debug key when those are unset, so local builds behave exactly as they did before. To sign for real, set these secrets: KEYSTORE_BASE64 base64 of the .jks (base64 -w0 release.jks) KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD Without them the workflow still succeeds, but it annotates the run and the release body with a warning that the APK carries the debug signature. The keystore is written to RUNNER_TEMP, deleted straight after the build, and the release notes carry the APK's SHA-256 so a sideloaded download can be checked. --- .github/workflows/release.yml | 138 ++++++++++++++++++++++++++++++++++ app/build.gradle.kts | 15 +++- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ca20f37 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,138 @@ +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: + packages: 'platforms;android-37 build-tools;36.0.0 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( From a774ff27639b84d55b7cde57d64e04f71f5b2626 Mon Sep 17 00:00:00 2001 From: Zel Date: Sun, 26 Jul 2026 21:41:43 +0800 Subject: [PATCH 3/4] ci: stop pinning the SDK platform and build-tools The first run failed in the Android SDK step: there is no 'platforms;android-37' package. AGP 9 uses minor-versioned platforms, so the id is 'platforms;android-37.0', and pinning it here duplicates what compileSdk already states. Let AGP resolve and download the platform and build-tools it needs, which it does once the licenses are accepted, and leave this step to accept them. --- .github/workflows/build.yml | 8 +++++--- .github/workflows/release.yml | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad43562..c501493 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,9 +47,11 @@ jobs: - name: Set up the Android SDK uses: android-actions/setup-android@v3 with: - # Pinned so a runner image update cannot silently change what we build against. - # Anything else AGP needs it downloads itself; this step accepts the licenses. - packages: 'platforms;android-37 build-tools;36.0.0 platform-tools' + # 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca20f37..5826aac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,9 @@ jobs: - name: Set up the Android SDK uses: android-actions/setup-android@v3 with: - packages: 'platforms;android-37 build-tools;36.0.0 platform-tools' + # 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') From 6dcfbe41bb36c6f85f805faa3cf7a2f17c74daa1 Mon Sep 17 00:00:00 2001 From: Zel Date: Sun, 26 Jul 2026 22:01:01 +0800 Subject: [PATCH 4/4] fix(main): reload when access is granted, and add pull to refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission is granted outside this screen — Shizuku shows its own dialog — so nothing was telling the app about it. The list stayed empty behind the "permissions required" dialog until the app was restarted. - UserServiceProvider exposes the bind state as a StateFlow, updated by Connection on connect and disconnect. MainScreenVm watches it and loads the list as soon as the privileged service binds. The current value is dropped: if the service was already bound at startup, the load kicked off in init succeeds by itself. - Pull to refresh on the app list. The empty state is rendered as a full-height item inside the LazyColumn rather than a plain Box, so it stays scrollable and can be pulled too, which is exactly the state you are in before access has been granted. The permission request had a second dead end. It ran once in onCreate and only if Shizuku's binder was already alive, so opening this app before starting Shizuku meant permission was never requested at all, and onResume only ever bound an already-granted service. "Proceed" then just re-ran the detection and found the same nothing. - The request now runs on every resume, guarded by a flag so a declined dialog is not re-shown, seeded from savedInstanceState so a rotation does not re-ask. - "Proceed" asks Shizuku for permission instead of only re-detecting. --- .../vegabobo/languageselector/MainActivity.kt | 56 ++++++++------ .../languageselector/service/Connection.kt | 2 + .../service/UserServiceProvider.kt | 19 +++++ .../ui/screen/main/MainScreen.kt | 76 ++++++++++++++----- .../ui/screen/main/MainScreenState.kt | 1 + .../ui/screen/main/MainScreenVm.kt | 41 +++++++++- 6 files changed, 151 insertions(+), 44 deletions(-) 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() }))