diff --git a/AGENTS.md b/AGENTS.md index bf40a2a..0d59550 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,9 +59,9 @@ branch names, credentials, signing material, or other secrets. the dedicated release workflow/Fastlane lane. - Tagged releases publish signed APKs and `SHA256SUMS` to GitHub Releases. Google Play integration is removed. Document installation from release assets; PR APKs are for testing only. -- Update checks default to daily background checks: official F-Droid installer uses its API, any - other named installer uses GitHub, and unknown installers require a source choice. Preserve the - installed APK variant and existing version-code offsets. Downloads require explicit consent; +- Update checks default to daily background checks: official F-Droid installers and installer packages handling + fdroidrepo/fdroidrepos links use its API; other named installers use GitHub. Unknown installers + or failed capability lookup require a source choice. Preserve the installed APK variant and existing version-code offsets. Downloads require explicit consent; ignored notifications repeat after a week, with skip-version and disable-checks actions. - The app is not yet available in F-Droid; do not document it as an installation source. - After creating and verifying a release, update the corresponding F-Droid submission/build recipe diff --git a/app/src/main/java/net/megaproxy487/AppUpdates.kt b/app/src/main/java/net/megaproxy487/AppUpdates.kt index f4b9149..1e1c863 100644 --- a/app/src/main/java/net/megaproxy487/AppUpdates.kt +++ b/app/src/main/java/net/megaproxy487/AppUpdates.kt @@ -1,8 +1,10 @@ package net.megaproxy487 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 @@ -18,9 +20,11 @@ import org.json.JSONObject internal enum class UpdateSource { FDROID, GITHUB } -internal fun updateSourceForInstaller(installer: String?): UpdateSource? = when { +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 } @@ -105,7 +109,17 @@ internal class AppUpdates(private val context: Context) { if (Build.VERSION.SDK_INT >= 30) manager.getInstallSourceInfo(context.packageName).installingPackageName else manager.getInstallerPackageName(context.packageName) } catch (_: Exception) { null } - return updateSourceForInstaller(installer) + 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) { diff --git a/app/src/main/java/net/megaproxy487/UpdatesScreen.kt b/app/src/main/java/net/megaproxy487/UpdatesScreen.kt index 3562b31..c14b636 100644 --- a/app/src/main/java/net/megaproxy487/UpdatesScreen.kt +++ b/app/src/main/java/net/megaproxy487/UpdatesScreen.kt @@ -175,13 +175,7 @@ internal fun UpdatesScreen(activity: Activity, onBack: () -> Unit, model: Update if (update.source == UpdateSource.FDROID) { Button(onClick = { try { - try { - activity.startActivity(Intent(Intent.ACTION_VIEW, - Uri.parse("fdroid.app://details?id=${activity.packageName}")) - .setPackage("org.fdroid.fdroid")) - } catch (_: android.content.ActivityNotFoundException) { - activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(FDROID_APP_URL))) - } + 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) { diff --git a/app/src/test/java/net/megaproxy487/AppUpdatesTest.kt b/app/src/test/java/net/megaproxy487/AppUpdatesTest.kt index ff087da..6804685 100644 --- a/app/src/test/java/net/megaproxy487/AppUpdatesTest.kt +++ b/app/src/test/java/net/megaproxy487/AppUpdatesTest.kt @@ -10,6 +10,8 @@ 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", "net.megaproxy487")) { diff --git a/app/src/test/java/net/megaproxy487/InstallerUpdateSourceTest.kt b/app/src/test/java/net/megaproxy487/InstallerUpdateSourceTest.kt new file mode 100644 index 0000000..1a88cd5 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/InstallerUpdateSourceTest.kt @@ -0,0 +1,77 @@ +package net.megaproxy487 + +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/net/megaproxy487/UpdatesUiTest.kt b/app/src/test/java/net/megaproxy487/UpdatesUiTest.kt index 1ba84e5..af6a9b3 100644 --- a/app/src/test/java/net/megaproxy487/UpdatesUiTest.kt +++ b/app/src/test/java/net/megaproxy487/UpdatesUiTest.kt @@ -51,6 +51,13 @@ class UpdatesUiTest : MainUiTestBase() { node(R.string.update_check).performScrollTo().performClick() node(R.string.update_in_fdroid).assertExists() node(R.string.update_download).assertDoesNotExist() - compose.runOnIdle { model.download(); assertNull(model.apk) } + node(R.string.update_in_fdroid).performScrollTo().performClick() + compose.runOnIdle { + val intent = org.robolectric.Shadows.shadowOf(activity).nextStartedActivity + assertEquals(FDROID_APP_URL, intent.data.toString()) + assertNull(intent.`package`) + model.download() + assertNull(model.apk) + } } } diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 9efb45d..0a8d7bf 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -121,7 +121,7 @@ visible in the matrix; an emulator result never silently substitutes for it. | ID | Steps → required observation | | --- | --- | -| U01 | Official F-Droid installer identity, another installer, unknown installer and manual override. Check chosen API and persistence. An unpublished F-Droid package gives an explicit unavailable result without switching to GitHub. Simulated installer identity is not installation from the repository. | +| U01 | Official and alternative F-Droid installers (fdroidrepo/fdroidrepos handlers), browser/file installer, unknown installer and manual override. An unrelated installed F-Droid client must not change a browser installation's source. Check chosen API and persistence. An unpublished F-Droid package gives an explicit unavailable result without switching to GitHub. Simulated installer identity is not installation from the repository. | | U02 | New/same/older/malformed release metadata and unavailable network. Explicit download consent/cancel, APK size/digest/package/version/signer/ABI checks, corruption and download interruption. Use unit tests for deliberately invalid APKs and isolate destructive fixtures to emulators. | | U03 | Universal stays universal, ABI-specific stays that ABI; system install permission denied/granted, cancellation, return from settings and successful update. Read back installed APK/version/signature and verify real connectivity afterward. | | U04 | Notifications allowed/denied/channel disabled; Update, Skip version, Disable checks; manual check after skip; disable while job is in flight; job restoration after reboot. Test no early reminder and reminder after seven days by controlled timestamp injection on an emulator. Report forced jobs/time simulation separately from real daily/weekly delivery. | diff --git a/docs/en/updates.md b/docs/en/updates.md index 0da56a6..5386bdd 100644 --- a/docs/en/updates.md +++ b/docs/en/updates.md @@ -14,11 +14,14 @@ which package installed it: | Installer reported by Android | Update source | | --- | --- | | Official F-Droid (`org.fdroid.fdroid`) or its privileged installer | F-Droid | -| Any other named installer, including a browser or file manager | GitHub | -| Missing, empty, or unreadable installer information | Ask the user to choose; no background requests until then | - -The installer identifies an app, not the original download website. Other F-Droid clients count -as other installers under this rule; select F-Droid manually if needed. Installing an APK downloaded +| Another installer that handles `fdroidrepo://` or `fdroidrepos://` links | F-Droid | +| Another named installer without those handlers, including a browser or file manager | GitHub | +| Missing, empty, or unreadable installer information, or failed handler lookup | Ask the user to choose; no background requests until then | + +Repository-link support is checked only in the installer package; an unrelated installed F-Droid +client does not change the source. This is a capability heuristic, not proof of the APK's origin. +Clients without these handlers need manual source selection. The installer identifies an app, +not the original download website. Installing an APK downloaded from the F-Droid website through a browser also selects GitHub initially. This is a default choice, not proof of the APK's origin. Source changes do not bypass signature checks. @@ -36,7 +39,7 @@ or use the notification permission button on the update screen. The notification offers: - **Update**: open the update screen and check the selected source again. F-Droid updates open - its client (or the app's web page if unavailable); GitHub updates require explicit download consent. + the package web link in a compatible client or browser; GitHub updates require explicit download consent. - **Skip this version**: suppress automatic notifications for this version in this source. Later versions can still notify. Manual checks still show a skipped update. - **Disable auto-checks**: stop scheduled checks and remove the notification. Manual checks remain diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 1eb5189..618e73e 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -123,7 +123,7 @@ Doze и разрушительные проверки данных сначал | ID | Действия → требуемый результат | | --- | --- | -| U01 | Installer F-Droid, другой, неизвестный; ручной выбор. Проверить API и persistence. Неопубликованный F-Droid пакет даёт понятную ошибку без перехода на GitHub. Подмена installer metadata не является установкой из репозитория. | +| U01 | Официальный и сторонний F-Droid (обработчики fdroidrepo/fdroidrepos), браузер/файловый установщик, неизвестный; ручной выбор. Посторонний установленный клиент F-Droid не меняет источник установки из браузера. Проверить API и persistence. Неопубликованный F-Droid пакет даёт понятную ошибку без перехода на GitHub. Подмена installer metadata не является установкой из репозитория. | | U02 | Новая/та же/старая/повреждённая metadata, offline. Download consent/cancel; size/digest/package/version/signer/ABI, повреждение/обрыв APK. Заведомо неверные APK проверять unit-тестами и изолированными эмуляторами. | | U03 | Universal остаётся universal, ABI-specific — тем же ABI; отказ/выдача install permission, Cancel, возврат из Settings, установка. Прочитать обратно APK/version/signature и проверить передачу данных. | | U04 | Notification permission/channel; Update/Skip/Disable; manual после Skip; выключение при работающем job; восстановление job после reboot. Раннего напоминания нет, после семи дней есть — контролируемой правкой времени на эмуляторе. Forced jobs/time simulation не называть реальной суточной/недельной доставкой. | diff --git a/docs/ru/updates.md b/docs/ru/updates.md index 160053e..78ce724 100644 --- a/docs/ru/updates.md +++ b/docs/ru/updates.md @@ -15,11 +15,13 @@ APK и установка без подтверждения не включен | Установщик, указанный Android | Источник обновлений | | --- | --- | | Официальный F-Droid (`org.fdroid.fdroid`) или его привилегированный установщик | F-Droid | -| Любое другое приложение, включая браузер и файловый менеджер | GitHub | -| Установщик не указан, пуст или сведения недоступны | Предложить выбор; до выбора фоновых запросов нет | +| Другой установщик, обрабатывающий ссылки `fdroidrepo://` или `fdroidrepos://` | F-Droid | +| Другой именованный установщик без этих обработчиков, включая браузер и файловый менеджер | GitHub | +| Установщик не указан, пуст, сведения недоступны или проверка обработчиков завершилась ошибкой | Предложить выбор; до выбора фоновых запросов нет | -Это имя установщика, а не сайт скачивания. Другие клиенты F-Droid по этому правилу относятся -к другим приложениям: при необходимости выберите F-Droid вручную. APK с сайта F-Droid, открытый +Поддержка ссылок проверяется только у установщика: наличие другого клиента F-Droid на устройстве +не меняет источник. Это эвристика возможностей, не доказательство происхождения APK. Для клиентов +без этих обработчиков нужен ручной выбор. Имя установщика не указывает сайт скачивания. APK с сайта F-Droid, открытый через браузер, также первоначально получает источник GitHub. Это выбор по умолчанию, а не доказательство происхождения APK. Смена источника не отменяет проверку подписи. @@ -37,7 +39,7 @@ Android планирует проверку примерно раз в сутк Действия в уведомлении: - **Обновить** — открыть экран обновлений и заново проверить выбранный источник. Для F-Droid - обновление выполняется через его клиент (при отсутствии — открывается страница приложения). + ссылка карточки открывается в совместимом клиенте или браузере. Для GitHub требуется отдельное согласие на скачивание. - **Пропустить версию** — больше не напоминать автоматически об этой версии в этом источнике. О следующих версиях уведомления появятся. Ручная проверка покажет и пропущенную версию.