diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdc7b00..e8f8e54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,6 @@ jobs: exit 1 fi "$sdkmanager" "platforms;android-36" "build-tools;36.0.0" - - run: ruby scripts/test-play-release.rb - run: bundle exec fastlane android checks - name: Publish test and lint reports if: always() @@ -57,13 +56,12 @@ jobs: name: message487-builds path: | app/build/outputs/apk/**/*.apk - app/build/outputs/bundle/**/*.aab if-no-files-found: error retention-days: 14 - name: Link build artifacts env: ARTIFACT_URL: ${{ steps.apk.outputs.artifact-url }} - run: echo "[Download debug APK and unsigned release APK/AAB]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" + run: echo "[Download debug APK and unsigned release APK]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" python: name: Python tests and style diff --git a/.github/workflows/play-metadata.yml b/.github/workflows/play-metadata.yml deleted file mode 100644 index 0904abf..0000000 --- a/.github/workflows/play-metadata.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Upload Google Play descriptions - -on: - workflow_dispatch: - inputs: - metadata_commit: - description: "Full commit SHA containing the reviewed descriptions for the Play release" - required: true - type: string - -permissions: - contents: read - -concurrency: - group: google-play-descriptions - cancel-in-progress: false - -jobs: - upload: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.4.10" - bundler-cache: true - - name: Upload reviewed descriptions - env: - PLAY_METADATA_COMMIT: ${{ inputs.metadata_commit }} - SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }} - run: bundle exec fastlane android play_metadata - - name: Next step - run: echo 'Descriptions uploaded without submission. Check both languages and the matching release in Play Console before sending for review.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b70ec32..2a9a6be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ concurrency: jobs: release: - name: Build and verify signed APK and AAB + name: Build and verify signed APK runs-on: ubuntu-latest timeout-minutes: 40 steps: @@ -80,7 +80,7 @@ jobs: retention-days: 14 publish: - name: Publish GitHub Release and Google Play draft + name: Publish GitHub Release needs: release if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' runs-on: ubuntu-latest @@ -104,10 +104,6 @@ jobs: run: | cd dist/release sha256sum -c SHA256SUMS - gh release create "$GITHUB_REF_NAME" ./*.apk ./*.aab mapping.txt SHA256SUMS \ + gh release create "$GITHUB_REF_NAME" ./*.apk mapping.txt SHA256SUMS \ --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes \ --title "Message487 $GITHUB_REF_NAME" - - name: Upload Google Play internal draft - env: - SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }} - run: bundle exec fastlane android play_release track:internal release_status:draft diff --git a/PRIVACY.md b/PRIVACY.md index 94b8f01..12ca997 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: September 28, 2026. +Last updated: October 5, 2026. Message487 forwards selected notifications and new incoming SMS to a webhook you configure. Its core SMS feature transfers new incoming SMS from Android to your n8n workflow so you can @@ -79,6 +79,27 @@ Release builds require HTTPS. Debug builds also permit HTTP to the Android emula localhost for development. The app contains no advertising, analytics SDKs or automatic crash reporting, and does not automatically send data to the developer. +## App updates + +The app determines an update source from Android's installer information and whether that +installer handles F-Droid repository links. An unknown installer requires a source choice. +A saved manual choice takes priority. This does not upload an installed-app inventory. + +Update checks are enabled by default and run about once a day when a source is known and the +network is available. GitHub installs check the project's latest release through api.github.com; +F-Droid installs check f-droid.org. You can change the source or disable automatic checks in +App updates. These services receive the IP address and a Message487 update User-Agent; the app +does not include message contents, webhook credentials, device codes or installation IDs. + +GitHub APK downloads require explicit consent and are stored in the app's update cache. The app +checks the release digest, package, increasing version code and matching installed signing +certificate before offering the system installer. Installation requires a separate user action +and Android's install permission. F-Droid updates open its app page and are handled by its client. +Notification permission is used only for update reminders. The source, automatic-check setting, +skipped versions and notification times are stored in private preferences without additional +encryption. Cached APKs can be removed by clearing the app cache; clearing app data removes +update preferences as well. + ## Controls and deletion To request deletion of a diagnostic report you sent to the Message487 developer: diff --git a/README.md b/README.md index d4580f1..3bec138 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,17 @@ A custom webhook is also supported. Telegram forwarding is one possible workflow app does not depend on Telegram. **Status:** development preview with notification/SMS capture, a persistent encrypted outbox, -background delivery, automatic retries and a delivery journal. Webhook requests require a Bearer token, stored encrypted on the device. Signed APK/AAB release automation is configured; see [Releases](docs/en/releases.md). +background delivery, automatic retries and a delivery journal. Webhook requests require a Bearer token, stored encrypted on the device. Signed APK release automation is configured; see [Releases](docs/en/releases.md). ## Getting started **[Download the latest signed APK](https://github.com/andre487/AndroidMessage487/releases/latest/download/message487.apk)** +Message487 is also available from [F-Droid](https://f-droid.org/packages/life.andre.message487/). +Open **App updates** using the update icon in the top bar. The source follows the installer: +F-Droid clients use F-Droid, other named installers use GitHub. Unknown installers require a +manual choice. Daily checks can be disabled; downloads and installation require separate actions. + The stable link follows the latest published release; it does not point to development builds. Read the [release notes](https://github.com/andre487/AndroidMessage487/releases/latest) for supported features. Release 0.0.1 predates Bearer authentication. @@ -119,10 +124,10 @@ bottom navigation on phones and rail navigation on wider windows. See the [desig for the visual conventions and references. Fastlane's `debug_artifact` lane builds only the debug APK. `checks` runs JVM/Robolectric tests, -debug/release lint, and builds debug and unsigned release APKs under `app/build/outputs/apk/`, plus an unsigned AAB under -`app/build/outputs/bundle/`. +debug/release lint, and builds debug and unsigned release APKs under `app/build/outputs/apk/`. + PR CI has no release signing credentials and does not require an emulator. -For signed APK/AAB artifacts and manual Play Console upload, see [Releases](docs/en/releases.md). +For signed APK releases, see [Releases](docs/en/releases.md). Store graphics and their provenance are documented in [Branding](assets/branding/README.md). See the [project context](docs/en/project-context.md) for remaining product decisions. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca8ace0..11506f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,8 +3,23 @@ + + + + + + + + + + + + + + + @@ -47,7 +62,10 @@ - + + + diff --git a/app/src/main/java/life/andre/message487/AppUpdates.kt b/app/src/main/java/life/andre/message487/AppUpdates.kt new file mode 100644 index 0000000..3b24da2 --- /dev/null +++ b/app/src/main/java/life/andre/message487/AppUpdates.kt @@ -0,0 +1,228 @@ +package life.andre.message487 + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import org.json.JSONObject + +internal class UpdateException(val textId: Int) : Exception() + +internal enum class UpdateSource { FDROID, GITHUB } + +internal fun updateSourceForInstaller(installer: String?, handlesFdroidRepos: Boolean? = false): UpdateSource? = when { + installer.isNullOrBlank() -> null + installer == "org.fdroid.fdroid" || installer == "org.fdroid.fdroid.privileged" -> UpdateSource.FDROID + handlesFdroidRepos == null -> null + handlesFdroidRepos -> UpdateSource.FDROID + else -> UpdateSource.GITHUB +} + +internal data class AppUpdate( + val source: UpdateSource, + val version: String, + val apkUrl: String? = null, + val sha256: String? = null, + val size: Long = 0, +) + +internal const val FDROID_APP_URL = "https://f-droid.org/packages/life.andre.message487/" +internal const val GITHUB_RELEASES_API = "https://api.github.com/repos/andre487/AndroidMessage487/releases/latest" +internal const val FDROID_UPDATE_API = "https://f-droid.org/api/v1/packages/life.andre.message487" +private const val RELEASE_DOWNLOAD_PREFIX = "https://github.com/andre487/AndroidMessage487/releases/download/" +internal const val MAX_UPDATE_APK_BYTES = 200L * 1024 * 1024 + +private fun versionParts(version: String): List { + require(Regex("[0-9]+\\.[0-9]+\\.[0-9]+").matches(version)) + return version.split('.').map(String::toLong) +} + +internal fun newerVersion(candidate: String, installed: String): Boolean { + val comparison = versionParts(candidate).zip(versionParts(installed)) + return comparison.firstOrNull { (a, b) -> a != b }?.let { (a, b) -> a > b } ?: false +} + +internal fun parseGithubUpdate(json: String, installedVersion: String): AppUpdate? { + val filename = "message487.apk" + val release = JSONObject(json) + require(!release.getBoolean("draft") && !release.getBoolean("prerelease")) + val tag = release.getString("tag_name") + require(tag.startsWith("v")) + val version = tag.removePrefix("v") + if (!newerVersion(version, installedVersion)) return null + val assets = release.getJSONArray("assets") + val matches = (0 until assets.length()).map { assets.getJSONObject(it) } + .filter { it.getString("name") == filename } + require(matches.size == 1) + val asset = matches.single() + val url = asset.getString("browser_download_url") + require(url == "$RELEASE_DOWNLOAD_PREFIX$tag/$filename") + require(asset.getString("state") == "uploaded") + val digest = asset.getString("digest") + require(Regex("sha256:[a-fA-F0-9]{64}").matches(digest)) + val size = asset.getLong("size") + require(size in 1..MAX_UPDATE_APK_BYTES) + return AppUpdate(UpdateSource.GITHUB, version, url, digest.removePrefix("sha256:").lowercase(), size) +} + +internal fun parseFdroidUpdate(json: String, installedCode: Long): AppUpdate? { + val result = JSONObject(json) + require(result.getString("packageName") == "life.andre.message487") + val suggested = result.getLong("suggestedVersionCode") + if (suggested <= installedCode) return null + val packages = result.getJSONArray("packages") + val candidate = (0 until packages.length()).map { packages.getJSONObject(it) } + .firstOrNull { it.getLong("versionCode") == suggested } + requireNotNull(candidate) + return AppUpdate(UpdateSource.FDROID, candidate.getString("versionName")) +} + +internal fun validateUpdateIdentity( + packageName: String, version: String?, code: Long, signers: Set, + installedPackage: String, installedCode: Long, installedSigners: Set, expectedVersion: String, +) { + require(packageName == installedPackage && version == expectedVersion && code > installedCode) + require(signers.isNotEmpty() && signers == installedSigners) +} + +internal class AppUpdates(private val context: Context) { + private val preferences = context.getSharedPreferences("updates", Context.MODE_PRIVATE) + private val manager = context.packageManager + + @Suppress("DEPRECATION") + fun source(): UpdateSource? { + val saved = preferences.getString("source", null) + UpdateSource.entries.firstOrNull { it.name == saved }?.let { return it } + val installer = try { + if (Build.VERSION.SDK_INT >= 30) manager.getInstallSourceInfo(context.packageName).installingPackageName + else manager.getInstallerPackageName(context.packageName) + } catch (_: Exception) { null } + if (installer.isNullOrBlank()) return null + val handlesRepos = try { + listOf("fdroidrepo", "fdroidrepos").any { scheme -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://f-droid.org/repo")) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setPackage(installer) + manager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .any { it.activityInfo?.packageName == installer } + } + } catch (_: Exception) { null } + return updateSourceForInstaller(installer, handlesRepos) + } + + fun select(source: UpdateSource) { + preferences.edit().putString("source", source.name).apply() + } + + suspend fun check(source: UpdateSource): AppUpdate? = withContext(Dispatchers.IO) { + val url = if (source == UpdateSource.FDROID) FDROID_UPDATE_API else GITHUB_RELEASES_API + val json = request(url) { connection -> + if (connection.responseCode == 404) throw UpdateException(R.string.update_not_published) + require(connection.responseCode == 200) + connection.inputStream.use { stream -> + String(readUpdateMetadata(stream), Charsets.UTF_8) + } + } + if (source == UpdateSource.FDROID) parseFdroidUpdate(json, installedCode()) + else parseGithubUpdate(json, BuildConfig.VERSION_NAME) + } + + suspend fun download(update: AppUpdate): File = withContext(Dispatchers.IO) { + require(update.source == UpdateSource.GITHUB) + val directory = File(context.cacheDir, "updates").apply { mkdirs() } + val partial = File.createTempFile("update-", ".part", directory) + val apk = File(directory, partial.nameWithoutExtension + ".apk") + try { + val digest = MessageDigest.getInstance("SHA-256") + request(requireNotNull(update.apkUrl)) { connection -> + require(connection.responseCode == 200) + connection.inputStream.use { input -> + partial.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + coroutineContext.ensureActive() + val count = input.read(buffer) + if (count < 0) break + total += count + require(total <= update.size && total <= MAX_UPDATE_APK_BYTES) + digest.update(buffer, 0, count) + output.write(buffer, 0, count) + } + require(total == update.size) + } + } + } + require(digest.digest().hex() == update.sha256) + verifyApk(partial, update.version) + check(partial.renameTo(apk)) + apk + } finally { + partial.delete() + } + } + + @Suppress("DEPRECATION") + private fun installedCode(): Long = PackageInfoCompat.getLongVersionCode(manager.getPackageInfo(context.packageName, 0)) + + @Suppress("DEPRECATION") + private fun verifyApk(file: File, version: String) { + val flags = if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES + val archive = requireNotNull(manager.getPackageArchiveInfo(file.absolutePath, flags)) + val installed = manager.getPackageInfo(context.packageName, flags) + validateUpdateIdentity(archive.packageName, archive.versionName, PackageInfoCompat.getLongVersionCode(archive), + signers(archive), context.packageName, PackageInfoCompat.getLongVersionCode(installed), signers(installed), version) + } + + @Suppress("DEPRECATION") + private fun signers(info: PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= 28) info.signingInfo?.apkContentsSigners else info.signatures + return signatures.orEmpty().map { MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).hex() }.toSet() + } +} + +private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + +// GitHub release assets redirect to its CDN. Only HTTPS redirects on these exact hosts are allowed. +private fun request(url: String, read: (HttpURLConnection) -> T): T { + var current = url + repeat(6) { + val uri = URI(current) + require(uri.scheme == "https" && uri.userInfo == null && (uri.port == -1 || uri.port == 443)) + require(uri.host in setOf("api.github.com", "github.com", "release-assets.githubusercontent.com", "f-droid.org")) + val connection = uri.toURL().openConnection() as HttpURLConnection + try { + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + connection.instanceFollowRedirects = false + connection.setRequestProperty("User-Agent", "Message487-Updates") + if (connection.responseCode in listOf(301, 302, 303, 307, 308)) { + current = uri.resolve(requireNotNull(connection.getHeaderField("Location"))).toString() + } else return read(connection) + } finally { connection.disconnect() } + } + error("Too many redirects") +} + +internal fun readUpdateMetadata(input: InputStream): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) return output.toByteArray() + require(output.size() + count <= 1024 * 1024) + output.write(buffer, 0, count) + } +} diff --git a/app/src/main/java/life/andre/message487/MainActivity.kt b/app/src/main/java/life/andre/message487/MainActivity.kt index f3fc9b8..f4b99e2 100644 --- a/app/src/main/java/life/andre/message487/MainActivity.kt +++ b/app/src/main/java/life/andre/message487/MainActivity.kt @@ -1,6 +1,8 @@ package life.andre.message487 import android.os.Bundle +import android.content.Intent +import androidx.activity.compose.LocalActivity import androidx.compose.ui.platform.LocalContext import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler @@ -25,10 +27,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel class MainActivity : ComponentActivity() { + private var updateCheckRequest by mutableIntStateOf(0) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { MessageTheme { MessageScreen() } } + if (intent.action == OPEN_UPDATES_ACTION) updateCheckRequest++ + setContent { MessageTheme { MessageScreen(checkRequest = updateCheckRequest) } } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.action == OPEN_UPDATES_ACTION) updateCheckRequest++ } } @@ -41,7 +52,7 @@ private enum class Destination(val label: Int, val icon: ImageVector) { @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun MessageScreen(model: ConnectionViewModel = viewModel()) { +internal fun MessageScreen(model: ConnectionViewModel = viewModel(), checkRequest: Int = 0) { val state by model.state.collectAsStateWithLifecycle() val settings by model.settings.collectAsStateWithLifecycle() val permissions by model.permissions.collectAsStateWithLifecycle() @@ -51,13 +62,15 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel()) { var destination by rememberSaveable { mutableStateOf(Destination.OVERVIEW) } val application = LocalContext.current.applicationContext as MessageApplication var diagnostics by rememberSaveable { mutableStateOf(false) } + var updates by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(checkRequest) { if (checkRequest > 0) { updates = true; diagnostics = false } } var crash by remember { mutableStateOf(application.crashHandler.pending) } - fun back() { if (diagnostics) diagnostics = false else destination = Destination.OVERVIEW } + fun back() { if (updates) updates = false else if (diagnostics) diagnostics = false else destination = Destination.OVERVIEW } var help by rememberSaveable { mutableStateOf(false) } val snackbar = remember { SnackbarHostState() } val noticeText = state.notice?.let { stringResource(it) } LaunchedEffect(noticeText) { noticeText?.let { snackbar.showSnackbar(it) } } - BackHandler(diagnostics || destination != Destination.OVERVIEW) { back() } + BackHandler(updates || diagnostics || destination != Destination.OVERVIEW) { back() } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) model.refreshPermissions() } @@ -67,7 +80,7 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel()) { BoxWithConstraints { val wide = maxWidth >= 600.dp Row(Modifier.fillMaxSize()) { - if (wide && !diagnostics) { + if (wide && !diagnostics && !updates) { NavigationRail(Modifier.fillMaxHeight(), containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { Spacer(Modifier.height(24.dp)) Destination.entries.forEach { item -> @@ -80,24 +93,27 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel()) { modifier = Modifier.weight(1f).imePadding(), topBar = { TopAppBar(title = { - Text(stringResource(if (diagnostics) R.string.diagnostics else if (destination == Destination.OVERVIEW) R.string.app_name + Text(stringResource(if (updates) R.string.updates else if (diagnostics) R.string.diagnostics else if (destination == Destination.OVERVIEW) R.string.app_name else if (destination == Destination.CONNECTION) R.string.connection else destination.label), style = MaterialTheme.typography.titleLarge) }, navigationIcon = { - if (diagnostics || destination != Destination.OVERVIEW) { + if (updates || diagnostics || destination != Destination.OVERVIEW) { IconButton(onClick = { back() }) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.back)) } } }, actions = { - if (!diagnostics) IconButton(onClick = { diagnostics = true }) { + if (!updates) IconButton(onClick = { updates = true; diagnostics = false }) { + Icon(Icons.Outlined.SystemUpdate, stringResource(R.string.updates)) + } + if (!diagnostics && !updates) IconButton(onClick = { diagnostics = true }) { Icon(Icons.Outlined.BugReport, stringResource(R.string.diagnostics)) } IconButton(onClick = { help = true }) { Icon(Icons.Outlined.HelpOutline, stringResource(R.string.help)) } }) }, bottomBar = { - if (!wide && !diagnostics) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { + if (!wide && !diagnostics && !updates) NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) { Destination.entries.forEach { item -> NavigationBarItem(selected = destination == item, onClick = { destination = item }, icon = { Icon(item.icon, null) }, label = { Text(stringResource(item.label)) }) @@ -110,7 +126,8 @@ internal fun MessageScreen(model: ConnectionViewModel = viewModel()) { if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) key(destination) { Box(Modifier.widthIn(max = 720.dp).fillMaxSize()) { - if (diagnostics) DiagnosticsScreen(application, settings) else when (destination) { + if (updates) UpdatesScreen(requireNotNull(LocalActivity.current), checkRequest = checkRequest) + else if (diagnostics) DiagnosticsScreen(application, settings) else when (destination) { Destination.OVERVIEW -> OverviewScreen(settings, permissions, connected, queue, state.busy, model, onConnection = { destination = Destination.CONNECTION }, onSources = { destination = Destination.SOURCES }, onJournal = { destination = Destination.JOURNAL }) diff --git a/app/src/main/java/life/andre/message487/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index 9d39ada..8ccaff1 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -21,6 +21,7 @@ open class MessageApplication : Application() { crashHandler.install() diagnostics.record(DiagnosticEvent.APP_STARTED) graph.start() + UpdateNotifications.schedule(this) } } diff --git a/app/src/main/java/life/andre/message487/UpdateNotifications.kt b/app/src/main/java/life/andre/message487/UpdateNotifications.kt new file mode 100644 index 0000000..1e654fa --- /dev/null +++ b/app/src/main/java/life/andre/message487/UpdateNotifications.kt @@ -0,0 +1,156 @@ +package life.andre.message487 + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.job.JobInfo +import android.app.job.JobParameters +import android.app.job.JobScheduler +import android.app.job.JobService +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +internal val UPDATE_REMINDER_INTERVAL = TimeUnit.DAYS.toMillis(7) +internal const val UPDATE_JOB_ID = 48702 +internal const val UPDATE_NOTIFICATION_ID = 48702 +internal const val UPDATE_NOTIFICATION_CHANNEL = "app_updates" +internal const val OPEN_UPDATES_ACTION = "life.andre.message487.OPEN_UPDATES" +internal const val SKIP_UPDATE_ACTION = "life.andre.message487.SKIP_UPDATE" +internal const val DISABLE_UPDATES_ACTION = "life.andre.message487.DISABLE_UPDATES" + +internal fun shouldNotifyUpdate(key: String, skipped: String?, lastKey: String?, lastTime: Long, now: Long): Boolean = + key != skipped && (key != lastKey || now - lastTime >= UPDATE_REMINDER_INTERVAL) + +internal class UpdatePreferences(context: Context) { + private val prefs = context.getSharedPreferences("updates", Context.MODE_PRIVATE) + var automatic: Boolean + get() = prefs.getBoolean("automatic", true) + set(value) { prefs.edit().putBoolean("automatic", value).apply() } + + fun shouldNotify(update: AppUpdate, now: Long): Boolean = automatic && shouldNotifyUpdate( + key(update), key(update).takeIf { it in prefs.getStringSet("skipped_versions", emptySet()).orEmpty() }, prefs.getString("notified", null), prefs.getLong("notified_at", 0), now) + + fun notified(update: AppUpdate, now: Long) { + prefs.edit().putString("notified", key(update)).putLong("notified_at", now).apply() + } + + fun skip(key: String) { + val skipped = prefs.getStringSet("skipped_versions", emptySet()).orEmpty() + key + prefs.edit().putStringSet("skipped_versions", skipped).apply() + } + + companion object { fun key(update: AppUpdate) = "${update.source.name}:${update.version}" } +} + +internal object UpdateNotifications { + fun schedule(context: Context) { + val scheduler = context.getSystemService(JobScheduler::class.java) + if (!UpdatePreferences(context).automatic || AppUpdates(context).source() == null) { + scheduler.cancel(UPDATE_JOB_ID) + cancel(context) + } else if (scheduler.getPendingJob(UPDATE_JOB_ID) == null) { + scheduler.schedule(JobInfo.Builder(UPDATE_JOB_ID, ComponentName(context, UpdateCheckService::class.java)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPeriodic(TimeUnit.DAYS.toMillis(1), TimeUnit.HOURS.toMillis(1)) + .setPersisted(true) + .build()) + } + } + + fun cancel(context: Context) = context.getSystemService(NotificationManager::class.java).cancel(UPDATE_NOTIFICATION_ID) + + fun enabled(context: Context): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() && + context.getSystemService(NotificationManager::class.java).getNotificationChannel(UPDATE_NOTIFICATION_CHANNEL) + ?.importance != NotificationManager.IMPORTANCE_NONE + + fun show(context: Context, update: AppUpdate, now: Long = System.currentTimeMillis()) { + val prefs = UpdatePreferences(context) + // A user can change source or disable checks while a request is in flight. + if (AppUpdates(context).source() != update.source || !prefs.shouldNotify(update, now)) return + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(NotificationChannel(UPDATE_NOTIFICATION_CHANNEL, + context.getString(R.string.updates), NotificationManager.IMPORTANCE_DEFAULT)) + if (!enabled(context)) return + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val open = PendingIntent.getActivity(context, UPDATE_NOTIFICATION_ID, + Intent(context, MainActivity::class.java).setAction(OPEN_UPDATES_ACTION) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), flags) + fun action(action: String) = PendingIntent.getBroadcast(context, UPDATE_NOTIFICATION_ID, + Intent(context, UpdateActionReceiver::class.java).setAction(action) + .putExtra("version_key", UpdatePreferences.key(update)), flags) + val source = context.getString(if (update.source == UpdateSource.FDROID) R.string.update_fdroid else R.string.update_github) + val notification = NotificationCompat.Builder(context, UPDATE_NOTIFICATION_CHANNEL) + .setSmallIcon(R.drawable.ic_update) + .setContentTitle(context.getString(R.string.update_available, update.version)) + .setContentText(context.getString(R.string.update_notification_text, source)) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.update_notification_text, source))) + .setContentIntent(open).setAutoCancel(true) + .addAction(0, context.getString(R.string.update_notification_open), open) + .addAction(0, context.getString(R.string.update_skip), action(SKIP_UPDATE_ACTION)) + .addAction(0, context.getString(R.string.update_disable), action(DISABLE_UPDATES_ACTION)) + .build() + try { + manager.notify(UPDATE_NOTIFICATION_ID, notification) + prefs.notified(update, now) + } catch (_: SecurityException) { /* Notification permission may be revoked during the check. */ } + } +} + +class UpdateActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val prefs = UpdatePreferences(context) + when (intent.action) { + SKIP_UPDATE_ACTION -> { + val key = intent.getStringExtra("version_key") ?: return + prefs.skip(key) + } + DISABLE_UPDATES_ACTION -> prefs.automatic = false + else -> return + } + UpdateNotifications.cancel(context) + UpdateNotifications.schedule(context) + } +} + +class UpdateCheckService : JobService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var check: Job? = null + + override fun onStartJob(params: JobParameters): Boolean { + val updates = AppUpdates(this) + val source = updates.source() ?: return false + if (!UpdatePreferences(this).automatic) return false + check = scope.launch { + try { + val update = updates.check(source) + if (update != null) UpdateNotifications.show(this@UpdateCheckService, update) + else UpdateNotifications.cancel(this@UpdateCheckService) + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { /* Retry at the next scheduled check; never switch source on failure. */ } + jobFinished(params, false) + } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + check?.cancel() + return UpdatePreferences(this).automatic + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} diff --git a/app/src/main/java/life/andre/message487/UpdatesScreen.kt b/app/src/main/java/life/andre/message487/UpdatesScreen.kt new file mode 100644 index 0000000..5dd3508 --- /dev/null +++ b/app/src/main/java/life/andre/message487/UpdatesScreen.kt @@ -0,0 +1,235 @@ +package life.andre.message487 + +import android.Manifest +import android.os.Build +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Switch +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.Modifier +import androidx.core.content.FileProvider +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.unit.dp + +internal class UpdatesViewModel @JvmOverloads constructor( + application: Application, + private val updates: AppUpdates = AppUpdates(application), + private val checkUpdate: suspend (UpdateSource) -> AppUpdate? = updates::check, + private val downloadUpdate: suspend (AppUpdate) -> File = updates::download, +) : AndroidViewModel(application) { + var automatic by mutableStateOf(UpdatePreferences(application).automatic) + private set + var source by mutableStateOf(updates.source()) + private set + var busy by mutableStateOf(false) + private set + var update by mutableStateOf(null) + private set + var apk by mutableStateOf(null) + private set + var message by mutableStateOf(null) + private set + + fun select(value: UpdateSource) { + if (busy) return + updates.select(value) + UpdateNotifications.cancel(getApplication()) + UpdateNotifications.schedule(getApplication()) + source = value + update = null + apk = null + message = null + } + + private var handledCheckRequest = 0 + + fun checkFromNotification(request: Int) { + if (request <= 0 || request == handledCheckRequest) return + handledCheckRequest = request + check() + } + + fun check() { + val selected = source ?: return + if (busy) return + update = null + apk = null + runOperation { + update = checkUpdate(selected) + if (update == null) message = R.string.update_current + } + } + + fun download() { + val candidate = update ?: return + if (busy || candidate.source != UpdateSource.GITHUB) return + runOperation { apk = downloadUpdate(candidate) } + } + + fun automatic(value: Boolean) { + UpdatePreferences(getApplication()).automatic = value + automatic = value + UpdateNotifications.schedule(getApplication()) + } + + fun refresh() { + automatic = UpdatePreferences(getApplication()).automatic + } + + fun reportOpenError() { message = R.string.update_open_error } + + private fun runOperation(operation: suspend () -> Unit) { + busy = true + message = null + viewModelScope.launch { + try { operation() } + catch (cancelled: CancellationException) { throw cancelled } + catch (error: Exception) { message = (error as? UpdateException)?.textId ?: R.string.update_failed } + finally { busy = false } + } + } +} + +@Composable +internal fun UpdatesScreen(activity: Activity, model: UpdatesViewModel = viewModel(), checkRequest: Int = 0) { + val lifecycle = LocalLifecycleOwner.current + var notificationsEnabled by remember { mutableStateOf(UpdateNotifications.enabled(activity)) } + val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + notificationsEnabled = UpdateNotifications.enabled(activity) + } + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + model.refresh() + notificationsEnabled = UpdateNotifications.enabled(activity) + } + } + lifecycle.lifecycle.addObserver(observer) + onDispose { lifecycle.lifecycle.removeObserver(observer) } + } + LaunchedEffect(checkRequest) { + model.checkFromNotification(checkRequest) + } + var consent by rememberSaveable { mutableStateOf(false) } + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.update_installed, BuildConfig.VERSION_NAME)) + Text(stringResource(R.string.update_manual_description)) + Text(stringResource(R.string.update_automatic)) + Switch(checked = model.automatic, onCheckedChange = model::automatic, + modifier = Modifier.semantics { contentDescription = activity.getString(R.string.update_automatic) }) + if (!notificationsEnabled) { + Text(stringResource(R.string.update_notifications_disabled)) + TextButton(onClick = { + try { + if (Build.VERSION.SDK_INT >= 33 && activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + activity.startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)) + } + } catch (_: Exception) { model.reportOpenError() } + }) { Text(stringResource(R.string.update_allow_notifications)) } + } + Text(stringResource(R.string.update_source), style = MaterialTheme.typography.titleMedium) + if (model.source == null) Text(stringResource(R.string.update_choose_source)) + UpdateSource.entries.forEach { source -> + val label = stringResource(if (source == UpdateSource.FDROID) R.string.update_fdroid else R.string.update_github) + TextButton( + onClick = { model.select(source) }, enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (model.source == source) stringResource(R.string.update_selected_source, label) else label) + } + } + Button(onClick = model::check, enabled = model.source != null && !model.busy) { + Text(stringResource(R.string.update_check)) + } + if (model.busy) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(R.string.update_working)) + } + model.message?.let { Text(stringResource(it)) } + model.update?.let { update -> + Text(stringResource(R.string.update_available, update.version), style = MaterialTheme.typography.titleMedium) + if (update.source == UpdateSource.FDROID) { + Button(onClick = { + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(FDROID_APP_URL))) + } catch (_: Exception) { model.reportOpenError() } + }) { Text(stringResource(R.string.update_in_fdroid)) } + } else if (model.apk == null) { + Button(onClick = { consent = true }, enabled = !model.busy) { + Text(stringResource(R.string.update_download)) + } + } else { + Text(stringResource(R.string.update_ready)) + Text(stringResource(R.string.update_install_permission)) + Button(onClick = { + try { + if (!activity.packageManager.canRequestPackageInstalls()) { + activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${activity.packageName}"))) + } else { + val apk = requireNotNull(model.apk) + require(apk.isFile) + val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", apk) + activity.startActivity(Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + } + } catch (_: Exception) { model.reportOpenError() } + }, enabled = !model.busy) { Text(stringResource(R.string.update_install)) } + } + } + } + if (consent) { + AlertDialog( + onDismissRequest = { consent = false }, + title = { Text(stringResource(R.string.update_download)) }, + text = { Column(Modifier.verticalScroll(rememberScrollState())) { + Text(stringResource(R.string.update_github_consent)) + } }, + confirmButton = { TextButton(onClick = { consent = false; model.download() }) { + Text(stringResource(R.string.update_download)) + } }, + dismissButton = { TextButton(onClick = { consent = false }) { Text(stringResource(R.string.cancel)) } }, + ) + } +} diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 0000000..72a7654 --- /dev/null +++ b/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1717359..22c5a37 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -125,4 +125,33 @@ Не удалось открыть политику конфиденциальности: браузер недоступен. Не отправлять дубли Пропускать сообщения с одинаковыми приложением, исходным временем и текстом. Отключите, чтобы пересылать и повторные сообщения. + + Обновления приложения + Установленная версия: %1$s + Проверка выполняется примерно раз в сутки при наличии сети. О проигнорированном обновлении напомним через неделю. APK скачивается только с вашего согласия. + Источник обновлений + Android не сообщил источник установки. Выберите источник обновлений. + F-Droid + GitHub + Выбран: %1$s + Проверить обновления + В этом источнике нет более новой версии. + Приложение или релиз ещё не опубликованы в этом источнике. Источник не изменён. + Не удалось проверить или безопасно скачать обновление. Проверьте соединение и повторите попытку. Установленное приложение не изменено. + Не удалось открыть магазин, браузер или установщик Android. Если скачанный файл был удалён, проверьте и скачайте обновление заново. + Проверка или скачивание… + Новая версия: %1$s + Обновить через F-Droid + Скачать с GitHub + APK скачан и проверен. Android проверит его при установке. + Нажмите «Установить обновление». Если Android запросит разрешение, разрешите установку из Message487, вернитесь сюда и нажмите кнопку ещё раз. Подтвердите обновление в системном установщике. + Установить обновление + APK будет скачан с GitHub в обход проверок F-Droid. Эта версия могла ещё не пройти проверку исходного кода и воспроизводимости в F-Droid. Продолжайте, только если доверяете этому источнику. Можно отменить скачивание и продолжить пользоваться установленной версией. Установка запускается отдельным действием. + Проверять обновления автоматически + Уведомления отключены. Разрешите их, чтобы получать напоминания об обновлениях. + Разрешить уведомления + Доступно в %1$s. Если проигнорировать, напомним через неделю. + Обновить + Пропустить версию + Отключить автопроверку diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac1e9cb..4336245 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,4 +125,33 @@ No browser is available to open the privacy policy. Skip duplicate messages Skip messages with exactly the same app, original timestamp and text. Turn off to forward repeated messages too. + + App updates + Installed version: %1$s + Checks run about once a day when online. An ignored update is announced again after a week. APKs are downloaded only with your consent. + Update source + Android did not identify the installer. Choose an update source. + F-Droid + GitHub + Selected: %1$s + Check for updates + No newer version is available from this source. + The app or release is not published in this source yet. The source has not been changed. + Could not check or download the update safely. Check your connection and retry. The installed app has not been changed. + Could not open the store, browser, or Android installer. If the download was cleared, check and download again. + Checking or downloading… + New version: %1$s + Update through F-Droid + Download from GitHub + The APK has been downloaded and checked. Android will verify it during installation. + Tap Install update. If Android asks, allow installs from Message487, return here and tap the button again. Confirm the update in the system installer. + Install update + The APK will be downloaded from GitHub, outside F-Droid’s checks. It may not have been reviewed or reproduced by F-Droid. Continue only if you trust this source. You can cancel and keep using the installed version. Installation requires a separate action. + Check for updates automatically + Notifications are disabled. Allow them to receive update reminders. + Allow notifications + Available from %1$s. If ignored, we will remind you in a week. + Update + Skip this version + Disable auto-checks diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 0df194a..e394e38 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/app/src/test/java/life/andre/message487/AppUpdatesTest.kt b/app/src/test/java/life/andre/message487/AppUpdatesTest.kt new file mode 100644 index 0000000..ebb5c54 --- /dev/null +++ b/app/src/test/java/life/andre/message487/AppUpdatesTest.kt @@ -0,0 +1,96 @@ +package life.andre.message487 + +import java.io.ByteArrayInputStream +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class AppUpdatesTest { + @Test fun installerSelectionDoesNotGuessWhenUnknown() { + assertNull(updateSourceForInstaller(null)) + assertNull(updateSourceForInstaller("")) + assertEquals(UpdateSource.FDROID, updateSourceForInstaller("org.example.fdroid", true)) + assertNull(updateSourceForInstaller("org.example.fdroid", null)) + assertEquals(UpdateSource.FDROID, updateSourceForInstaller("org.fdroid.fdroid")) + assertEquals(UpdateSource.FDROID, updateSourceForInstaller("org.fdroid.fdroid.privileged")) + for (installer in listOf("com.android.chrome", "com.android.documentsui", "org.example.other", "life.andre.message487")) { + assertEquals(UpdateSource.GITHUB, updateSourceForInstaller(installer)) + } + } + + private fun github(tag: String = "v0.1.2"): JSONObject = JSONObject().apply { + put("tag_name", tag); put("draft", false); put("prerelease", false) + put("assets", JSONArray().put(JSONObject().apply { + put("name", "message487.apk"); put("state", "uploaded"); put("size", 123) + put("digest", "sha256:" + "ab".repeat(32)) + put("browser_download_url", "https://github.com/andre487/AndroidMessage487/releases/download/$tag/message487.apk") + })) + } + + @Test fun githubUsesStableAssetAndComparesVersionsNumerically() { + assertNull(parseGithubUpdate(github().toString(), "0.1.2")) + assertNull(parseGithubUpdate(github().toString(), "0.2.0")) + assertTrue(newerVersion("0.10.0", "0.9.9")) + val update = parseGithubUpdate(github().toString(), "0.1.1")!! + assertEquals("0.1.2", update.version) + assertEquals(UpdateSource.GITHUB, update.source) + assertEquals(123L, update.size) + assertEquals("ab".repeat(32), update.sha256) + } + + @Test fun githubRejectsUnsafeOrIncompleteAssetsAndPrereleases() { + fun reject(change: (JSONObject) -> Unit) { + val release = github().also(change) + assertThrows(IllegalArgumentException::class.java) { parseGithubUpdate(release.toString(), "0.1.1") } + } + reject { it.put("prerelease", true) } + reject { it.put("draft", true) } + reject { it.put("tag_name", "v0.1.2-beta") } + reject { it.getJSONArray("assets").getJSONObject(0).put("browser_download_url", "https://evil.example/update.apk") } + reject { it.getJSONArray("assets").getJSONObject(0).put("size", MAX_UPDATE_APK_BYTES + 1) } + reject { it.getJSONArray("assets").getJSONObject(0).put("digest", "sha256:bad") } + reject { it.put("assets", JSONArray()) } + reject { val assets = it.getJSONArray("assets"); assets.put(assets.getJSONObject(0)) } + } + + @Test fun fdroidUsesSuggestedPublishedVersionNotNewestUnstableVersion() { + val json = """{"packageName":"life.andre.message487","suggestedVersionCode":15000,"packages":[ + {"versionName":"0.2.0-beta","versionCode":16000},{"versionName":"0.1.2","versionCode":15000}]}""" + assertEquals("0.1.2", parseFdroidUpdate(json, 14004)!!.version) + assertNull(parseFdroidUpdate(json, 15000)) + assertNull(parseFdroidUpdate(json, 15004)) + assertThrows(IllegalArgumentException::class.java) { parseFdroidUpdate(json.replace("life.andre.message487", "other"), 14000) } + assertThrows(IllegalArgumentException::class.java) { parseFdroidUpdate(json.replace("\"suggestedVersionCode\":15000", "\"suggestedVersionCode\":17000"), 14000) } + } + + @Test fun apkIdentityRejectsWrongPackageSignerVersionAndDowngrade() { + fun verify(pkg: String = "life.andre.message487", version: String = "0.1.2", code: Long = 15004, + signatures: Set = setOf("release")) = validateUpdateIdentity( + pkg, version, code, signatures, "life.andre.message487", 14004, setOf("release"), "0.1.2") + verify() + assertThrows(IllegalArgumentException::class.java) { verify(pkg = "other") } + assertThrows(IllegalArgumentException::class.java) { verify(version = "0.1.1") } + assertThrows(IllegalArgumentException::class.java) { verify(code = 14000) } + assertThrows(IllegalArgumentException::class.java) { verify(code = 14004) } + assertThrows(IllegalArgumentException::class.java) { verify(signatures = setOf("debug")) } + assertThrows(IllegalArgumentException::class.java) { verify(signatures = emptySet()) } + assertThrows(IllegalArgumentException::class.java) { verify(signatures = setOf("release", "other")) } + } + + @Test fun ignoredUpdateWaitsAWeekAndSkippedVersionStaysSkipped() { + val time = 1_000_000L + assertTrue(shouldNotifyUpdate("GITHUB:2", null, null, 0, time)) + assertFalse(shouldNotifyUpdate("GITHUB:2", null, "GITHUB:2", time, time + UPDATE_REMINDER_INTERVAL - 1)) + assertTrue(shouldNotifyUpdate("GITHUB:2", null, "GITHUB:2", time, time + UPDATE_REMINDER_INTERVAL)) + assertFalse(shouldNotifyUpdate("GITHUB:2", "GITHUB:2", "GITHUB:2", time, time + UPDATE_REMINDER_INTERVAL * 2)) + assertTrue(shouldNotifyUpdate("GITHUB:3", "GITHUB:2", "GITHUB:2", time, time + 1)) + assertTrue(shouldNotifyUpdate("FDROID:2", "GITHUB:2", "GITHUB:2", time, time + 1)) + assertFalse(shouldNotifyUpdate("GITHUB:2", null, "GITHUB:2", time, time - 1)) + } + + @Test fun oversizedMetadataIsRejectedBeforeParsing() { + assertArrayEquals(byteArrayOf(1, 2), readUpdateMetadata(ByteArrayInputStream(byteArrayOf(1, 2)))) + assertThrows(IllegalArgumentException::class.java) { readUpdateMetadata(ByteArrayInputStream(ByteArray(1024 * 1024 + 1))) } + } +} diff --git a/app/src/test/java/life/andre/message487/InstallerUpdateSourceTest.kt b/app/src/test/java/life/andre/message487/InstallerUpdateSourceTest.kt new file mode 100644 index 0000000..fb0d62c --- /dev/null +++ b/app/src/test/java/life/andre/message487/InstallerUpdateSourceTest.kt @@ -0,0 +1,77 @@ +package life.andre.message487 + +import android.app.Application +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 35], application = Application::class) +class InstallerUpdateSourceTest { + private val app get() = RuntimeEnvironment.getApplication() + + @Before fun clearSource() { + app.getSharedPreferences("updates", 0).edit().clear().commit() + } + + @Suppress("DEPRECATION") + private fun installer(name: String?) { + if (Build.VERSION.SDK_INT >= 30) { + shadowOf(app.packageManager).setInstallSourceInfo(app.packageName, name, name) + } else { + app.packageManager.setInstallerPackageName(app.packageName, name) + } + } + + @Suppress("DEPRECATION") + private fun handlesRepository(name: String, scheme: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$scheme://f-droid.org/repo")) + .addCategory(Intent.CATEGORY_BROWSABLE).setPackage(name) + shadowOf(app.packageManager).addResolveInfoForIntent(intent, ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = name + this.name = "$name.RepositoryActivity" + exported = true + enabled = true + } + }) + } + + @Test fun detectsAlternativeClientWithEitherRepositoryScheme() { + for (scheme in listOf("fdroidrepo", "fdroidrepos")) { + val name = "org.example.$scheme" + installer(name) + handlesRepository(name, scheme) + assertEquals(UpdateSource.FDROID, AppUpdates(app).source()) + } + } + + @Test fun unrelatedFdroidClientDoesNotChangeBrowserInstallerSource() { + handlesRepository("org.example.store", "fdroidrepos") + installer("com.android.chrome") + assertEquals(UpdateSource.GITHUB, AppUpdates(app).source()) + } + + @Test fun unknownInstallerRequiresChoiceEvenWithFdroidInstalled() { + handlesRepository("org.example.store", "fdroidrepo") + installer(null) + assertNull(AppUpdates(app).source()) + } + + @Test fun manualSourceTakesPriorityAndOfficialPrivilegedInstallerStillWorks() { + installer("org.fdroid.fdroid.privileged") + assertEquals(UpdateSource.FDROID, AppUpdates(app).source()) + AppUpdates(app).select(UpdateSource.GITHUB) + assertEquals(UpdateSource.GITHUB, AppUpdates(app).source()) + } +} diff --git a/app/src/test/java/life/andre/message487/ResourceContractTest.kt b/app/src/test/java/life/andre/message487/ResourceContractTest.kt index 511ebdd..5278b2f 100644 --- a/app/src/test/java/life/andre/message487/ResourceContractTest.kt +++ b/app/src/test/java/life/andre/message487/ResourceContractTest.kt @@ -39,8 +39,8 @@ class ResourceContractTest { assertEquals(listOf(".MainActivity"), elements("activity") .filter { it.getAttributeNS(namespace, "exported") == "true" }.map { it.getAttributeNS(namespace, "name") }) assertEquals("android.permission.BIND_NOTIFICATION_LISTENER_SERVICE", - elements("service").single().getAttributeNS(namespace, "permission")) - assertEquals("android.permission.BROADCAST_SMS", elements("receiver").single().getAttributeNS(namespace, "permission")) + elements("service").single { it.getAttributeNS(namespace, "name") == ".NotificationCaptureService" }.getAttributeNS(namespace, "permission")) + assertEquals("android.permission.BROADCAST_SMS", elements("receiver").single { it.getAttributeNS(namespace, "name") == ".SmsCaptureReceiver" }.getAttributeNS(namespace, "permission")) val provider = elements("provider").single() assertEquals("false", provider.getAttributeNS(namespace, "exported")) assertEquals("true", provider.getAttributeNS(namespace, "grantUriPermissions")) @@ -48,8 +48,10 @@ class ResourceContractTest { assertFalse(elements("uses-permission").any { it.getAttributeNS(namespace, "name") == "android.permission.QUERY_ALL_PACKAGES" }) val paths = document("res/xml/file_paths.xml").documentElement.childNodes val roots = (0 until paths.length).mapNotNull { paths.item(it) as? Element } - assertEquals(1, roots.size) - assertEquals("cache-path", roots.single().tagName) - assertEquals("feedback/", roots.single().getAttribute("path")) + assertEquals(setOf("feedback/", "updates/"), roots.map { it.getAttribute("path") }.toSet()) + assertTrue(roots.all { it.tagName == "cache-path" }) + assertEquals("android.permission.BIND_JOB_SERVICE", + elements("service").single { it.getAttributeNS(namespace, "name") == ".UpdateCheckService" }.getAttributeNS(namespace, "permission")) + assertEquals("false", elements("receiver").single { it.getAttributeNS(namespace, "name") == ".UpdateActionReceiver" }.getAttributeNS(namespace, "exported")) } } diff --git a/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt new file mode 100644 index 0000000..28cc94e --- /dev/null +++ b/app/src/test/java/life/andre/message487/UpdateNotificationsTest.kt @@ -0,0 +1,81 @@ +package life.andre.message487 + +import android.app.Application +import android.app.NotificationManager +import android.app.job.JobScheduler +import android.content.Intent +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class UpdateNotificationsTest { + private val app get() = RuntimeEnvironment.getApplication() + private val scheduler get() = app.getSystemService(JobScheduler::class.java) + private val notifications get() = app.getSystemService(NotificationManager::class.java) + + @Before fun reset() { + app.getSharedPreferences("updates", 0).edit().clear().commit() + scheduler.cancelAll() + notifications.cancelAll() + shadowOf(app).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + } + + @Test fun defaultsEnabledButUnknownInstallerSchedulesNothing() { + assertTrue(UpdatePreferences(app).automatic) + UpdateNotifications.schedule(app) + assertNull(scheduler.getPendingJob(UPDATE_JOB_ID)) + AppUpdates(app).select(UpdateSource.GITHUB) + UpdateNotifications.schedule(app) + val job = scheduler.getPendingJob(UPDATE_JOB_ID)!! + assertTrue(job.isPeriodic) + assertTrue(job.isPersisted) + assertEquals(java.util.concurrent.TimeUnit.DAYS.toMillis(1), job.intervalMillis) + } + + @Test fun notificationActionsSkipOnlyThatVersionAndDisableCancelsJob() { + AppUpdates(app).select(UpdateSource.GITHUB) + UpdateNotifications.schedule(app) + val update = AppUpdate(UpdateSource.GITHUB, "0.1.2") + UpdateNotifications.show(app, update, 1000) + val notification = shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID) + assertNotNull(notification) + assertEquals(3, notification.actions.size) + val open = shadowOf(notification.actions[0].actionIntent).savedIntent + assertEquals(OPEN_UPDATES_ACTION, open.action) + assertEquals(MainActivity::class.java.name, open.component!!.className) + UpdateActionReceiver().onReceive(app, shadowOf(notification.actions[1].actionIntent).savedIntent) + assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) + assertFalse(UpdatePreferences(app).shouldNotify(update, UPDATE_REMINDER_INTERVAL * 2)) + assertTrue(UpdatePreferences(app).shouldNotify(update.copy(version = "0.1.3"), 1001)) + UpdateActionReceiver().onReceive(app, shadowOf(notification.actions[2].actionIntent).savedIntent) + assertFalse(UpdatePreferences(app).automatic) + assertNull(scheduler.getPendingJob(UPDATE_JOB_ID)) + } + + @Test fun reminderPersistsAndDoesNotPostBeforeOneWeek() { + AppUpdates(app).select(UpdateSource.FDROID) + val update = AppUpdate(UpdateSource.FDROID, "0.1.2") + UpdateNotifications.show(app, update, 1000) + notifications.cancelAll() + UpdateNotifications.show(app, update, 1000 + UPDATE_REMINDER_INTERVAL - 1) + assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) + UpdateNotifications.show(app, update, 1000 + UPDATE_REMINDER_INTERVAL) + assertNotNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) + } + + @Test fun changedSourceOrDisabledChecksSuppressInFlightResult() { + AppUpdates(app).select(UpdateSource.FDROID) + UpdateNotifications.show(app, AppUpdate(UpdateSource.GITHUB, "0.1.2")) + assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) + UpdatePreferences(app).automatic = false + UpdateNotifications.show(app, AppUpdate(UpdateSource.FDROID, "0.1.2")) + assertNull(shadowOf(notifications).getNotification(UPDATE_NOTIFICATION_ID)) + } +} diff --git a/app/src/test/java/life/andre/message487/UpdatesUiTest.kt b/app/src/test/java/life/andre/message487/UpdatesUiTest.kt new file mode 100644 index 0000000..4c74316 --- /dev/null +++ b/app/src/test/java/life/andre/message487/UpdatesUiTest.kt @@ -0,0 +1,67 @@ +package life.andre.message487 + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [35], qualifiers = "en-w411dp-h891dp") +@LooperMode(LooperMode.Mode.PAUSED) +class UpdatesUiTest { + @get:Rule val compose = createAndroidComposeRule() + private fun node(id: Int) = compose.onNodeWithText(compose.activity.getString(id)) + + @Before fun reset() { + compose.activity.getSharedPreferences("updates", 0).edit().clear().commit() + } + + @Test fun unknownSourceRequiresChoiceAndDownloadRequiresConsent() { + var downloads = 0 + val model = UpdatesViewModel(compose.activity.application, + checkUpdate = { AppUpdate(it, "0.0.6") }, + downloadUpdate = { downloads++; error("Test failure") }) + compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + node(R.string.update_check).assertIsNotEnabled() + node(R.string.update_github).performScrollTo().performClick() + node(R.string.update_check).performScrollTo().performClick() + node(R.string.update_download).performScrollTo().performClick() + node(R.string.update_github_consent).assertIsDisplayed() + node(R.string.cancel).performClick() + compose.runOnIdle { assertEquals(0, downloads) } + node(R.string.update_download).performScrollTo().performClick() + compose.onNode(hasText(compose.activity.getString(R.string.update_download)) and hasClickAction() and hasAnyAncestor(isDialog())).performClick() + node(R.string.update_failed).assertExists() + compose.runOnIdle { assertEquals(1, downloads); assertNull(model.apk) } + } + + @Test fun fdroidOpensStoreAndFailureDoesNotFallBackToGithub() { + var fail = false + val model = UpdatesViewModel(compose.activity.application, + checkUpdate = { if (fail) throw UpdateException(R.string.update_not_published) else AppUpdate(it, "0.0.6") }, + downloadUpdate = { error("F-Droid must never download an APK here") }) + model.select(UpdateSource.FDROID) + compose.setContent { MessageTheme { UpdatesScreen(compose.activity, model) } } + node(R.string.update_check).performScrollTo().performClick() + node(R.string.update_in_fdroid).performScrollTo().performClick() + compose.runOnIdle { + assertEquals(FDROID_APP_URL, shadowOf(compose.activity).nextStartedActivity.dataString) + model.download() + assertNull(model.apk) + fail = true + } + node(R.string.update_check).performScrollTo().performClick() + node(R.string.update_not_published).assertExists() + node(R.string.update_download).assertDoesNotExist() + assertEquals(UpdateSource.FDROID, model.source) + } +} diff --git a/docs/en/apk-installation.md b/docs/en/apk-installation.md index 95c9996..380069c 100644 --- a/docs/en/apk-installation.md +++ b/docs/en/apk-installation.md @@ -2,6 +2,23 @@ [English](../en/apk-installation.md) | [Русский](../ru/apk-installation.md) +## App updates + +Open **App updates** from the update icon in the top bar. F-Droid installers, including +alternative clients that handle F-Droid repository links, use the F-Droid package API. +Other identified installers use GitHub Releases. If Android cannot identify the source, +choose it manually. Your saved choice takes priority; errors never switch the source. + +Automatic checks run about once a day when online. Allow notifications to receive reminders; +ignored versions repeat after a week. You can skip a version or disable automatic checks. +**Check for updates** also works manually. F-Droid updates open the app page in its client +(or browser). GitHub updates require confirmation to download, then a separate **Install update** +action. The app checks SHA-256, package, increasing version code and the installed signing +certificate before opening Android's installer. Allow installation from Message487 when asked, +return to the update screen and press **Install update** again. Different signing keys cannot +update one another; keep the existing app data and use the matching distribution. + + ## Download and install 1. Open the project's [GitHub Releases](https://github.com/andre487/AndroidMessage487/releases) diff --git a/docs/en/releases.md b/docs/en/releases.md index 976fa30..e04aee6 100644 --- a/docs/en/releases.md +++ b/docs/en/releases.md @@ -1,21 +1,19 @@ -# Signed APK and App Bundle releases +# Signed APK releases [English](../en/releases.md) | [Русский](../ru/releases.md) Installing on a phone? See [APK installation and Android restrictions](apk-installation.md). Run `bundle exec fastlane android release_artifacts` with JDK 21 and Android SDK 36. -The lane runs Android JVM/Compose tests and debug/release lint, then builds signed APK and AAB -from the same release variant. It checks the APK certificate, package/version and non-debuggable -flag, and verifies the certificate and signature of every AAB payload entry. +The lane runs Android JVM/Compose tests and debug/release lint, then builds a signed release APK. It checks the APK certificate, package/version and non-debuggable +flag. | Output in `dist/release/` | Purpose | | --- | --- | | `message487-.apk` | Versioned signed APK | | `message487.apk` | Byte-identical APK with a stable download filename | -| `message487.aab` | Signed Android App Bundle for Google Play | | `mapping.txt` | R8 mapping for this exact build | -| `SHA256SUMS` | Checksums for both APK names, AAB and mapping | +| `SHA256SUMS` | Checksums for both APK names and mapping | The [permanent APK link](https://github.com/andre487/AndroidMessage487/releases/latest/download/message487.apk) follows GitHub's latest published release, not the current branch. Existing versioned URLs keep @@ -36,7 +34,7 @@ Signing follows MegaProxy's environment contract with the `MESSAGE487_` prefix: Gradle reads only the four signing environment variables (path, store password, alias and key password). Partial signing configuration fails closed. The PR `checks` lane rejects signing inputs -and verifies unsigned release APK and AAB files. Passwords are not command-line arguments and must never be +and verifies unsigned release APK files. Passwords are not command-line arguments and must never be printed, committed or passed through Gradle `-P` properties. GitHub Secrets use the same names as MegaProxy: `ANDROID_SIGNING_KEY_BASE64`, @@ -47,109 +45,18 @@ GitHub Release. PR workflows do not consume signing secrets. ## Verification and publication -- Push a `release-check/*` tag to build and verify signed APK/AAB files in GitHub Actions without publishing - a Release. The signed APK/AAB files, checksums, mapping and test reports are available as Actions artifacts. +- Push a `release-check/*` tag to build and verify signed APK files in GitHub Actions without publishing + a Release. The signed APK files, checksums, mapping and test reports are available as Actions artifacts. - After the workflow is merged into the default branch, manual dispatch also builds artifacts only. - For publication, increment `versionCode`, set the intended `versionName` in `app/build.gradle.kts`, and merge the reviewed change after all required PR checks pass. Push the matching `v` tag. The workflow requires the tag commit to be contained in `main` and rejects a version mismatch. - It publishes the verified APKs, AAB, mapping and checksums to GitHub Releases. An existing Release is + It publishes the verified APKs, mapping and checksums to GitHub Releases. An existing Release is not overwritten by a rerun. The first configured version is `0.0.1` with `versionCode = 1`. Later releases must increase `versionCode` to support Android upgrades. Keep using the same signing key for installed users. -## Upload to Google Play - -1. Build release artifacts as above or download them from a verified release workflow run. - Use a new `versionCode` for each Play upload; increase it in `app/build.gradle.kts` before building. -2. Create/select the Play Console app for `life.andre.message487` and configure Play App Signing. - The AAB uses the same configured key as the GitHub APK. Confirm that Play accepts it as the - upload key. To allow updates between GitHub and Play installs, plan the **app signing key** - consistently; an upload key and a Play-generated app signing key are different roles. - See [Android signing guidance](https://developer.android.com/studio/publish/app-signing). -3. Create an internal-testing release and upload `message487.aab`. An AAB is a publishing - artifact; install the APK on phones, not the AAB. Keep `mapping.txt` with the build - (AGP also embeds the R8 mapping in the bundle). -4. Add the icon and feature graphic from [Branding](../../assets/branding/README.md), real app - screenshots, descriptions, privacy policy and Data safety answers. Complete Play's - [SMS permissions declaration/review](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en) - for `RECEIVE_SMS`; a successful AAB build does not establish store eligibility. -5. Review the internal release in Play Console before rolling it out. - -### Fastlane upload - -Like MegaProxy, `play_release` uploads existing signed artifacts and changelogs as an **internal -draft** by default. Message487 uploads `mapping.txt` (R8), rather than native symbols. -Build both files together with `release_artifacts`; the upload lane does not build them or verify -their signatures or embedded versions. Use an unused, increasing `versionCode` for each upload. - -```shell -bundle exec fastlane android release_artifacts -bundle exec fastlane android play_release dry_run:true -``` - -`dry_run:true` checks options and readable, non-empty AAB/mapping files locally and prints the -destination. It never calls Google, even when combined with `validate_only:true`. It does not -check Google permissions, version-code availability, signatures or store eligibility. - -For API access, enable the Google Play Developer API and grant a dedicated service account access -to `life.andre.message487` and the intended tracks in Play Console. Keep its JSON key outside the -repository. Supply the complete JSON via `SUPPLY_JSON_KEY_DATA`, not as a lane argument or Base64. -For example, export it in a private `~/.config/message487/release.env` file with mode `0600`: - -```shell -source "$HOME/.config/message487/release.env" -bundle exec fastlane android play_release validate_only:true -# Create the internal draft only when ready: -bundle exec fastlane android play_release -``` - -`validate_only:true` uploads into a temporary Google Play edit and validates it without committing -the release. It requires credentials and network access; it is not an offline dry run. -See [Fastlane supply](https://docs.fastlane.tools/actions/upload_to_play_store/). - -Options: `aab:`, `mapping:`, `track:`, `release_status:draft|completed`, `validate_only:true|false` -and `dry_run:true|false`. Default files are `dist/release/message487.aab` and -`dist/release/mapping.txt`; `MESSAGE487_RELEASE_DIR` overrides that directory. Relative paths -resolve from the repository root. `track:production release_status:completed` requests a production -release; review and managed publishing may still delay availability. The release label uses -`versionName` from the current checkout, so use artifacts built from that checkout. - -Changelogs come from `fastlane/metadata/android//changelogs/.txt`. -Descriptions, images and screenshots are not uploaded by this lane; category and tags remain -manual settings documented in [Branding](../../assets/branding/README.md). -Run `ruby scripts/test-play-release.rb` for offline regression checks (also run in PR CI). - -On a `v*` tag push, CI builds and verifies artifacts, publishes the GitHub Release, then uploads -an internal Google Play draft using the repository Actions secret `SUPPLY_JSON_KEY_DATA`. -The secret is passed only to the upload step. `release-check/*` and manual workflow dispatch -build artifacts only. Creating a GitHub Release manually does not trigger this workflow. -A failed Play upload fails the job but leaves the published GitHub Release available. Native symbol packaging from MegaProxy is unnecessary here: this app has no native core. -The bundle signature verifier also rejects unsigned added entries, modified entries, missing -required bundle entries and unexpected certificates; its regression fixtures run in Android CI. - -### Store descriptions without a new binary - -Run the manual **Upload Google Play descriptions** workflow from `main` and supply the full -SHA of the reviewed commit containing the descriptions. Use a commit whose text matches the -version being reviewed in Play; it may include editorial fixes made after that version's tag. -The workflow runs current tooling and reads only EN/RU title, short description and full -description from that commit. It does not execute code from the selected commit. - -Locally, with the service-account environment loaded: - -```shell -bundle exec fastlane android play_metadata metadata_commit: -``` - -Omitting the SHA uses committed `HEAD`, never uncommitted text. Screenshots, artwork, binaries -and release notes stay untouched. Upload leaves changes unsubmitted and disables Fastlane's -automatic submission fallback. Check both languages and the matching release in Publishing -overview before sending for review. Upload, review approval and publication are separate steps. -The general store listing is shared across tracks. Neither an internal draft nor this workflow -binds the descriptions to a version. See [Google's publishing controls](https://support.google.com/googleplay/android-developer/answer/9859654?hl=en). - ## F-Droid Store descriptions, changelogs and artwork live in @@ -157,7 +64,7 @@ Store descriptions, changelogs and artwork live in before tagging a release. The submission recipe is maintained in `fdroid/fdroiddata`, not duplicated in this repository. Pin each build to the full release commit SHA, use JDK 21, and compare against the versioned GitHub release APK with the expected -signing certificate. Keep dependency metadata disabled for both APKs and bundles. +signing certificate. Keep dependency metadata disabled for APKs. A successful GitHub build alone does not establish reproducibility: the F-Droid build and binary comparison must pass before marking that verification complete. diff --git a/docs/en/testing.md b/docs/en/testing.md index 24d2c65..b94f701 100644 --- a/docs/en/testing.md +++ b/docs/en/testing.md @@ -6,7 +6,7 @@ Run the same suites locally and in GitHub Actions: | Suite | Command | Coverage | | --- | --- | --- | -| Android | `bundle exec fastlane android checks` | JVM logic, MockWebServer HTTP integration, Robolectric database/preferences/provider integration, Compose interactions, localization, manifest security, light/dark contrast, debug/release lint and APK/AAB builds, unsigned release verification | +| Android | `bundle exec fastlane android checks` | JVM logic, MockWebServer HTTP integration, Robolectric database/preferences/provider integration, Compose interactions, localization, manifest security, light/dark contrast, debug/release lint and APK builds, unsigned release verification | | Python | `PYTHON=.venv/bin/python bundle exec fastlane android python_checks` | DevServer HTTP test-client behavior against a local HTTP server; pinned Black/isort style checks | | n8n | `bundle exec fastlane android server_tests` | Live receive/validation, notification/SMS/test payloads, HTTP failure, invalid ACK and timeout | @@ -16,7 +16,7 @@ The server suite uses synthetic data and retains it in the development execution `.github/workflows/ci.yml` runs all three jobs on pull requests, main pushes and manual dispatch. Android XML/HTML test and lint reports are uploaded even if a check fails. Successful Android jobs -also publish a debug APK and unsigned release APK/AAB files. These checks never sign release artifacts. +also publish a debug APK and unsigned release APK files. These checks never sign release artifacts. Local success does not establish a GitHub run result for uncommitted/unpushed changes. ## Comparison with MegaProxy diff --git a/docs/ru/apk-installation.md b/docs/ru/apk-installation.md index 570df3e..5301206 100644 --- a/docs/ru/apk-installation.md +++ b/docs/ru/apk-installation.md @@ -2,6 +2,25 @@ [English](../en/apk-installation.md) | [Русский](../ru/apk-installation.md) +## Обновления приложения + +Откройте **Обновления приложения** значком обновления в верхней панели. Для установок из +F-Droid, включая альтернативные клиенты с поддержкой ссылок репозиториев F-Droid, +используется API F-Droid. Остальные известные установщики используют GitHub Releases. +Если Android не определил источник, выберите его вручную. Сохранённый выбор имеет приоритет; +ошибка проверки не переключает источник. + +Автопроверка выполняется примерно раз в сутки при доступной сети. Разрешите уведомления, +чтобы получать напоминания; проигнорированная версия напоминает о себе через неделю. +Можно пропустить версию или отключить автопроверку. Кнопка **Проверить обновления** работает +и вручную. Обновление F-Droid открывает страницу приложения в клиенте или браузере. +Для GitHub отдельно подтверждаются скачивание и **Установить обновление**. Приложение +проверяет SHA-256, пакет, увеличение versionCode и совпадение установленного сертификата +подписи, затем открывает системный установщик. Если Android запросил разрешение установки +из Message487, разрешите его, вернитесь и нажмите кнопку установки ещё раз. Приложения с +разными ключами подписи не обновляют друг друга; сохраняйте данные и используйте свою сборку. + + ## Скачать и установить 1. Откройте на телефоне [GitHub Releases проекта](https://github.com/andre487/AndroidMessage487/releases). diff --git a/docs/ru/releases.md b/docs/ru/releases.md index b71abc0..5edded3 100644 --- a/docs/ru/releases.md +++ b/docs/ru/releases.md @@ -1,21 +1,19 @@ -# Выпуск подписанных APK и App Bundle +# Выпуск подписанных APK [English](../en/releases.md) | [Русский](../ru/releases.md) Для установки на телефон см. [инструкцию по APK и ограничениям Android](apk-installation.md). Запустите `bundle exec fastlane android release_artifacts` с JDK 21 и Android SDK 36. -Lane выполняет JVM/Compose-тесты и debug/release lint, затем собирает подписанные APK и AAB -из одного release-варианта. Проверяются сертификат, пакет, версия и отсутствие debug-флага -APK, а также сертификат и подпись каждого содержательного файла AAB. +Lane выполняет JVM/Compose-тесты и debug/release lint, затем собирает подписанный release APK. Проверяются сертификат, пакет, версия и отсутствие debug-флага +APK. | Файл в `dist/release/` | Назначение | | --- | --- | | `message487-.apk` | Подписанный APK с версией в имени | | `message487.apk` | Побайтовая копия APK с постоянным именем для скачивания | -| `message487.aab` | Подписанный Android App Bundle для Google Play | | `mapping.txt` | R8 mapping именно этой сборки | -| `SHA256SUMS` | Контрольные суммы обоих APK, AAB и mapping | +| `SHA256SUMS` | Контрольные суммы обоих APK и mapping | [Постоянная ссылка на APK](https://github.com/andre487/AndroidMessage487/releases/latest/download/message487.apk) ведёт на последний опубликованный релиз GitHub, а не на текущую ветку. Старые ссылки @@ -36,7 +34,7 @@ APK, а также сертификат и подпись каждого сод Gradle читает только четыре signing-переменные: путь, пароль хранилища, alias и пароль ключа. Частичная конфигурация приводит к ошибке. PR-lane `checks` отвергает signing-параметры -и проверяет неподписанные release APK и AAB. Пароли не передаются аргументами командной строки; +и проверяет неподписанные release APK. Пароли не передаются аргументами командной строки; их нельзя печатать, коммитить или передавать через Gradle `-P`. Имена GitHub Secrets совпадают с MegaProxy: `ANDROID_SIGNING_KEY_BASE64`, @@ -47,114 +45,18 @@ Workflow восстанавливает ключ и пароль с приват ## Проверка и публикация -- Тег `release-check/*` запускает сборку и проверку подписанных APK/AAB в GitHub Actions - без публикации Release. APK, AAB, mapping, контрольные суммы и отчёты доступны как артефакты. +- Тег `release-check/*` запускает сборку и проверку подписанных APK в GitHub Actions + без публикации Release. APK, mapping, контрольные суммы и отчёты доступны как артефакты. - После слияния workflow в основную ветку ручной запуск также только собирает артефакты. - Для публикации увеличьте `versionCode`, задайте нужный `versionName` в `app/build.gradle.kts` и слейте проверенный PR после обязательных проверок. Отправьте тег `v`. - Workflow требует наличия коммита в `main` и совпадения версии с тегом. Проверенные APK, AAB, + Workflow требует наличия коммита в `main` и совпадения версии с тегом. Проверенные APK, mapping и контрольные суммы публикуются в GitHub Releases. Повторный запуск не перезаписывает существующий Release. Первая настроенная версия — `0.0.1`, `versionCode = 1`. Для последующих версий увеличивайте `versionCode`, чтобы Android мог обновить приложение. Сохраняйте тот же ключ подписи. -## Загрузить в Google Play - -1. Соберите релизные артефакты командой выше или скачайте их из проверенного запуска - release-workflow. Для каждой загрузки в Play нужен новый `versionCode`: увеличьте его - в `app/build.gradle.kts` до сборки. -2. Создайте или выберите в Play Console приложение `life.andre.message487` и настройте - Play App Signing. AAB подписывается настроенным ключом GitHub APK: убедитесь, что Play - принимает его как upload key. Для обновлений между установками из GitHub и Play - согласуйте **ключ подписи приложения**; upload key и генерируемый Play ключ приложения - выполняют разные роли. См. [документацию Android о подписи](https://developer.android.com/studio/publish/app-signing). -3. Создайте релиз внутреннего тестирования и загрузите `message487.aab`. AAB предназначен - для публикации; на телефон устанавливается APK. Сохраните `mapping.txt` вместе со сборкой - (AGP также включает R8 mapping в бандл). -4. Добавьте иконку и баннер из [Branding](../../assets/branding/README.md), настоящие скриншоты, - описания, политику конфиденциальности и ответы Data safety. Для `RECEIVE_SMS` необходимо - пройти [декларирование и проверку SMS-разрешений](https://support.google.com/googleplay/android-developer/answer/10208820?hl=ru). - Успешная сборка AAB сама по себе не означает соответствие правилам магазина. -5. Проверьте внутренний релиз в Play Console перед распространением. - -### Загрузка через Fastlane - -Как в MegaProxy, `play_release` загружает готовые подписанные артефакты и списки изменений, -по умолчанию создавая **черновик внутреннего тестирования**. Вместо нативных символов -Message487 передаёт `mapping.txt` для R8. Собирайте оба файла вместе через `release_artifacts`: -lane загрузки не собирает их и не проверяет подписи или встроенные версии. -Для каждой загрузки нужен новый, возрастающий `versionCode`. - -```shell -bundle exec fastlane android release_artifacts -bundle exec fastlane android play_release dry_run:true -``` - -`dry_run:true` локально проверяет параметры и наличие доступных непустых AAB/mapping, затем -выводит назначение загрузки. Обращений к Google нет, даже вместе с `validate_only:true`. -Доступ к Play, доступность versionCode, подписи и соответствие правилам магазина он не проверяет. - -Для API включите Google Play Developer API и предоставьте отдельному сервисному аккаунту -доступ к `life.andre.message487` и нужным трекам в Play Console. JSON-ключ храните вне -репозитория. Передавайте его целиком через `SUPPLY_JSON_KEY_DATA`, не аргументом lane и не -в Base64. Например, экспортируйте переменную в приватном файле -`~/.config/message487/release.env` с правами `0600`: - -```shell -source "$HOME/.config/message487/release.env" -bundle exec fastlane android play_release validate_only:true -# Создать внутренний черновик, когда всё готово: -bundle exec fastlane android play_release -``` - -`validate_only:true` загружает данные во временную транзакцию Google Play и проверяет их, -не сохраняя релиз. Нужны ключ и сеть; это не локальный dry run. -См. [Fastlane supply](https://docs.fastlane.tools/actions/upload_to_play_store/). - -Параметры: `aab:`, `mapping:`, `track:`, `release_status:draft|completed`, -`validate_only:true|false`, `dry_run:true|false`. Файлы по умолчанию — -`dist/release/message487.aab` и `dist/release/mapping.txt`; каталог переопределяет -`MESSAGE487_RELEASE_DIR`. Относительные пути считаются от корня репозитория. -`track:production release_status:completed` запрашивает production-релиз; проверка Google и -управляемая публикация могут задержать доступность. Название релиза использует `versionName` -текущего checkout, поэтому берите артефакты, собранные из него. - -Списки изменений берутся из `fastlane/metadata/android//changelogs/.txt`. -Описания, изображения и скриншоты этот lane не загружает; категория и теги задаются вручную -по [Branding](../../assets/branding/README.md). Локальная регрессионная проверка: -`ruby scripts/test-play-release.rb` (также запускается в PR CI). - -При отправке тега `v*` CI собирает и проверяет артефакты, публикует GitHub Release, затем -загружает внутренний черновик Google Play с секретом репозитория `SUPPLY_JSON_KEY_DATA`. -Ключ передаётся только шагу загрузки. `release-check/*` и ручной запуск workflow только -собирают файлы. Создание GitHub Release вручную не запускает этот workflow. -Ошибка загрузки Play завершает job с ошибкой, но опубликованный GitHub Release остаётся доступен. Архив нативных символов из MegaProxy здесь не нужен: у приложения нет -нативного ядра. Проверка подписи бандла отвергает добавленные неподписанные файлы, -изменённые файлы, отсутствие обязательных частей и чужой сертификат; регрессионные -тесты этой проверки входят в Android CI. - -### Описания магазина без новой сборки - -Запустите вручную workflow **Upload Google Play descriptions** из `main`, указав полный SHA -проверенного коммита с описаниями. Текст должен соответствовать версии на проверке в Play; -редакторские исправления могут быть сделаны после её релизного тега. Workflow использует -текущий код инструментов и читает из указанного коммита только название, краткое и полное -описание EN/RU. Код выбранного коммита не исполняется. - -Локально, после загрузки окружения с ключом сервисного аккаунта: - -```shell -bundle exec fastlane android play_metadata metadata_commit:<полный-sha-коммита> -``` - -Без SHA используется закоммиченный `HEAD`, а не рабочие правки. Скриншоты, изображения, -сборки и release notes не меняются. Загрузка оставляет изменения неотправленными; автоматическая -повторная попытка Fastlane с отправкой на проверку отключена. Проверьте оба языка и нужную -версию в «Обзоре публикации», затем отправляйте на проверку. Загрузка, одобрение и публикация — -разные этапы. Карточка общая для треков; internal draft и этот workflow не привязывают описание -к версии. См. [управление публикацией Google](https://support.google.com/googleplay/android-developer/answer/9859654?hl=ru). - ## F-Droid Описания, списки изменений и графика магазина находятся в @@ -163,7 +65,7 @@ bundle exec fastlane android play_metadata metadata_commit:<полный-sha-к без дублирования в этом репозитории. Для сборки указывайте полный SHA релизного коммита и JDK 21; сравнивайте результат с APK конкретной версии из GitHub Releases с проверкой ожидаемого сертификата подписи. Метаданные зависимостей должны оставаться -выключенными для APK и App Bundle. Успешной GitHub-сборки недостаточно для заявления +выключенными для APK. Успешной GitHub-сборки недостаточно для заявления о воспроизводимости: должны пройти сборка F-Droid и сравнение бинарных файлов. Генерация baseline/startup-профилей Android отключена: AGP создавал разное diff --git a/docs/ru/testing.md b/docs/ru/testing.md index 43b7b8f..c947237 100644 --- a/docs/ru/testing.md +++ b/docs/ru/testing.md @@ -17,7 +17,7 @@ `.github/workflows/ci.yml` запускает все три job на PR, push в main и вручную. XML/HTML-отчёты Android-тестов и lint загружаются и при ошибках. Успешный Android-job -также публикует debug APK и неподписанные release APK и AAB. Подпись release в этих проверках +также публикует debug APK и неподписанные release APK. Подпись release в этих проверках не используется. Локальный успех не подтверждает результат GitHub для неотправленных изменений. ## Сравнение с MegaProxy diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5281e98..a9e178e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,25 +1,3 @@ -require "json" -require "open3" -require "tmpdir" -require "fileutils" - -def play_credentials - json_key_data = ENV["SUPPLY_JSON_KEY_DATA"] - if json_key_data.to_s.strip.empty? - UI.user_error!("Set SUPPLY_JSON_KEY_DATA to the service-account JSON contents") - end - begin - credentials = JSON.parse(json_key_data) - rescue JSON::ParserError - UI.user_error!("SUPPLY_JSON_KEY_DATA must contain valid JSON") - end - unless credentials.is_a?(Hash) && credentials["type"] == "service_account" && - %w[client_email private_key token_uri].all? { |key| credentials[key].is_a?(String) && !credentials[key].strip.empty? } - UI.user_error!("SUPPLY_JSON_KEY_DATA must contain a service-account key with client_email, private_key and token_uri") - end - json_key_data -end - default_platform(:android) opt_out_usage ensure_bundle_exec @@ -27,14 +5,14 @@ ensure_bundle_exec project_root = File.expand_path("..", __dir__) platform :android do - desc "Run JVM tests, Android lint, and build debug APK and unsigned release APK/AAB" + desc "Run JVM tests, Android lint, and build debug APK and unsigned release APK" lane :checks do signing_variables = %w[MESSAGE487_KEYSTORE_PATH MESSAGE487_KEYSTORE_PASSWORD MESSAGE487_KEY_ALIAS MESSAGE487_KEY_PASSWORD MESSAGE487_KEY_PASSWORD_FILE] if signing_variables.any? { |name| !ENV.fetch(name, "").empty? } UI.user_error!("Signing configuration must not be available to PR checks") end gradle( - tasks: %w[testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease], + tasks: %w[testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease], flags: "--no-daemon", project_dir: project_root ) @@ -49,10 +27,7 @@ platform :android do if system(apksigner, "verify", unsigned_apk, out: File::NULL, err: File::NULL) UI.user_error!("CI release APK is unexpectedly signed") end - sh("java", File.join(project_root, "scripts/VerifyBundle.java"), - File.join(project_root, "app/build/outputs/bundle/release/app-release.aab"), "unsigned") - sh(File.join(project_root, "scripts/test-bundle-verification.sh")) - UI.success("Verified unsigned release APK and App Bundle") + UI.success("Verified unsigned release APK") end desc "Run Python tests and formatting checks for the development server" @@ -74,7 +49,7 @@ platform :android do sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "DevServer/tests/smoke.py")) end - desc "Build, sign and verify release APK, App Bundle and checksums" + desc "Build, sign and verify release APK and checksums" lane :release_artifacts do sh(File.join(project_root, "scripts/build-release-apk.sh")) end @@ -89,106 +64,4 @@ platform :android do gradle(task: "installDebug", project_dir: project_root) end - desc "Upload store descriptions from a committed revision without submitting for review" - lane :play_metadata do |options| - unknown_options = options.keys - [:metadata_commit] - UI.user_error!("Unknown play_metadata options: #{unknown_options.join(', ')}") unless unknown_options.empty? - revision = options.fetch(:metadata_commit, ENV.fetch("PLAY_METADATA_COMMIT", "HEAD")).to_s - unless revision == "HEAD" || revision.match?(/\A[0-9a-f]{40}\z/) - UI.user_error!("metadata_commit must be HEAD or a full commit SHA") - end - commit, _, status = Open3.capture3("git", "-C", project_root, "rev-parse", "--verify", "#{revision}^{commit}") - UI.user_error!("Metadata commit is unavailable; fetch it first") unless status.success? - commit = commit.strip - Dir.mktmpdir("message487-play-metadata-") do |metadata_path| - %w[en-US ru-RU].each do |language| - FileUtils.mkdir_p(File.join(metadata_path, language)) - { "title" => 30, "short_description" => 80, "full_description" => 4000 }.each do |field, limit| - path = "fastlane/metadata/android/#{language}/#{field}.txt" - value, _, result = Open3.capture3("git", "-C", project_root, "show", "#{commit}:#{path}") - UI.user_error!("Missing #{path} at #{commit}") unless result.success? - value = value.force_encoding(Encoding::UTF_8).strip - unless value.valid_encoding? && !value.empty? && value.length <= limit - UI.user_error!("Invalid #{language}/#{field}: expected 1–#{limit} UTF-8 characters") - end - File.write(File.join(metadata_path, language, "#{field}.txt"), value) - end - end - UI.message("Store description source: #{commit}") - upload_to_play_store( - json_key_data: play_credentials, - package_name: "life.andre.message487", - metadata_path: metadata_path, - skip_upload_apk: true, - skip_upload_aab: true, - skip_upload_metadata: false, - skip_upload_changelogs: true, - skip_upload_images: true, - skip_upload_screenshots: true, - changes_not_sent_for_review: true, - rescue_changes_not_sent_for_review: false - ) - end - end - - desc "Upload a signed release AAB and R8 mapping to Google Play (internal draft by default)" - lane :play_release do |options| - allowed_options = %i[aab mapping track release_status validate_only dry_run] - unknown_options = options.keys - allowed_options - UI.user_error!("Unknown play_release options: #{unknown_options.join(', ')}") unless unknown_options.empty? - - track = options.fetch(:track, "internal").to_s - UI.user_error!("track must not be empty") if track.strip.empty? - release_status = options.fetch(:release_status, "draft").to_s - unless %w[draft completed].include?(release_status) - UI.user_error!("release_status must be draft or completed") - end - validate_only = options.fetch(:validate_only, false).to_s - unless %w[true false].include?(validate_only) - UI.user_error!("validate_only must be true or false") - end - - release_dir = File.expand_path(ENV.fetch("MESSAGE487_RELEASE_DIR", "dist/release"), project_root) - files = { - aab: File.expand_path(options.fetch(:aab, File.join(release_dir, "message487.aab")), project_root), - mapping: File.expand_path(options.fetch(:mapping, File.join(release_dir, "mapping.txt")), project_root) - } - files.each do |name, path| - unless File.file?(path) && File.readable?(path) && File.size?(path) - UI.user_error!("#{name} must be a readable, non-empty file: #{path}") - end - end - UI.user_error!("aab must have the .aab extension") unless File.extname(files[:aab]) == ".aab" - UI.user_error!("mapping must have the .txt extension") unless File.extname(files[:mapping]) == ".txt" - - app_version = File.read(File.join(project_root, "app/build.gradle.kts"))[/^\s*versionName = "([^"]+)"/, 1] - UI.user_error!("Could not read versionName from app/build.gradle.kts") unless app_version - - dry_run = options.fetch(:dry_run, false).to_s - UI.user_error!("dry_run must be true or false") unless %w[true false].include?(dry_run) - if dry_run == "true" - UI.success("Dry run: #{files[:aab]} + #{files[:mapping]} -> life.andre.message487, #{track}, #{release_status}, Version #{app_version}") - UI.message("Local file/option checks only; no Google API calls, upload or signature/version validation") - next - end - - json_key_data = play_credentials - - upload_to_play_store( - **files, - json_key_data: json_key_data, - package_name: "life.andre.message487", - version_name: "Version #{app_version}", - metadata_path: File.join(project_root, "fastlane/metadata/android"), - track: track, - release_status: release_status, - validate_only: validate_only == "true", - skip_upload_apk: true, - skip_upload_aab: false, - skip_upload_metadata: true, - skip_upload_changelogs: false, - skip_upload_images: true, - skip_upload_screenshots: true - ) - end end diff --git a/fastlane/README.md b/fastlane/README.md index 2041ef6..8edbae7 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do [bundle exec] fastlane android checks ``` -Run JVM tests, Android lint, and build debug APK and unsigned release APK/AAB +Run JVM tests, Android lint, and build debug APK and unsigned release APK ### android python_checks @@ -45,7 +45,7 @@ Exercise the running development n8n server [bundle exec] fastlane android release_artifacts ``` -Build, sign and verify release APK, App Bundle and checksums +Build, sign and verify release APK and checksums ### android debug_artifact @@ -63,22 +63,6 @@ Build a debug APK Install the debug APK on the connected emulator or device -### android play_metadata - -```sh -[bundle exec] fastlane android play_metadata metadata_commit: -``` - -Upload store descriptions from a committed revision without submitting for review. - -### android play_release - -```sh -[bundle exec] fastlane android play_release -``` - -Upload a signed release AAB and R8 mapping to Google Play (internal draft by default) - ---- This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. diff --git a/scripts/VerifyBundle.java b/scripts/VerifyBundle.java deleted file mode 100644 index 2dd7fae..0000000 --- a/scripts/VerifyBundle.java +++ /dev/null @@ -1,42 +0,0 @@ -import java.nio.file.Path; -import java.security.MessageDigest; -import java.util.HexFormat; -import java.util.Set; -import java.util.jar.JarFile; - -/** Verify every payload entry, including entries appended after signing. */ -class VerifyBundle { - public static void main(String[] args) throws Exception { - if (args.length != 2) throw new IllegalArgumentException("Usage: VerifyBundle.java bundle.aab SHA256|unsigned"); - boolean unsigned = args[1].equals("unsigned"); - var required = new java.util.HashSet<>(Set.of( - "BundleConfig.pb", "base/manifest/AndroidManifest.xml", "base/resources.pb", "base/dex/classes.dex")); - int payloadEntries = 0; - try (var jar = new JarFile(Path.of(args[0]).toFile(), true)) { - var entries = jar.entries(); - while (entries.hasMoreElements()) { - var entry = entries.nextElement(); - if (entry.isDirectory()) continue; - try (var input = jar.getInputStream(entry)) { - input.transferTo(java.io.OutputStream.nullOutputStream()); - } - required.remove(entry.getName()); - String name = entry.getName().toUpperCase(java.util.Locale.ROOT); - if (name.equals("META-INF/MANIFEST.MF") || - name.matches("META-INF/[^/]+\\.(SF|RSA|DSA|EC)") || - name.matches("META-INF/SIG-[^/]+")) continue; - payloadEntries++; - var certificates = entry.getCertificates(); - if (unsigned) { - if (certificates != null && certificates.length > 0) throw new SecurityException("Unexpected signed bundle"); - } else { - if (certificates == null || certificates.length != 1) throw new SecurityException("Unsigned or unexpected bundle entry"); - var digest = MessageDigest.getInstance("SHA-256").digest(certificates[0].getEncoded()); - if (!HexFormat.of().formatHex(digest).equals(args[1])) throw new SecurityException("Unexpected bundle signing certificate"); - } - } - } - if (!required.isEmpty() || payloadEntries == 0) throw new SecurityException("Incomplete app bundle"); - System.out.println("Verified " + (unsigned ? "unsigned" : "signed") + " App Bundle"); - } -} diff --git a/scripts/build-release-apk.sh b/scripts/build-release-apk.sh index 97fe4af..1fe668a 100755 --- a/scripts/build-release-apk.sh +++ b/scripts/build-release-apk.sh @@ -41,7 +41,7 @@ export MESSAGE487_KEYSTORE_PASSWORD="$keystore_password" cd "$project_dir" # Separate invocations ensure clean finishes before generated-resource tasks start. ./gradlew clean --no-daemon -./gradlew testDebugUnitTest lintDebug lintRelease assembleRelease bundleRelease --no-daemon +./gradlew testDebugUnitTest lintDebug lintRelease assembleRelease --no-daemon apk="$project_dir/app/build/outputs/apk/release/app-release.apk" actual_fingerprint="$("$apksigner" verify --verbose --print-certs "$apk" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')" if [[ "$actual_fingerprint" != "$MESSAGE487_EXPECTED_CERT_SHA256" ]]; then @@ -57,18 +57,15 @@ if grep -q '^application-debuggable' <<< "$badging"; then echo "Release APK must not be debuggable" >&2 exit 1 fi -bundle="$project_dir/app/build/outputs/bundle/release/app-release.aab" -java "$project_dir/scripts/VerifyBundle.java" "$bundle" "$MESSAGE487_EXPECTED_CERT_SHA256" mkdir -p "$MESSAGE487_RELEASE_DIR" # Only remove artifacts owned by this script so old APKs cannot enter a new release. find "$MESSAGE487_RELEASE_DIR" -maxdepth 1 -type f \ \( -name 'message487-*.apk' -o -name 'message487-*.aab' -o -name message487.apk -o -name message487.aab -o -name mapping.txt -o -name SHA256SUMS \) -delete cp "$apk" "$MESSAGE487_RELEASE_DIR/message487-$version_name.apk" cp "$apk" "$MESSAGE487_RELEASE_DIR/message487.apk" -cp "$bundle" "$MESSAGE487_RELEASE_DIR/message487.aab" cp "$project_dir/app/build/outputs/mapping/release/mapping.txt" "$MESSAGE487_RELEASE_DIR/mapping.txt" ( cd "$MESSAGE487_RELEASE_DIR" - shasum -a 256 message487*.apk message487.aab mapping.txt > SHA256SUMS + shasum -a 256 message487*.apk mapping.txt > SHA256SUMS ) -echo "Verified signed release APK, App Bundle and checksums: $MESSAGE487_RELEASE_DIR" +echo "Verified signed release APK and checksums: $MESSAGE487_RELEASE_DIR" diff --git a/scripts/test-bundle-verification.sh b/scripts/test-bundle-verification.sh deleted file mode 100755 index 35956d0..0000000 --- a/scripts/test-bundle-verification.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/message487-bundle-test.XXXXXX")" -trap 'rm -rf "$fixture_dir"' EXIT -mkdir -p "$fixture_dir/content/base/manifest" "$fixture_dir/content/base/dex" -for entry in BundleConfig.pb base/manifest/AndroidManifest.xml base/resources.pb base/dex/classes.dex; do - printf 'Synthetic test entry' > "$fixture_dir/content/$entry" -done -jar --create --file "$fixture_dir/unsigned.aab" -C "$fixture_dir/content" . -verify=(java "$project_dir/scripts/VerifyBundle.java") -"${verify[@]}" "$fixture_dir/unsigned.aab" unsigned >/dev/null -printf 'Synthetic-test-password' > "$fixture_dir/password" -keytool -genkeypair -alias fixture -keyalg RSA -keystore "$fixture_dir/key.p12" \ - -storepass:file "$fixture_dir/password" -dname CN=BundleTest -validity 1 >/dev/null 2>&1 -fingerprint="$(keytool -exportcert -alias fixture -keystore "$fixture_dir/key.p12" \ - -storepass:file "$fixture_dir/password" | shasum -a 256 | cut -d ' ' -f 1)" -cp "$fixture_dir/unsigned.aab" "$fixture_dir/signed.aab" -jarsigner -keystore "$fixture_dir/key.p12" -storepass:file "$fixture_dir/password" \ - "$fixture_dir/signed.aab" fixture >/dev/null -"${verify[@]}" "$fixture_dir/signed.aab" "$fingerprint" >/dev/null -expect_rejected() { - if "${verify[@]}" "$@" >/dev/null 2>&1; then - echo "Bundle verification accepted an invalid fixture" >&2 - exit 1 - fi -} -expect_rejected "$fixture_dir/unsigned.aab" "$fingerprint" -expect_rejected "$fixture_dir/signed.aab" unsigned -expect_rejected "$fixture_dir/signed.aab" "${fingerprint}00" -cp "$fixture_dir/signed.aab" "$fixture_dir/appended.aab" -printf 'Unsigned appended payload' > "$fixture_dir/content/base/extra.pb" -jar --update --file "$fixture_dir/appended.aab" -C "$fixture_dir/content" base/extra.pb -expect_rejected "$fixture_dir/appended.aab" "$fingerprint" -cp "$fixture_dir/signed.aab" "$fixture_dir/metadata.aab" -mkdir -p "$fixture_dir/content/META-INF" -printf 'Unsigned appended metadata' > "$fixture_dir/content/META-INF/extra.txt" -jar --update --file "$fixture_dir/metadata.aab" -C "$fixture_dir/content" META-INF/extra.txt -expect_rejected "$fixture_dir/metadata.aab" "$fingerprint" -printf 'Tampered entry' > "$fixture_dir/content/base/dex/classes.dex" -jar --update --file "$fixture_dir/signed.aab" -C "$fixture_dir/content" base/dex/classes.dex -expect_rejected "$fixture_dir/signed.aab" "$fingerprint" -jar --create --file "$fixture_dir/incomplete.aab" -C "$fixture_dir/content" BundleConfig.pb -expect_rejected "$fixture_dir/incomplete.aab" unsigned -echo "Passed bundle verification: signatures, certificate, tampering, unsigned additions and structure" diff --git a/scripts/test-play-release.rb b/scripts/test-play-release.rb deleted file mode 100644 index f726455..0000000 --- a/scripts/test-play-release.rb +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env ruby -require "json" -require "tmpdir" - -# Exercise the real lane with a recording upload action; never contact Google. -module UI - def self.user_error!(message) = raise(ArgumentError, message) - def self.success(*) = nil - def self.message(*) = nil -end - -class LaneCheck - attr_reader :uploads, :metadata - def initialize(root = File.expand_path("..", __dir__)) - @lanes = {} - @uploads = [] - path = File.expand_path("../fastlane/Fastfile", __dir__) - instance_eval(File.read(path), File.join(root, "fastlane/Fastfile")) - end - def default_platform(*) = nil - def opt_out_usage = nil - def ensure_bundle_exec = nil - def desc(*) = nil - def platform(*) = yield - def lane(name, &block) = @lanes[name] = block - def upload_to_play_store(**options) - @metadata = Dir.glob(File.join(options[:metadata_path], "**/*")).select { |path| File.file?(path) } - .to_h { |path| [path.delete_prefix("#{options[:metadata_path]}/"), File.read(path)] } - @uploads << options - end - def run(**options) = @lanes.fetch(:play_release).call(options) - def run_metadata(**options) = @lanes.fetch(:play_metadata).call(options) -end - -def assert(value) - raise "Assertion failed" unless value -end - -Dir.mktmpdir("message487-play-test") do |dir| - ENV["MESSAGE487_RELEASE_DIR"] = dir - ENV.delete("SUPPLY_JSON_KEY_DATA") - File.write(File.join(dir, "message487.aab"), "fixture") - File.write(File.join(dir, "mapping.txt"), "fixture") - check = LaneCheck.new - check.run(dry_run: true) - check.run(dry_run: true, validate_only: true) - assert(check.uploads.empty?) - [{dry_run: "yes"}, {validate_only: "yes"}, {track: " "}, - {release_status: "unknown"}, {validate_olny: true}, - {aab: File.join(dir, "missing.aab")}, {mapping: File.join(dir, "message487.aab")}, - {}].each do |options| - begin - check.run(**options) - raise "Expected rejection: #{options}" - rescue ArgumentError - assert(check.uploads.empty?) - end - end - ["invalid-json", "{}", '{"type":"authorized_user"}'].each do |key| - ENV["SUPPLY_JSON_KEY_DATA"] = key - begin - check.run(validate_only: true) - raise "Expected credentials rejection" - rescue ArgumentError - assert(check.uploads.empty?) - end - end - ENV["SUPPLY_JSON_KEY_DATA"] = JSON.generate(type: "service_account", client_email: "test@example.invalid", - private_key: "fixture", token_uri: "https://example.invalid/token") - check.run(validate_only: true) - upload = check.uploads.last - assert(upload.values_at(:package_name, :track, :release_status, :validate_only) == - ["life.andre.message487", "internal", "draft", true]) - assert(upload[:mapping] == File.join(dir, "mapping.txt")) - assert(upload[:skip_upload_metadata] && upload[:skip_upload_images] && upload[:skip_upload_screenshots]) - assert(!upload[:skip_upload_changelogs] && !upload[:skip_upload_aab] && upload[:skip_upload_apk]) - check.run(track: "production", release_status: "completed") - assert(check.uploads.last.values_at(:track, :release_status, :validate_only) == ["production", "completed", false]) -end -puts "play_release checks passed (no network calls)" - -Dir.mktmpdir("message487-metadata-test") do |root| - git = lambda do |*args| - output, error, status = Open3.capture3("git", "-C", root, *args) - raise error unless status.success? - output.strip - end - git.call("init", "-q") - git.call("config", "user.name", "Test") - git.call("config", "user.email", "test@example.invalid") - source = File.expand_path("../fastlane/metadata/android", __dir__) - destination = File.join(root, "fastlane/metadata/android") - FileUtils.mkdir_p(File.dirname(destination)) - FileUtils.cp_r(source, destination) - git.call("add", ".") - git.call("commit", "-qm", "Fixture") - commit = git.call("rev-parse", "HEAD") - check = LaneCheck.new(root) - ENV.delete("PLAY_METADATA_COMMIT") - check.run_metadata(metadata_commit: commit) - original = check.metadata - assert(original.size == 6) - upload = check.uploads.last - assert(upload[:skip_upload_aab] && upload[:skip_upload_apk] && upload[:skip_upload_changelogs]) - assert(upload[:skip_upload_images] && upload[:skip_upload_screenshots] && !upload[:skip_upload_metadata]) - assert(upload[:changes_not_sent_for_review] && !upload[:rescue_changes_not_sent_for_review]) - assert(!File.exist?(upload[:metadata_path])) - title = File.join(destination, "en-US/title.txt") - File.write(title, "Uncommitted text") - check.run_metadata - assert(check.metadata == original) - [{metadata_commit: "--help"}, {metadata_commit: "0" * 40}, {typo: true}].each do |options| - count = check.uploads.size - begin - check.run_metadata(**options) - raise "Expected invalid revision/options rejection" - rescue ArgumentError - assert(check.uploads.size == count) - end - end - ["", "x" * 31, "\xff".b, nil].each do |invalid| - invalid ? File.binwrite(title, invalid) : File.delete(title) - git.call("add", ".") - git.call("commit", "-qm", "Invalid metadata") - count = check.uploads.size - begin - check.run_metadata - raise "Expected invalid/missing text rejection" - rescue ArgumentError - assert(check.uploads.size == count) - end - end -end -puts "play_metadata checks passed (no network calls)"