Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
56 changes: 34 additions & 22 deletions app/src/main/java/vegabobo/languageselector/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Boolean> = _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
Expand Down
Loading
Loading