From fefa91924dd126c9df3986e16bac63b8744e9a78 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 5 Oct 2026 02:17:25 +0300 Subject: [PATCH 1/4] feat(mobile): launcher shortcuts, a Transfers widget and download notification actions --- AGENTS.md | 2 +- README.md | 9 +- .../src/main/kotlin/DesignTokenCodegen.kt | 23 ++ .../main/kotlin/GenerateDesignTokensTask.kt | 9 +- .../src/main/kotlin/GenerateShortcutsTask.kt | 48 +++ .../src/test/kotlin/DesignTokenCodegenTest.kt | 11 + .../src/test/kotlin/GenerateShortcutsTest.kt | 23 ++ core/design/build.gradle.kts | 5 + design/README.md | 5 +- docs/behavior.md | 97 ++++- docs/harness.md | 45 +++ mobile/build.gradle.kts | 15 + .../android/MobileHomeScreenProofTest.kt | 374 ++++++++++++++++++ mobile/src/main/AndroidManifest.xml | 23 ++ .../io/putdotio/android/MobileDeepLink.kt | 98 ++++- .../kotlin/io/putdotio/android/MobileShell.kt | 50 ++- .../io/putdotio/android/SignedInMobileRoot.kt | 17 + .../android/auth/MobileOAuthRuntime.kt | 25 +- .../putdotio/android/auth/MobileSessionKey.kt | 11 + .../android/documents/MobileDocuments.kt | 12 +- .../downloads/MobileDownloadActionReceiver.kt | 135 +++++++ .../android/downloads/MobileDownloadEngine.kt | 27 +- .../downloads/MobileDownloadNotifications.kt | 67 ++-- .../transfers/MobileTransfersScreen.kt | 41 +- .../android/widgets/MobileTransfersWidget.kt | 230 +++++++++++ .../widgets/MobileTransfersWidgetViews.kt | 84 ++++ .../android/widgets/TransfersWidgetContent.kt | 53 +++ .../res/drawable/shortcut_add_transfer.xml | 10 + .../main/res/drawable/shortcut_downloads.xml | 10 + .../src/main/res/drawable/shortcut_search.xml | 10 + .../main/res/drawable/widget_background.xml | 6 + .../src/main/res/layout/widget_transfers.xml | 193 +++++++++ mobile/src/main/res/values-v31/dimens.xml | 4 + mobile/src/main/res/values/dimens.xml | 5 + mobile/src/main/res/values/strings.xml | 22 ++ .../res/xml-v31/transfers_widget_info.xml | 15 + .../main/res/xml/transfers_widget_info.xml | 12 + mobile/src/main/shortcuts.xml | 43 ++ .../putdotio/android/MobileDeepLinksTest.kt | 39 +- .../android/MobileShellLinkActionsTest.kt | 167 ++++++++ .../putdotio/android/MobileShortcutsTest.kt | 84 ++++ .../MobileDownloadActionReceiverTest.kt | 114 ++++++ .../MobileDownloadNotificationsTest.kt | 67 +++- .../widgets/MobileTransfersWidgetTest.kt | 283 +++++++++++++ 44 files changed, 2507 insertions(+), 116 deletions(-) create mode 100644 build-logic/src/main/kotlin/GenerateShortcutsTask.kt create mode 100644 build-logic/src/test/kotlin/GenerateShortcutsTest.kt create mode 100644 mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetViews.kt create mode 100644 mobile/src/main/kotlin/io/putdotio/android/widgets/TransfersWidgetContent.kt create mode 100644 mobile/src/main/res/drawable/shortcut_add_transfer.xml create mode 100644 mobile/src/main/res/drawable/shortcut_downloads.xml create mode 100644 mobile/src/main/res/drawable/shortcut_search.xml create mode 100644 mobile/src/main/res/drawable/widget_background.xml create mode 100644 mobile/src/main/res/layout/widget_transfers.xml create mode 100644 mobile/src/main/res/values-v31/dimens.xml create mode 100644 mobile/src/main/res/values/dimens.xml create mode 100644 mobile/src/main/res/xml-v31/transfers_widget_info.xml create mode 100644 mobile/src/main/res/xml/transfers_widget_info.xml create mode 100644 mobile/src/main/shortcuts.xml create mode 100644 mobile/src/test/kotlin/io/putdotio/android/MobileShellLinkActionsTest.kt create mode 100644 mobile/src/test/kotlin/io/putdotio/android/MobileShortcutsTest.kt create mode 100644 mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt create mode 100644 mobile/src/test/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetTest.kt diff --git a/AGENTS.md b/AGENTS.md index 07319587..3c5a7de7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ and [Harness](docs/harness.md) the emulator lanes, evidence, and live proof. | `core/common` | The put.io failure and result kernel (`PutioFailure`, `PutioResult`, `putioRequest`, API rejection reasons), Files ids, cursors and sort order, the download-token account read, timestamps, avatar URLs, account storage keys, `SessionScopedHolder` | | `core/design` | `PutioTheme`, generated design tokens, file-type and shared Phosphor drawables, `BasePutioActivity`, the inactive-account notice's words | | `domain/` | One domain's models, SDK repository, reducer and controller, shared by both surfaces: `account` (account settings, app config, inactive-account notice), `auth`, `files`, `history`, `playback`, `search`, `sharing` (public links; mobile only so far), `transfers`, `trash` | -| `mobile` | Phone and tablet app: touch UI, shell, navigation, offline downloads, the system file picker's documents provider, services and session wiring | +| `mobile` | Phone and tablet app: touch UI, shell, navigation, offline downloads, the system file picker's documents provider, launcher shortcuts, the home-screen widget, services and session wiring | | `tv` | Android TV app: D-pad UI, shell and session wiring | | `build-logic` | Convention plugins (`putio.android.application`, `putio.android.library`), design-token codegen, launcher-manifest check, proof tasks | diff --git a/README.md b/README.md index 562d8d11..9824e231 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ Mobile, on the emulator harness against the live API: under Account shows the queue in start order with each row's place, a 1–4 concurrency limit (default 3), paused, failed and missing rows with retry, storage used, and multi-select delete of local copies only. Finished and - failed downloads notify and open their row when notifications are allowed. + failed downloads notify and open their row when notifications are allowed, + with Play for a finished one and Try again for a failed one, each acting only + for the account that downloaded it. Completed downloads play without a connection and resume where this device left off; positions saved offline reach put.io once it answers again - Transfer intake: tapped magnet links, opened or shared `.torrent` files, shared @@ -54,6 +56,11 @@ Mobile, on the emulator harness against the live API: - Exclusive access: create, copy, share and revoke public links to your own files and folders from their action sheet; Account lists every link with revoke +- Launcher shortcuts: Search, Add transfer (an empty sheet; nothing is added + until Add) and Downloads, each waiting for sign-in when signed out +- A Transfers home-screen widget, proven on the emulator with a faked account so + far: active transfers with progress, refreshed every 30 minutes, on sign-in, + from the Transfers screen and on demand; sign-out clears it at once - System file picker, proven on the emulator with a faked account so far: while signed in, put.io is a read-only location in other apps' pickers with paged folders and search; files stream from put.io, or open from a completed audio diff --git a/build-logic/src/main/kotlin/DesignTokenCodegen.kt b/build-logic/src/main/kotlin/DesignTokenCodegen.kt index ca3fb4f3..f3087dba 100644 --- a/build-logic/src/main/kotlin/DesignTokenCodegen.kt +++ b/build-logic/src/main/kotlin/DesignTokenCodegen.kt @@ -35,6 +35,12 @@ object DesignTokenCodegen { */ val APP_TOKENS: Set = setOf("yellowSolid") + /** + * Tokens Android XML reads as `@color/putio_`: RemoteViews layouts, which Compose + * colors cannot reach, and adaptive shortcut icons. Add a token here before an XML surface uses it. + */ + val RESOURCE_TOKENS: Set = setOf("appBg", "componentBg", "text", "textSecondary", "yellowSolid") + /** * M3 role → token property (optionally `property:alpha`). These are the * contracted roles from DESIGN.md. The container and background entries @@ -126,6 +132,23 @@ object DesignTokenCodegen { return render(resolved, ratios, designVersion) } + /** `values/putio_design_colors.xml`: the [RESOURCE_TOKENS] colors, resolved like the Kotlin ones. */ + fun generateColorResources(dtcgJson: String, designVersion: String): String { + val tokens = parse(dtcgJson) + return buildString { + appendLine("") + appendLine("") + appendLine("") + for ((property, cssName) in TOKEN_BINDINGS) { + if (property !in RESOURCE_TOKENS) continue + val argb = resolve(tokens, cssName) + appendLine(" #${"%08X".format(argb)}") + } + appendLine("") + } + } + private fun parse(dtcgJson: String): Map { val root = Json.parseToJsonElement(dtcgJson).jsonObject val out = mutableMapOf() diff --git a/build-logic/src/main/kotlin/GenerateDesignTokensTask.kt b/build-logic/src/main/kotlin/GenerateDesignTokensTask.kt index 5bdb2507..2356f54b 100644 --- a/build-logic/src/main/kotlin/GenerateDesignTokensTask.kt +++ b/build-logic/src/main/kotlin/GenerateDesignTokensTask.kt @@ -10,7 +10,7 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction -/** Writes PutioDesignTokens.kt from the vendored DTCG token graph. */ +/** Writes PutioDesignTokens.kt and the XML color resources from the vendored DTCG token graph. */ @CacheableTask abstract class GenerateDesignTokensTask : DefaultTask() { @@ -25,6 +25,10 @@ abstract class GenerateDesignTokensTask : DefaultTask() { @get:OutputDirectory abstract val outputDir: DirectoryProperty + /** An Android resource directory holding `values/putio_design_colors.xml`. */ + @get:OutputDirectory + abstract val resOutputDir: DirectoryProperty + @TaskAction fun generate() { val source = tokensFile.get().asFile.readText() @@ -32,5 +36,8 @@ abstract class GenerateDesignTokensTask : DefaultTask() { val target = outputDir.get().asFile.resolve("io/putdotio/android/design/PutioDesignTokens.kt") target.parentFile.mkdirs() target.writeText(code) + val colors = resOutputDir.get().asFile.resolve("values/putio_design_colors.xml") + colors.parentFile.mkdirs() + colors.writeText(DesignTokenCodegen.generateColorResources(source, designVersion.get())) } } diff --git a/build-logic/src/main/kotlin/GenerateShortcutsTask.kt b/build-logic/src/main/kotlin/GenerateShortcutsTask.kt new file mode 100644 index 00000000..7d418551 --- /dev/null +++ b/build-logic/src/main/kotlin/GenerateShortcutsTask.kt @@ -0,0 +1,48 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Writes `xml/shortcuts.xml` from a template with each `${applicationId}` replaced. The platform + * reads a static shortcut's `android:targetPackage` literally, without resolving resource + * references, and every channel and build type installs under its own application id. + */ +@CacheableTask +abstract class GenerateShortcutsTask : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val template: RegularFileProperty + + @get:Input + abstract val applicationId: Property + + /** An Android resource directory. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val target = outputDir.get().asFile.resolve("xml/shortcuts.xml") + target.parentFile.mkdirs() + target.writeText(renderShortcuts(template.get().asFile.readText(), applicationId.get())) + } +} + +internal fun renderShortcuts(template: String, applicationId: String): String { + if (PLACEHOLDER !in template) throw GradleException("The shortcuts template names no $PLACEHOLDER") + if (!applicationId.matches(Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+"))) { + throw GradleException("'$applicationId' is not an application id") + } + return template.replace(PLACEHOLDER, applicationId) +} + +private const val PLACEHOLDER = "\${applicationId}" diff --git a/build-logic/src/test/kotlin/DesignTokenCodegenTest.kt b/build-logic/src/test/kotlin/DesignTokenCodegenTest.kt index 6e2e1ee5..86bb3eb2 100644 --- a/build-logic/src/test/kotlin/DesignTokenCodegenTest.kt +++ b/build-logic/src/test/kotlin/DesignTokenCodegenTest.kt @@ -101,4 +101,15 @@ class DesignTokenCodegenTest { assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("tv-overscan-y")) } + + @Test + fun generatesXmlColorsForTheResourceTokensOnly() { + val dtcg = java.io.File("../design/tokens.dtcg.json").readText() + val xml = DesignTokenCodegen.generateColorResources(dtcg, "3.3.0") + assertTrue(xml.contains("#FFFDCE45")) + // Dark mode wins here too. + assertTrue(xml.contains("#FF161616")) + assertEquals(DesignTokenCodegen.RESOURCE_TOKENS.size, Regex("""" + assertEquals( + """""".repeat(2), + renderShortcuts(template, "io.put.putio.mobile.nightly.debug"), + ) + } + + @Test + fun aTemplateWithoutThePlaceholderOrABadIdFails() { + assertThrows(GradleException::class.java) { renderShortcuts("", "io.put.putio.mobile") } + assertThrows(GradleException::class.java) { + renderShortcuts("${'$'}{applicationId}", "io.put.putio.mobile\" android:x=\"") + } + } +} diff --git a/core/design/build.gradle.kts b/core/design/build.gradle.kts index 4246b6ab..857f7f42 100644 --- a/core/design/build.gradle.kts +++ b/core/design/build.gradle.kts @@ -27,6 +27,7 @@ val generateDesignTokens = tasks.register("generateDes }, ) outputDir.set(layout.buildDirectory.dir("generated/designTokens/kotlin")) + resOutputDir.set(layout.buildDirectory.dir("generated/designTokens/res")) } androidComponents { @@ -35,5 +36,9 @@ androidComponents { generateDesignTokens, GenerateDesignTokensTask::outputDir, ) + variant.sources.res?.addGeneratedSourceDirectory( + generateDesignTokens, + GenerateDesignTokensTask::resOutputDir, + ) } } diff --git a/design/README.md b/design/README.md index 74ca28c6..b1f457ed 100644 --- a/design/README.md +++ b/design/README.md @@ -15,7 +15,10 @@ SRI, and pins the SHA-256 of every source asset and of every written file. The Compose color schemes and the TV overscan ratios (`tv.overscan.x/y`) are generated from the JSON at build time by `:core:design:generateDesignTokens` (task class in `build-logic/`), which records the locked version in the generated -header. Never hand-edit the generated Kotlin, the JSON, or the icons. +header. The same task writes `@color/putio_` resources for the tokens in +`RESOURCE_TOKENS`, for XML that Compose cannot reach: the home-screen widget's +RemoteViews layout and the adaptive shortcut icons. Never hand-edit the generated +Kotlin, the JSON, or the icons. Syncing downloads that one tarball, verifies it and each source asset before use, and writes the files only when every output matches the lock. Icon diff --git a/docs/behavior.md b/docs/behavior.md index e8e13108..42988831 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -774,16 +774,39 @@ against each merged manifest in `check`. (`autoVerify`; the `assetlinks.json` publication is a release-owner task, so unverified installs still open through the app chooser or an explicit package) and `putio://{files,transfers,search,history,trash,downloads}`, plus +`putio://transfers/add`, which opens Transfers with an empty Add transfer sheet, `putio://downloads/`, which a download notification opens to show that -row's actions. `putio://auth` +row's actions, and `putio://downloads//play`, which plays that row's copy +on this device over Downloads or, without one, opens the row. Links a download +notification builds add `?user=`: under any other account the shell +drops them unopened, and a `user` that is not an account id voids the link. No +other query is read. `putio://auth` stays with the OAuth receiver. A link is consumed once per Activity intent, any put.io URI is removed from the retained intent, and routing waits for sign-in; -an unrouted link survives process death as its token-free `putio://` form. A file +an unrouted link survives process death as its token-free `putio://` form, +`user` included. A file id resolves through the item resolver, opens as under [History opens](#timestamps-and-history-opens) with Back staying in Files, and reuses the navigation-failure dialog. -Tests: `MobileDeepLinksTest`, `MainActivityDeepLinkTest`. +Tests: `MobileDeepLinksTest`, `MainActivityDeepLinkTest`, `MobileShellLinkActionsTest`. + +## App shortcuts + +Long-pressing the mobile launcher icon offers three static shortcuts: Search +(`putio://search`), Add transfer (`putio://transfers/add`) and Downloads +(`putio://downloads`). Each is an explicit `ACTION_VIEW` intent for this +variant's `MainActivity`, whose package the build generates from the variant's +application id, so production, nightly and debug installs never open each +other's. Each routes as the product link it carries under +[Deep links](#deep-links): signed out, the app shows sign-in and opens the +target once signed in. Add transfer opens an empty sheet and adds nothing until +the viewer taps Add; no shortcut changes anything on its own. The shortcuts +need no session, so the launcher lists them from install. Their icons are +adaptive: a Phosphor glyph on the brand yellow, in the launcher's own shape. + +Tests: `MobileShortcutsTest` (reads `shortcuts.xml` with the platform's own +intent parser), `MobileShellLinkActionsTest`, `MobileDeepLinksTest`. ## Timestamps and History opens @@ -943,13 +966,29 @@ the delete on the next start. **Notifications.** When a download finishes or fails, with or without the app open, a notification names the file and opens its row in Downloads; the lock -screen sees the outcome without the name. Each file has one slot, cleared by a -retry or a delete. Only the account signed in now is notified, so one account's +screen sees the outcome without the name. A finished download's notification +offers Play, which plays the copy on this device over Downloads (or opens the +row if the copy is gone), and Open in Downloads; a failed one offers Try again +and Open in Downloads. Play, Open and the tap are explicit, immutable intents +for MainActivity carrying `putio://downloads/[/play]?user=`, so +under another account they open nothing (see [Deep links](#deep-links)). Try +again is an explicit, immutable broadcast to an unexported receiver, so no +other app can send it. It hands the download back to Media3 without opening +the app, and the row follows Media3's state as after any background change, +but only for the account that owns it, once the session settles (a stored one +is restored first, for up to 15 s), and only while the row is still failed or +missing. Under another account or none, or for a row that is gone, it removes +the notification instead; while the session cannot be confirmed it does +nothing. Each file has one slot, cleared by a retry, a delete or the end of +its account's session: when a session ends, every outcome of an account other +than the one signed in then is removed. Only the account signed in now is +notified, so one account's outcome never appears while another is signed in. Nothing is posted while the app's notifications or the Finished downloads channel are off, or, on Android 13 and later, while POST_NOTIFICATIONS is not granted. The first download asks for that permission once; afterwards Downloads shows a notice whose Turn on asks again while the -system allows it and otherwise opens the app's notification settings. +system allows it and otherwise opens the app's notification settings. Transfers +post no notifications, so they have no actions. **Offline resume.** Offline playback resumes like streaming: with the resume setting confirmed this session, else the one this device last confirmed, else @@ -974,17 +1013,59 @@ copies, concurrency, progress polling while shown), `MobileDownloadsScreenTest` (queue order and places, the limit, the notification notice, selection, shown and hidden events), `MobileShellDownloadsTest` (bulk delete never reaches put.io, notification links, the subtitle setting a Files download carries), -`MobileDownloadStoreTest` (unreadable rows kept), `MobileDownloadEngineTest` (queue order and limit +`MobileDownloadStoreTest` (unreadable rows kept), `MobileDownloadActionReceiverTest` (Try again for the +owner only), `MobileDownloadEngineTest` (queue order and limit across process recreation, recovery of every row state, rows rebuilt for downloads an unreadable index lost, low-storage pause, reconcile, sign-out parking, account isolation, close before reconcile, in-memory progress and its denominator, the recorded request URL and the local-copy check against a real Media3 manager and cache), `MobileDownloadNotificationsTest` (granted, denied and disabled states, -signed-in account only), `OfflinePlaybackPositionsTest` (sync rules, the +signed-in account only, actions and their intents, other accounts' outcomes at a +session's end), `OfflinePlaybackPositionsTest` (sync rules, the player's write racing a pass, refused files), `MobilePlaybackReportingTest` (cold offline start), `UserScopedCacheKeysTest` (token-free, user-scoped cache keys), `OfflinePlaybackRepositoryTest`, `MobileDeepLinksTest`. +## Transfers widget + +Mobile offers a resizable four-by-two Transfers home-screen widget. Signed in, +it lists up to three transfers still running (anything but completed or +failed, seeding included) from the first page of the account's transfers, +newest first. Each row shows the status in the Transfers screen's words, a +percentage when put.io reports one and a progress bar, indeterminate without +one; a footer says when the rows were read and how many more are running. With +none it reads No active transfers. A tap opens Transfers through +`putio://transfers`, and a refresh button reads again. Signed out it reads Sign +in to put.io to see your transfers, with no refresh button, and a tap opens +sign-in, then Transfers. + +It reads the transfers through the SDK in the signed-in session, never with a +token of its own: when it is placed, every 30 minutes (`updatePeriodMillis`, +the platform's shortest period), on sign-in, on its refresh button, and +whenever the Transfers screen loads rows, which it shows without a request of +its own. With no widget placed nothing is read. A refresh replaces one still +running and gives up after 25 s. In a process the app has not started it +restores the stored session first, for up to 15 s, as the download service +does. A 401 signs the session out as anywhere else. A failed read keeps rows +already shown, with the time they were read; with none shown, or when the +session cannot be confirmed, it reads Couldn't reach put.io. + +Rows belong to the session that read them. A read is shown only while that +session is still signed in, checked under one lock with sign-out, so a read +that finishes after sign-out or an account switch is dropped. A session's end +(sign-out, a rejected session, a cold start whose restore ends signed out) +replaces every placed widget with the signed-out state at once and empties +each row's text too, because launchers reapply new views onto the old ones. +Nothing is stored: the launcher holds the only copy of the names, and the +widget's intents carry token-free product links. The refresh button is an +explicit, immutable broadcast to an unexported receiver. Its colors are the +generated design tokens' XML colors (`@color/putio_*`). + +Tests: `MobileTransfersWidgetTest` (each auth state against Robolectric's +widget host, which reapplies views as launchers do: sign-out, a read that +outlives its session, an account switch, a 401, failed reads, no widget, the +Transfers screen's rows, intents). + ## TV Back Files is the TV home, as Home is in tv-native and tv-vite. Back with focus on diff --git a/docs/harness.md b/docs/harness.md index 5365ff6d..5497ae29 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -1374,6 +1374,51 @@ synthetic proof. Follow the [Evidence](#evidence) contract. granted, restores the concurrency setting and removes only its own index rows. Screenshots land in `downloads-queue-proof-/`. +## Shortcuts, widget and notification actions proof + +Behaviour: [App shortcuts](./behavior.md#app-shortcuts), +[Transfers widget](./behavior.md#transfers-widget) and the notifications under +[Downloads and offline playback](./behavior.md#downloads-and-offline-playback); +`MobileShortcutsTest`, `MobileShellLinkActionsTest`, `MobileTransfersWidgetTest`, +`MobileDownloadNotificationsTest` and `MobileDownloadActionReceiverTest` pin +them on the JVM. Run this lane on an emulator you booted, for example +`scripts/emulator.sh create phone --name ` then +`scripts/emulator.sh boot phone --name --headless`: it places a widget on +the launcher's home screen and leaves it there, and it grants +POST_NOTIFICATIONS. + +Shortcuts need no session. `adb shell dumpsys shortcut` must list `search`, +`add-transfer` and `downloads` for the package, each with +`cmp=/io.putdotio.android.MainActivity`; a resource reference in place +of the package would show as `cmp=@/...` and open nothing. Long-press the +icon with `adb shell "input motionevent DOWN ; sleep 1.5; input +motionevent UP "` (`input swipe` in place does not open the menu on the +API 37 launcher), capture the menu, then tap Add transfer: signed out it opens +sign-in. + +`MobileHomeScreenProofTest` drives the real launcher and notification shade +over a controlled session and synthetic rows; it makes no API calls, so report +it as synthetic proof. Follow the [Evidence](#evidence) contract with +`putio.homescreen.enabled=true` and `putio.homescreen.runId=`, one +selector at a time, and require `OK (1 test)` for each. Start +`scripts/evidence.sh record --seconds 75` just before the notification selector. +Screenshots land in `homescreen-proof-/`: + +- `theTransfersWidgetFollowsTheSessionAndForgetsItOnSignOut` pins the widget + through the launcher's dialog the first time, then captures + `01-widget-signed-out`, `02-widget-signed-in` (three rows and `1 more`), + `03-widget-cleared-on-sign-out` (no row name anywhere on screen) and + `04-widget-next-account` +- `downloadNotificationActionsActOnlyForTheirOwnAccount` captures + `01-failed-offers-try-again` and `02-retried-for-its-account` (the owner's + Try again reached the receiver and replaced the outcome), + `03-another-accounts-outcome` and `04-another-account-refused` (Try again on + an earlier account's notification retried nothing and removed it), then + `05-finished-offers-play` and `06-play-opens-the-app`: Play opened + MainActivity holding `putio://downloads//play?user=`, which waits at + sign-in on a signed-out install. The receiver's Media3 start is a stand-in; + routing after sign-in is `MobileShellLinkActionsTest` + ## Shared-with-me items proof Behaviour: [Shared-with-me items](./behavior.md#shared-with-me-items). diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index b2abefac..2edc6eb9 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -61,6 +61,21 @@ android { } } +androidComponents { + onVariants { variant -> + // Static shortcuts name their target package literally, and each channel and build type + // installs under its own application id. + val shortcuts = tasks.register( + "generate${variant.name.replaceFirstChar(Char::titlecase)}Shortcuts", + ) { + template.set(layout.projectDirectory.file("src/main/shortcuts.xml")) + applicationId.set(variant.applicationId) + outputDir.set(layout.buildDirectory.dir("generated/shortcuts/${variant.name}")) + } + variant.sources.res?.addGeneratedSourceDirectory(shortcuts, GenerateShortcutsTask::outputDir) + } +} + val launchSmokeTest = "io.putdotio.android.LaunchSmokeTest#shellLaunchesStaysResumedAndRenders" tasks.register("verifyLaunchProof") { diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt new file mode 100644 index 00000000..ff68ef83 --- /dev/null +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt @@ -0,0 +1,374 @@ +package io.putdotio.android + +import android.Manifest +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.Instrumentation +import android.app.NotificationManager +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.graphics.Bitmap +import android.graphics.Rect +import android.os.Build +import android.os.SystemClock +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.content.edit +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import io.putdotio.android.auth.MobileAccount +import io.putdotio.android.auth.MobileAuthSessionId +import io.putdotio.android.auth.MobileAuthState +import io.putdotio.android.downloads.DownloadArtifact +import io.putdotio.android.downloads.DownloadEntry +import io.putdotio.android.downloads.DownloadFailureReason +import io.putdotio.android.downloads.DownloadNotificationRetries +import io.putdotio.android.downloads.DownloadOutcome +import io.putdotio.android.downloads.DownloadStatus +import io.putdotio.android.downloads.MobileDownloadActionReceiver +import io.putdotio.android.downloads.MobileDownloadNotifications +import io.putdotio.android.downloads.MobileDownloadStore +import io.putdotio.android.downloads.SignedInAccount +import io.putdotio.android.downloads.downloadPreferences +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.transfers.AppTransferStatus +import io.putdotio.android.transfers.TransferId +import io.putdotio.android.transfers.TransferItem +import io.putdotio.android.widgets.MobileTransfersWidgetProvider +import io.putdotio.android.widgets.MobileWidgets +import io.putdotio.android.widgets.TransfersWidgetSource +import io.putdotio.android.widgets.TransfersWidgetUpdater +import io.putdotio.sdk.files.PutioFileType +import java.io.File +import java.io.FileInputStream +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * The Transfers widget on the real launcher and the download notifications' actions in the real shade, + * over a controlled session and synthetic rows: no API call, credential or sign-in. Run it on an + * emulator you booted: the widget it places stays on that home screen. + */ +@RunWith(AndroidJUnit4::class) +class MobileHomeScreenProofTest { + @get:Rule val optIn = TestRule { base, _ -> + object : Statement() { + override fun evaluate() { + assumeTrue("Home-screen proof requires opt-in", arguments.getString("putio.homescreen.enabled") == "true") + require(Build.VERSION.SDK_INT == 37) { "Home-screen proof requires API 37" } + runId() + // The shade and the launcher are other apps' windows; reading them needs every window. + val automation = instrumentation.uiAutomation + automation.serviceInfo = automation.serviceInfo.apply { + flags = flags or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + } + base.evaluate() + } + } + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val auth = MutableStateFlow(MobileAuthState.SignedOut()) + private var transfers: List = emptyList() + + @After + fun tearDown() { + MobileWidgets.transfersForTest = null + MobileDownloadActionReceiver.retriesForTest = null + context.getSystemService(NotificationManager::class.java).cancelAll() + downloadPreferences(context).edit(commit = true) { + remove("user-$USER") + remove("user-$OTHER") + } + scope.cancel() + } + + @Test + fun theTransfersWidgetFollowsTheSessionAndForgetsItOnSignOut() { + MobileWidgets.transfersForTest = TransfersWidgetUpdater( + context = context, + source = TransfersWidgetSource( + authState = auth, + settledSession = { auth.value }, + load = { PutioResult.Success(transfers) }, + rejectSession = { error("No session is rejected in this lane") }, + ), + scope = scope, + ) + val provider = ComponentName(context, MobileTransfersWidgetProvider::class.java) + val widgets = AppWidgetManager.getInstance(context) + if (widgets.getAppWidgetIds(provider).isEmpty()) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + awaitCondition("MainActivity resumed") { scenario.state == Lifecycle.State.RESUMED } + check(widgets.requestPinAppWidget(provider, null, null)) { "The launcher cannot pin widgets" } + tap(awaitNode("the launcher's add button") { node -> + node.text?.toString()?.let(ADD_BUTTON::matches) == true + }) + awaitCondition("the widget placed") { widgets.getAppWidgetIds(provider).isNotEmpty() } + } + } + home() + awaitText(SIGNED_OUT) + screenshot("01-widget-signed-out") + + transfers = FIRST_ACCOUNT + auth.value = signedIn(USER, 1L) + MobileWidgets.transfers(context).refresh() + awaitText(FIRST_ACCOUNT.first().name) + screenshot("02-widget-signed-in") + + auth.value = MobileAuthState.SignedOut() + MobileWidgets.transfers(context).clear() + awaitText(SIGNED_OUT) + for (transfer in FIRST_ACCOUNT) assertNull(transfer.name, findNode(transfer.name)) + screenshot("03-widget-cleared-on-sign-out") + + transfers = NEXT_ACCOUNT + auth.value = signedIn(OTHER, 2L) + MobileWidgets.transfers(context).refresh() + awaitText(NEXT_ACCOUNT.first().name) + for (transfer in FIRST_ACCOUNT) assertNull(transfer.name, findNode(transfer.name)) + screenshot("04-widget-next-account") + } + + @Test + fun downloadNotificationActionsActOnlyForTheirOwnAccount() { + instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.POST_NOTIFICATIONS) + val finished = row(9001L, "Sintel.mkv", DownloadStatus.Completed(1_024L)) + val failed = row(9002L, "Tears of Steel.mkv", DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + val otherAccounts = row(9003L, "Another account.mkv", DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + runBlocking { + MobileDownloadStore(context, USER).apply { + upsert(finished) + upsert(failed) + } + MobileDownloadStore(context, OTHER).upsert(otherAccounts) + } + val started = CopyOnWriteArrayList>() + MobileDownloadActionReceiver.retriesForTest = DownloadNotificationRetries( + signedIn = { SignedInAccount.User(USER) }, + find = { userId, fileId -> MobileDownloadStore.peek(context, userId, fileId) }, + // Stands in for Media3: the real start also replaces the outcome notification. + start = { userId, entry -> + started += userId to entry.fileId + MobileDownloadNotifications.cancel(context, userId, entry.fileId) + }, + dismiss = { userId, fileId -> MobileDownloadNotifications.cancel(context, userId, fileId) }, + ) + + MobileDownloadNotifications.post(context, USER, failed, DownloadOutcome.Failed(DownloadFailureReason.NETWORK)) + openShade() + awaitNotification("Tears of Steel.mkv") + screenshot("01-failed-offers-try-again") + tap(awaitAction("Try again")) + awaitCondition("the owner's retry") { started.contains(USER to failed.fileId) } + awaitCondition("the outcome replaced") { !posted(USER, failed.fileId) } + screenshot("02-retried-for-its-account") + closeShade() + + // A notification an earlier account left behind: Try again under this account retries nothing. + MobileDownloadNotifications.post( + context, + OTHER, + otherAccounts, + DownloadOutcome.Failed(DownloadFailureReason.NETWORK), + ) + openShade() + awaitNotification("Another account.mkv") + screenshot("03-another-accounts-outcome") + tap(awaitAction("Try again")) + awaitCondition("the other account's outcome removed") { !posted(OTHER, otherAccounts.fileId) } + assertEquals(listOf(USER to failed.fileId), started.toList()) + screenshot("04-another-account-refused") + closeShade() + + MobileDownloadNotifications.post(context, USER, finished, DownloadOutcome.Completed) + openShade() + awaitNotification("Sintel.mkv") + screenshot("05-finished-offers-play") + tap(awaitAction("Play")) + val activity = awaitResumedMainActivity() + // Signed out here, so MainActivity holds the scoped link until sign-in; the shell then plays the copy. + assertEquals( + MobileDeepLink.Downloads(finished.fileId, play = true, userId = USER), + activity.deepLinkRequests.pending.value, + ) + SystemClock.sleep(SETTLE_MS) + screenshot("06-play-opens-the-app") + instrumentation.runOnMainSync { activity.finish() } + } + + /** A notification action; the shade may show the notification collapsed, so it is expanded first. */ + private fun awaitAction(label: String): AccessibilityNodeInfo { + val action = { node: AccessibilityNodeInfo -> node.text?.toString() == label && node.isClickable } + nodes().firstOrNull(action)?.let { return it } + nodes().firstOrNull { it.contentDescription?.toString()?.startsWith("Expand") == true }?.let(::tap) + return awaitNode(label, action) + } + + private fun posted(userId: Long, fileId: FilesItemId): Boolean = + context.getSystemService(NotificationManager::class.java).activeNotifications + .any { it.tag == "download:$userId:${fileId.value}" } + + private fun awaitResumedMainActivity(): MainActivity { + var resumed: MainActivity? = null + awaitCondition("MainActivity resumed from the notification") { + instrumentation.runOnMainSync { + resumed = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED) + .filterIsInstance().firstOrNull() + } + resumed != null + } + return checkNotNull(resumed) + } + + private fun openShade() { + instrumentation.uiAutomation.performGlobalAction(AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS) + SystemClock.sleep(SETTLE_MS) + } + + private fun closeShade() { + instrumentation.uiAutomation.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) + SystemClock.sleep(SETTLE_MS) + } + + private fun home() { + instrumentation.uiAutomation.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME) + SystemClock.sleep(SETTLE_MS) + } + + /** An expanded notification reads its title, the name and the reason as one text. */ + private fun awaitNotification(name: String) = + awaitNode("the notification for $name") { it.text?.toString()?.lines()?.contains(name) == true } + + private fun awaitText(text: String) = awaitNode("'$text' on screen") { it.text?.toString() == text } + + private fun findNode(text: String): AccessibilityNodeInfo? = nodes().firstOrNull { it.text?.toString() == text } + + private fun awaitNode(label: String, matches: (AccessibilityNodeInfo) -> Boolean): AccessibilityNodeInfo { + var found: AccessibilityNodeInfo? = null + awaitCondition(label) { + found = nodes().firstOrNull(matches) + found != null + } + return checkNotNull(found) + } + + /** Every visible node of every window: the shade and the launcher are not this app's windows. */ + private fun nodes(): List { + val roots = instrumentation.uiAutomation.windows.mapNotNull { it.root } + + listOfNotNull(instrumentation.uiAutomation.rootInActiveWindow) + val all = mutableListOf() + val queue = ArrayDeque(roots) + while (queue.isNotEmpty()) { + val node = queue.removeFirst() + if (node.isVisibleToUser) all += node + for (index in 0 until node.childCount) node.getChild(index)?.let(queue::addLast) + } + return all + } + + /** A real touch, as a person taps. */ + private fun tap(node: AccessibilityNodeInfo) { + val bounds = Rect().also(node::getBoundsInScreen) + instrumentation.uiAutomation.executeShellCommand("input tap ${bounds.centerX()} ${bounds.centerY()}") + .use { FileInputStream(it.fileDescriptor).readBytes() } + SystemClock.sleep(SETTLE_MS) + } + + private fun awaitCondition(label: String, condition: () -> Boolean) { + val deadline = SystemClock.uptimeMillis() + TIMEOUT_MS + while (!condition()) { + check(SystemClock.uptimeMillis() < deadline) { + "Timed out waiting for $label; on screen: ${nodes().mapNotNull { it.text?.toString() }}" + } + SystemClock.sleep(POLL_MS) + } + } + + private fun screenshot(label: String) { + instrumentation.waitForIdleSync() + instrumentation.uiAutomation.waitForIdle(IDLE_MS, TIMEOUT_MS) + val directory = File(requireNotNull(context.getExternalFilesDir(null)), "homescreen-proof-${runId()}") + check(directory.mkdirs() || directory.isDirectory) + val bitmap = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + try { + File(directory, "$label.png").outputStream().use { + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + } finally { + bitmap.recycle() + } + } + + private fun runId(): UUID = UUID.fromString(requireNotNull(arguments.getString("putio.homescreen.runId"))) + private val arguments get() = InstrumentationRegistry.getArguments() + private val instrumentation: Instrumentation get() = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + + private companion object { + const val USER = 147L + const val OTHER = 148L + const val TIMEOUT_MS = 20_000L + const val POLL_MS = 200L + const val IDLE_MS = 300L + const val SETTLE_MS = 1_500L + const val SIGNED_OUT = "Sign in to put.io to see your transfers." + val ADD_BUTTON = Regex("(?i)add( to home screen)?") + + val FIRST_ACCOUNT = listOf( + transfer(1L, "ubuntu-26.04-desktop-amd64.iso", AppTransferStatus.Downloading, 42.0), + transfer(2L, "Big Buck Bunny (2008) 1080p", AppTransferStatus.Queued, null), + transfer(3L, "Sintel.2010.4K.mkv", AppTransferStatus.Seeding, 100.0), + transfer(4L, "debian-13.1.0-amd64-netinst.iso", AppTransferStatus.Waiting, 0.0), + ) + val NEXT_ACCOUNT = listOf( + transfer(9L, "Tears of Steel (2012) 4K", AppTransferStatus.Downloading, 7.0), + ) + + fun signedIn(userId: Long, session: Long) = MobileAuthState.SignedIn( + account = MobileAccount(userId = userId, username = "proof$userId", email = "proof@example.invalid"), + sessionId = MobileAuthSessionId(session), + ) + + fun row(fileId: Long, name: String, status: DownloadStatus) = DownloadEntry( + FilesItemId(fileId), name, PutioFileType.VIDEO, DownloadArtifact.HLS, status, + createdAt = fileId, accepted = true, + ) + + fun transfer(id: Long, name: String, status: AppTransferStatus, percent: Double?) = TransferItem( + id = TransferId(id), + name = name, + status = status, + fileId = null, + sizeBytes = null, + percentDone = percent, + downloadSpeedBytesPerSecond = null, + uploadSpeedBytesPerSecond = null, + estimatedSecondsRemaining = null, + availability = null, + errorMessage = null, + createdAt = "2026-10-04T00:00:00", + userFileExists = null, + ) + } +} diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 0131a3be..c6055f02 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -93,7 +93,30 @@ + + + + + + + + + + + + ? { @@ -41,16 +59,32 @@ private fun Uri.productSegments(): List? { }?.filter { it.isNotBlank() } } -private fun deepLinkFor(segments: List): MobileDeepLink? = when (val section = segments.firstOrNull()) { - null, "files" -> filesDeepLink(segments) - "downloads" -> downloadsDeepLink(segments) - else -> SECTION_LINKS[section]?.takeIf { segments.size == 1 } -} - -private fun downloadsDeepLink(segments: List): MobileDeepLink? = when (segments.size) { - 1 -> MobileDeepLink.Downloads() - 2 -> segments[1].toLongOrNull()?.takeIf { it > 0L }?.let { MobileDeepLink.Downloads(FilesItemId(it)) } - else -> null +private fun deepLinkFor(segments: List, user: String?): MobileDeepLink? = + when (val section = segments.firstOrNull()) { + null, "files" -> filesDeepLink(segments) + "downloads" -> downloadsDeepLink(segments, user) + "transfers" -> when (segments.drop(1)) { + emptyList() -> MobileDeepLink.Transfers + listOf("add") -> MobileDeepLink.AddTransfer + else -> null + } + else -> SECTION_LINKS[section]?.takeIf { segments.size == 1 } + } + +/** An opaque URI such as `putio:x` has no query at all. */ +private fun Uri.userQuery(): String? = if (isHierarchical) getQueryParameter(USER_QUERY) else null + +/** `downloads`, `downloads/` or `downloads//play`; a `user` that is not an account id voids the link. */ +private fun downloadsDeepLink(segments: List, user: String?): MobileDeepLink? { + val userId = user?.toLongOrNull()?.takeIf { it > 0L } + val fileId = segments.getOrNull(1)?.toLongOrNull()?.takeIf { it > 0L }?.let(::FilesItemId) + val play = when (segments.drop(2)) { + emptyList() -> false + listOf(PLAY_SEGMENT) -> true + else -> null + } + val valid = play != null && (user == null || userId != null) && (segments.size == 1 || fileId != null) + return if (valid) MobileDeepLink.Downloads(fileId, play = play == true, userId = userId) else null } private fun filesDeepLink(segments: List): MobileDeepLink? = when (segments.size) { @@ -64,27 +98,53 @@ internal fun MobileDeepLink.toRouteUri(): Uri = when (this) { MobileDeepLink.Files -> "putio://files" is MobileDeepLink.File -> "putio://files/${id.value}" MobileDeepLink.Transfers -> "putio://transfers" + MobileDeepLink.AddTransfer -> "putio://transfers/add" MobileDeepLink.Search -> "putio://search" MobileDeepLink.History -> "putio://history" MobileDeepLink.Trash -> "putio://trash" - is MobileDeepLink.Downloads -> fileId?.let { "putio://downloads/${it.value}" } ?: "putio://downloads" + is MobileDeepLink.Downloads -> buildString { + append("putio://downloads") + fileId?.let { append("/${it.value}") } + if (play && fileId != null) append("/$PLAY_SEGMENT") + userId?.let { append("?$USER_QUERY=$it") } + } }.toUri() +/** + * An explicit, immutable pending intent that opens this link in MainActivity, for notifications and + * widgets. The link is its data, so each account, row and action keeps its own pending intent. + */ +internal fun MobileDeepLink.pendingIntent(context: Context): PendingIntent = + PendingIntent.getActivity( + context, + 0, + Intent(Intent.ACTION_VIEW, toRouteUri()) + .setClass(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + /** * Removes any product URI, recognised or not, so a replayed launch intent cannot route * again and a web link's query never reaches saved state. The OAuth callback and * foreign URIs are left alone. */ internal fun Intent.consumeMobileDeepLink(): MobileDeepLink? { - val segments = data?.takeIf { action == Intent.ACTION_VIEW }?.productSegments() ?: return null + val uri = data?.takeIf { action == Intent.ACTION_VIEW } + val segments = uri?.productSegments() ?: return null + val user = uri.userQuery() setDataAndType(null, null) - return deepLinkFor(segments) + return deepLinkFor(segments, user) } private val WEB_HOSTS = setOf("app.put.io", "put.io", "www.put.io") +/** Scopes a Downloads link to one account; the only query a product link reads. */ +private const val USER_QUERY = "user" + +private const val PLAY_SEGMENT = "play" + private val SECTION_LINKS = mapOf( - "transfers" to MobileDeepLink.Transfers, "search" to MobileDeepLink.Search, "history" to MobileDeepLink.History, "trash" to MobileDeepLink.Trash, diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt index a5c44d5c..63efe074 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt @@ -50,6 +50,7 @@ import io.putdotio.android.downloads.DownloadsController import io.putdotio.android.downloads.DownloadsEvent import io.putdotio.android.downloads.DownloadsState import io.putdotio.android.downloads.MOBILE_DOWNLOADS_ROUTE +import io.putdotio.android.downloads.toFilesItem import io.putdotio.android.files.FilesBrowserEvent import io.putdotio.android.files.FilesBrowserState import io.putdotio.android.files.FilesContent @@ -182,11 +183,15 @@ internal fun MobileShell( val shareNavigationBlocked = shareNavigationBlocked(filesState, trashState, transfersState, nowPlayingPending) DeepLinkNavigationEffect( deepLinkRequests = deepLinkRequests, + userId = account.userId, navigationReady = backStackEntry != null, navigationBlocked = shareNavigationBlocked, navController = navController, - onOpenFile = onOpenFile, - onFocusDownload = { fileId -> downloadsController?.dispatch(DownloadsEvent.Focus(fileId)) }, + targets = DeepLinkTargets( + openFile = onOpenFile, + openDownload = { fileId, play -> navController.openDownload(downloadsController, fileId, play) }, + addTransfer = transferDraft::open, + ), ) IncomingTransferNavigationEffect( incomingRequestId = incomingDraft.incomingRequestId, @@ -334,48 +339,65 @@ private fun shareNavigationBlocked( } || trashState?.hasPendingMutation == true || transfersState.navigation is TransferNavigation.Resolving || transfersState.mutation is TransferMutation.Running || nowPlayingPending +/** What a routed link opens beyond plain navigation. */ +private class DeepLinkTargets( + val openFile: suspend (FilesItemId) -> Unit, + val openDownload: (FilesItemId, play: Boolean) -> Unit, + val addTransfer: () -> Unit, +) + @Composable private fun DeepLinkNavigationEffect( deepLinkRequests: MobileDeepLinkRequests, + userId: Long, navigationReady: Boolean, navigationBlocked: Boolean, navController: NavHostController, - onOpenFile: suspend (FilesItemId) -> Unit, - onFocusDownload: (FilesItemId) -> Unit, + targets: DeepLinkTargets, ) { val pendingDeepLink by deepLinkRequests.pending.collectAsStateWithLifecycle() // Keyed on readiness rather than the entry so the navigation a link causes cannot restart it. - LaunchedEffect(pendingDeepLink, navigationReady, navigationBlocked) { + LaunchedEffect(pendingDeepLink, navigationReady, navigationBlocked, userId) { val link = pendingDeepLink ?: return@LaunchedEffect if (!navigationReady || navigationBlocked) return@LaunchedEffect - // A newer link restarts this effect and cancels an unfinished file resolve. - navController.openDeepLink(link, onOpenFile, onFocusDownload) + // A newer link restarts this effect and cancels an unfinished file resolve. One scoped to + // another account, such as an earlier account's notification, is dropped unopened. + if (link.belongsTo(userId)) navController.openDeepLink(link, targets) deepLinkRequests.acknowledge(link) } } -private suspend fun NavHostController.openDeepLink( - link: MobileDeepLink, - onOpenFile: suspend (FilesItemId) -> Unit, - onFocusDownload: (FilesItemId) -> Unit, -) { +private suspend fun NavHostController.openDeepLink(link: MobileDeepLink, targets: DeepLinkTargets) { when (link) { MobileDeepLink.Files -> navigateTo(MobileDestination.Files) is MobileDeepLink.File -> { navigateTo(MobileDestination.Files) - onOpenFile(link.id) + targets.openFile(link.id) } MobileDeepLink.Transfers -> navigateTo(MobileDestination.Transfers) + MobileDeepLink.AddTransfer -> { + navigateTo(MobileDestination.Transfers) + // Tab restoration can bring back playback above Transfers; the sheet belongs to Transfers. + popBackStack(MobileDestination.Transfers.route, inclusive = false) + targets.addTransfer() + } MobileDeepLink.Search, MobileDeepLink.History -> navigateTo(MobileDestination.Search) MobileDeepLink.Trash -> navigateToTrash() is MobileDeepLink.Downloads -> { navigateTo(MobileDestination.Account) navigate(MOBILE_DOWNLOADS_ROUTE) { launchSingleTop = true } - link.fileId?.let(onFocusDownload) + link.fileId?.let { targets.openDownload(it, link.play) } } } } +/** Plays a copy on this device over Downloads when asked to and it is there; otherwise opens its row. */ +private fun NavHostController.openDownload(controller: DownloadsController?, fileId: FilesItemId, play: Boolean) { + val state = controller?.state?.value ?: return + val entry = state.entry(fileId)?.takeIf { play && state.isAvailableOffline(fileId) } + if (entry != null) navigateToPlayback(entry.toFilesItem()) else controller.dispatch(DownloadsEvent.Focus(fileId)) +} + @Composable private fun IncomingTransferNavigationEffect( incomingRequestId: Long?, diff --git a/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt b/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt index 08d7057a..f1d9c181 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt @@ -15,6 +15,7 @@ import io.putdotio.android.auth.MobileAuthController import io.putdotio.android.auth.MobileAuthSessionId import io.putdotio.android.auth.MobileAuthState import io.putdotio.android.auth.MobileOAuthRuntime +import io.putdotio.android.auth.MobileSessionKey import io.putdotio.android.downloads.DownloadsController import io.putdotio.android.downloads.DownloadsEvent import io.putdotio.android.downloads.MobileDownloadCache @@ -69,6 +70,7 @@ import io.putdotio.android.transfers.TransfersState import io.putdotio.android.trash.MobileTrashViewModel import io.putdotio.android.trash.SdkTrashRepository import io.putdotio.android.trash.TrashController +import io.putdotio.android.widgets.MobileWidgets import io.putdotio.sdk.files.PutioCredentialUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -251,6 +253,7 @@ private fun SignedInMobileSession( val searchState by searchHistorySession.search.state.collectAsStateWithLifecycle() val historyState by searchHistorySession.history.state.collectAsStateWithLifecycle() val transfersState by transfersController.state.collectAsStateWithLifecycle() + TransfersWidgetEffect(transfersState.content, MobileSessionKey(account.userId, sessionId)) val publicLinksState = publicLinksController?.state?.collectAsStateWithLifecycle()?.value val navigationFailure by searchHistorySession.navigationFailure.collectAsStateWithLifecycle() val recentSearchFailure by searchHistorySession.recentSearchFailure.collectAsStateWithLifecycle() @@ -442,3 +445,17 @@ internal fun TransfersState.authoritativeSessionFailure(): PutioFailure? = (mutation as? TransferMutation.Failed)?.failure, (retryOutcome as? TransferRetryOutcome.Failed)?.failure, ).firstOrNull { it is PutioFailure.AuthenticationRequired } + +/** What the Transfers screen reads reaches the home-screen widget without another request. */ +@Composable +private fun TransfersWidgetEffect(content: TransfersContent, session: MobileSessionKey) { + val appContext = LocalContext.current.applicationContext + LaunchedEffect(content, session) { + val items = when (content) { + is TransfersContent.Ready -> content.items + TransfersContent.Empty -> emptyList() + else -> return@LaunchedEffect + } + MobileWidgets.transfers(appContext).show(session, items) + } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt index 39bc4408..1a75ba2b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt @@ -10,11 +10,13 @@ import io.putdotio.android.BuildConfig import io.putdotio.android.playback.SdkPlaybackPositionRepository import io.putdotio.android.documents.MobileDocumentsProvider import io.putdotio.android.downloads.MobileDownloadCache +import io.putdotio.android.downloads.MobileDownloadNotifications import io.putdotio.android.downloads.OfflinePlaybackPositions import io.putdotio.android.downloads.PositionRemote import io.putdotio.android.downloads.downloadPreferences import io.putdotio.android.files.MobileMoveTargetStore import io.putdotio.android.share.MobileFileShareService +import io.putdotio.android.widgets.MobileWidgets import io.putdotio.sdk.PutioClient import io.putdotio.sdk.PutioConfig import kotlinx.coroutines.CoroutineScope @@ -23,7 +25,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration import io.putdotio.android.playback.MobilePlaybackReporting internal fun interface OAuthRuntimeFailureReporter { @@ -110,6 +115,15 @@ class MobileOAuthRuntime internal constructor( } } + /** + * The session once it settles, for a component without a screen: a stored one is restored first. + * Null while restore or validation is still running after [timeout]. + */ + internal suspend fun awaitSettledSession(timeout: Duration): MobileAuthState? { + ensureSessionRestored {} + return withTimeoutOrNull(timeout) { authController.state.first { it.isSettled() } } + } + /** put.io rejected [sessionId] outside any screen; signs it out unless another session replaced it. */ internal fun rejectSession(sessionId: MobileAuthSessionId) { applicationScope.launch { authController.rejectAuthoritativeSession(sessionId) } @@ -191,8 +205,17 @@ class MobileOAuthRuntime internal constructor( onSessionLeft = { session -> MobileFileShareService.endSession(context, session) MobileDocumentsProvider.sessionLeft(context) + // The collector can lag the next sign-in; that account's outcomes stay. + MobileDownloadNotifications.cancelOtherAccounts( + context, + (authController.state.value as? MobileAuthState.SignedIn)?.account?.userId, + ) + MobileWidgets.sessionLeft(context) + }, + onSessionStarted = { + MobileDocumentsProvider.sessionStarted(context) + MobileWidgets.sessionStarted(context) }, - onSessionStarted = { MobileDocumentsProvider.sessionStarted(context) }, ).also { runtime -> syncWhenOnline(context, runtime.keepOfflinePositions(downloadPreferences(context))) } diff --git a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileSessionKey.kt b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileSessionKey.kt index 93b33c73..d130f5b6 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileSessionKey.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileSessionKey.kt @@ -9,3 +9,14 @@ internal data class MobileSessionKey( /** The signed-in session's key, or null in every other state. */ internal fun MobileAuthState.sessionKey(): MobileSessionKey? = (this as? MobileAuthState.SignedIn)?.let { MobileSessionKey(it.account.userId, it.sessionId) } + +/** Signed in, signed out or waiting on the viewer; the restore and validation states still change on their own. */ +internal fun MobileAuthState.isSettled(): Boolean = + when (this) { + MobileAuthState.Initializing, + MobileAuthState.RestoringSession, + is MobileAuthState.ValidatingSession, + MobileAuthState.SigningOut, + -> false + else -> true + } diff --git a/mobile/src/main/kotlin/io/putdotio/android/documents/MobileDocuments.kt b/mobile/src/main/kotlin/io/putdotio/android/documents/MobileDocuments.kt index 2a137916..b931e01e 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/documents/MobileDocuments.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/documents/MobileDocuments.kt @@ -8,6 +8,7 @@ import io.putdotio.android.PutioResult import io.putdotio.android.auth.MobileAuthSessionId import io.putdotio.android.auth.MobileAuthState import io.putdotio.android.auth.MobileSessionKey +import io.putdotio.android.auth.isSettled import io.putdotio.android.auth.sessionKey import io.putdotio.android.files.FilesItem import io.putdotio.android.files.FilesItemId @@ -206,17 +207,6 @@ internal class MobileDocuments( } } -/** Signed in, signed out or waiting on the viewer; the restore and validation states still change on their own. */ -private fun MobileAuthState.isSettled(): Boolean = - when (this) { - MobileAuthState.Initializing, - MobileAuthState.RestoringSession, - is MobileAuthState.ValidatingSession, - MobileAuthState.SigningOut, - -> false - else -> true - } - /** * Runs [block] on this thread until it returns, [timeout] passes (null), or [signal] cancels it, which throws * [android.os.OperationCanceledException]. A provider call blocks a binder thread; the caller can give it up. diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt new file mode 100644 index 00000000..e5ce4f6b --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt @@ -0,0 +1,135 @@ +package io.putdotio.android.downloads + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import io.putdotio.android.MobileDeepLink +import io.putdotio.android.auth.MobileAuthState +import io.putdotio.android.auth.MobileOAuthRuntime +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.parseMobileDeepLink +import io.putdotio.android.toRouteUri +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Retry on a failed download's notification. Not exported, so only this app's own pending intent + * reaches it, and the work runs without opening the app. + */ +internal class MobileDownloadActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val link = (parseMobileDeepLink(intent.data) as? MobileDeepLink.Downloads) + ?.takeIf { intent.action == ACTION_RETRY } + val userId = link?.userId + val fileId = link?.fileId + if (userId == null || fileId == null) return + val retries = retriesForTest ?: production(context.applicationContext) + // Null when called outside a broadcast, as a test may. + val pending: PendingResult? = goAsync() + scope.launch { + try { + retries.retry(userId, fileId) + } finally { + pending?.finish() + } + } + } + + internal companion object { + const val ACTION_RETRY = "io.putdotio.android.downloads.action.RETRY" + + /** The proof lane replaces the session and the transfer with its own. */ + @Volatile + internal var retriesForTest: DownloadNotificationRetries? = null + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + /** Restoring a session in a cold process reads put.io; past this the tap does nothing. */ + private val SESSION_TIMEOUT = 15.seconds + + /** An explicit, immutable broadcast; the data keeps one pending intent per account and file. */ + fun retry(context: Context, userId: Long, fileId: FilesItemId): PendingIntent = + PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_RETRY, MobileDeepLink.Downloads(fileId, userId = userId).toRouteUri()) + .setClass(context, MobileDownloadActionReceiver::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + private fun production(context: Context): DownloadNotificationRetries { + val runtime = MobileOAuthRuntime.get(context) + return DownloadNotificationRetries( + signedIn = { + when (val state = runtime.awaitSettledSession(SESSION_TIMEOUT)) { + is MobileAuthState.SignedIn -> SignedInAccount.User(state.account.userId) + is MobileAuthState.SignedOut -> SignedInAccount.Nobody + else -> SignedInAccount.Unknown + } + }, + find = { userId, fileId -> MobileDownloadStore.peek(context, userId, fileId) }, + start = { userId, entry -> sendDownloadRequest(context, userId, entry) }, + dismiss = { userId, fileId -> MobileDownloadNotifications.cancel(context, userId, fileId) }, + ) + } + } +} + +/** Who is signed in once the session settles; unknown while put.io cannot confirm a stored session. */ +internal sealed interface SignedInAccount { + data class User(val userId: Long) : SignedInAccount + + data object Nobody : SignedInAccount + + data object Unknown : SignedInAccount +} + +internal enum class DownloadRetryOutcome { + /** Media3 queues the download again; the row and notification follow its transfer. */ + STARTED, + + /** Another account, or nobody, is signed in: the notification goes and nothing else happens. */ + REFUSED, + + /** The row is gone, deleting, or no longer failed; the stale notification goes. */ + NOT_RETRYABLE, + + /** The session could not be confirmed in time, or the service could not start; the notification stays. */ + UNCONFIRMED, +} + +/** A notification's Retry, acting only for the account that owns the download and only while it is signed in. */ +internal class DownloadNotificationRetries( + private val signedIn: suspend () -> SignedInAccount, + private val find: (userId: Long, fileId: FilesItemId) -> DownloadEntry?, + private val start: (userId: Long, entry: DownloadEntry) -> Unit, + private val dismiss: (userId: Long, fileId: FilesItemId) -> Unit, +) { + suspend fun retry(userId: Long, fileId: FilesItemId): DownloadRetryOutcome { + val outcome = when (signedIn()) { + SignedInAccount.Unknown -> DownloadRetryOutcome.UNCONFIRMED + SignedInAccount.User(userId) -> + find(userId, fileId)?.takeIf { it.canRetry && !it.removing }?.let { queue(userId, it) } + ?: DownloadRetryOutcome.NOT_RETRYABLE + else -> DownloadRetryOutcome.REFUSED + } + // Another account's outcome, or a stale one, must not stay on screen. + if (outcome == DownloadRetryOutcome.REFUSED || outcome == DownloadRetryOutcome.NOT_RETRYABLE) { + dismiss(userId, fileId) + } + return outcome + } + + private fun queue(userId: Long, entry: DownloadEntry): DownloadRetryOutcome = + try { + start(userId, entry) + DownloadRetryOutcome.STARTED + } catch (_: IllegalStateException) { + // The system refused to start the download service from the background; Downloads still offers it. + DownloadRetryOutcome.UNCONFIRMED + } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt index abfe0372..593b2e4b 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt @@ -155,16 +155,7 @@ internal class MobileDownloadEngine( } } - override fun start(entry: DownloadEntry) { - val url = entry.artifact.apiUrl(entry.fileId, subtitlesHidden = entry.subtitlesHidden == true) - val request = DownloadRequest.Builder(downloadContentId(userId, entry.fileId), url.toUri()) - .setMimeType(if (entry.artifact == DownloadArtifact.HLS) MimeTypes.APPLICATION_M3U8 else null) - // The name rides along so a row lost from the app's index can be rebuilt with it. - .setData(entry.name.encodeToByteArray()) - .build() - MobileDownloadNotifications.cancel(appContext, userId, entry.fileId) - DownloadService.sendAddDownload(appContext, MobileDownloadService::class.java, request, true) - } + override fun start(entry: DownloadEntry) = sendDownloadRequest(appContext, userId, entry) override fun remove(fileId: FilesItemId) { MobileDownloadNotifications.cancel(appContext, userId, fileId) @@ -250,6 +241,22 @@ internal class MobileDownloadEngine( } } +/** + * Hands [userId]'s download to Media3 through the download service: a new request, or a failed or + * missing one again, which joins the back of the queue. Its last outcome notification goes. + */ +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) +internal fun sendDownloadRequest(context: Context, userId: Long, entry: DownloadEntry) { + val url = entry.artifact.apiUrl(entry.fileId, subtitlesHidden = entry.subtitlesHidden == true) + val request = DownloadRequest.Builder(downloadContentId(userId, entry.fileId), url.toUri()) + .setMimeType(if (entry.artifact == DownloadArtifact.HLS) MimeTypes.APPLICATION_M3U8 else null) + // The name rides along so a row lost from the app's index can be rebuilt with it. + .setData(entry.name.encodeToByteArray()) + .build() + MobileDownloadNotifications.cancel(context, userId, entry.fileId) + DownloadService.sendAddDownload(context, MobileDownloadService::class.java, request, true) +} + /** This user's file id in a `userId:fileId` request id; null for another account's request. */ private fun fileIdOf(userId: Long, contentId: String): FilesItemId? { if (contentId.substringBefore(':', missingDelimiterValue = "").toLongOrNull() != userId) return null diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadNotifications.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadNotifications.kt index 11549eb9..5f8ab718 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadNotifications.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadNotifications.kt @@ -1,9 +1,7 @@ package io.putdotio.android.downloads import android.Manifest -import android.app.PendingIntent import android.content.Context -import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.annotation.StringRes @@ -11,12 +9,13 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadManager -import io.putdotio.android.MainActivity +import io.putdotio.android.MobileDeepLink import io.putdotio.android.R +import io.putdotio.android.design.R as DesignR +import io.putdotio.android.pendingIntent import io.putdotio.android.files.FilesItemId /** What a finished transfer tells the viewer; Media3 reports both on its process-wide manager. */ @@ -29,10 +28,10 @@ internal sealed interface DownloadOutcome { /** * Tells the viewer when a download finishes or fails, with or without the app open. The name * comes from the owner's index row; no URL or token reaches a notification. Each file has one - * slot, replaced by its next outcome and cleared by a retry or a delete; a tap opens the row in - * Downloads. Nothing is posted while the app's notifications are off, the channel is blocked, - * or, from Android 13, POST_NOTIFICATIONS is not granted, nor for an account other than the one - * signed in now. + * slot, replaced by its next outcome and cleared by a retry, a delete or the owner's sign-out; a + * tap opens the row in Downloads, and its actions play a finished copy or retry a failed one. + * Nothing is posted while the app's notifications are off, the channel is blocked, or, from + * Android 13, POST_NOTIFICATIONS is not granted, nor for an account other than the one signed in now. */ @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) internal class MobileDownloadNotifications( @@ -82,7 +81,7 @@ internal class MobileDownloadNotifications( .setDescription(context.getString(R.string.mobile_downloads_updates_channel_description)) .build(), ) - val notification = outcomeNotification(context, entry, outcome).build() + val notification = outcomeNotification(context, userId, entry, outcome).build() // The grant can be revoked between the check and this call. try { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || @@ -100,12 +99,24 @@ internal class MobileDownloadNotifications( NotificationManagerCompat.from(context).cancel(tag(userId, fileId), NOTIFICATION_ID) } + /** A session ended: outcomes of every account but [signedInUser], who may already be the next one, go. */ + fun cancelOtherAccounts(context: Context, signedInUser: Long?) { + val manager = NotificationManagerCompat.from(context) + for (posted in manager.activeNotifications) { + val owner = posted.tag?.takeIf { it.startsWith(TAG_PREFIX) }?.removePrefix(TAG_PREFIX) + ?.substringBefore(':')?.toLongOrNull() ?: continue + if (posted.id == NOTIFICATION_ID && owner != signedInUser) manager.cancel(posted.tag, posted.id) + } + } + /** - * One outcome's notification. Actions such as Play or Retry (#41) belong on this builder - * so the posting, permission and slot rules stay in one place. + * One outcome's notification. Its tap and actions carry [userId], so they do nothing under + * another account: Play and Open route through MainActivity's product links, Retry through + * [MobileDownloadActionReceiver], which only this app can reach. */ internal fun outcomeNotification( context: Context, + userId: Long, entry: DownloadEntry, outcome: DownloadOutcome, ): NotificationCompat.Builder { @@ -132,6 +143,19 @@ internal class MobileDownloadNotifications( .setSmallIcon(icon) .setContentTitle(title) .build() + val openRow = MobileDeepLink.Downloads(entry.fileId, userId = userId).pendingIntent(context) + val primary = when (outcome) { + DownloadOutcome.Completed -> NotificationCompat.Action( + DesignR.drawable.ic_ph_play_fill, + context.getString(R.string.mobile_downloads_play), + MobileDeepLink.Downloads(entry.fileId, play = true, userId = userId).pendingIntent(context), + ) + is DownloadOutcome.Failed -> NotificationCompat.Action( + R.drawable.ic_ph_arrow_clockwise, + context.getString(R.string.mobile_action_retry), + MobileDownloadActionReceiver.retry(context, userId, entry.fileId), + ) + } return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(icon) .setContentTitle(title) @@ -142,21 +166,18 @@ internal class MobileDownloadNotifications( .setPublicVersion(public) .setOnlyAlertOnce(true) .setAutoCancel(true) - .setContentIntent(openRow(context, entry.fileId)) + .setContentIntent(openRow) + .addAction(primary) + .addAction( + R.drawable.ic_ph_arrow_circle_down, + context.getString(R.string.mobile_downloads_notification_open), + openRow, + ) } - private fun openRow(context: Context, fileId: FilesItemId): PendingIntent = - PendingIntent.getActivity( - context, - 0, - // Each file's link is distinct data, so each row keeps its own pending intent. - Intent(Intent.ACTION_VIEW, "putio://downloads/${fileId.value}".toUri()) - .setClass(context, MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) + private const val TAG_PREFIX = "download:" - private fun tag(userId: Long, fileId: FilesItemId): String = "download:$userId:${fileId.value}" + private fun tag(userId: Long, fileId: FilesItemId): String = "$TAG_PREFIX$userId:${fileId.value}" } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransfersScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransfersScreen.kt index 3c085e89..a395d552 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransfersScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransfersScreen.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Configuration import android.text.format.DateUtils import android.text.format.Formatter +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -758,25 +759,27 @@ private fun retryOutcomeMessage(outcome: TransferRetryOutcome): String = } @Composable -private fun TransferItem.statusLabel(): String = - stringResource( - when (status) { - AppTransferStatus.Waiting -> R.string.mobile_transfer_status_waiting - AppTransferStatus.PreparingDownload -> R.string.mobile_transfer_status_preparing - AppTransferStatus.Queued -> R.string.mobile_transfer_status_queued - AppTransferStatus.Downloading -> R.string.mobile_transfer_status_downloading - AppTransferStatus.WaitingForCompleteQueue, - AppTransferStatus.Completing, - -> R.string.mobile_transfer_status_finishing - AppTransferStatus.WaitingForDownloader -> R.string.mobile_transfer_status_waiting_for_downloader - AppTransferStatus.Stopping -> R.string.mobile_transfer_status_stopping - AppTransferStatus.Seeding -> R.string.mobile_transfer_status_seeding - AppTransferStatus.PreparingSeed -> R.string.mobile_transfer_status_preparing_seed - AppTransferStatus.Completed -> completedStatusResource() - AppTransferStatus.Failed -> R.string.mobile_transfer_status_failed - is AppTransferStatus.Unknown -> R.string.mobile_transfer_status_updating - }, - ) +private fun TransferItem.statusLabel(): String = stringResource(statusLabelRes()) + +/** The status a row shows; the home-screen widget shows the same words. */ +@StringRes +internal fun TransferItem.statusLabelRes(): Int = + when (status) { + AppTransferStatus.Waiting -> R.string.mobile_transfer_status_waiting + AppTransferStatus.PreparingDownload -> R.string.mobile_transfer_status_preparing + AppTransferStatus.Queued -> R.string.mobile_transfer_status_queued + AppTransferStatus.Downloading -> R.string.mobile_transfer_status_downloading + AppTransferStatus.WaitingForCompleteQueue, + AppTransferStatus.Completing, + -> R.string.mobile_transfer_status_finishing + AppTransferStatus.WaitingForDownloader -> R.string.mobile_transfer_status_waiting_for_downloader + AppTransferStatus.Stopping -> R.string.mobile_transfer_status_stopping + AppTransferStatus.Seeding -> R.string.mobile_transfer_status_seeding + AppTransferStatus.PreparingSeed -> R.string.mobile_transfer_status_preparing_seed + AppTransferStatus.Completed -> completedStatusResource() + AppTransferStatus.Failed -> R.string.mobile_transfer_status_failed + is AppTransferStatus.Unknown -> R.string.mobile_transfer_status_updating + } private fun TransferItem.completedStatusResource(): Int = when { diff --git a/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt new file mode 100644 index 00000000..786117ea --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt @@ -0,0 +1,230 @@ +package io.putdotio.android.widgets + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import io.putdotio.android.PutioFailure +import io.putdotio.android.PutioResult +import io.putdotio.android.auth.MobileAuthSessionId +import io.putdotio.android.auth.MobileAuthState +import io.putdotio.android.auth.MobileOAuthRuntime +import io.putdotio.android.auth.MobileSessionKey +import io.putdotio.android.auth.sessionKey +import io.putdotio.android.transfers.TransferItem +import io.putdotio.android.transfers.SdkTransfersRepository +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** The home-screen Transfers widget. The system asks for an update every 30 minutes and when one is placed. */ +class MobileTransfersWidgetProvider : AppWidgetProvider() { + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + // Null when called outside a broadcast, as a test may. + val pending: PendingResult? = goAsync() + MobileWidgets.transfers(context).refresh { pending?.finish() } + } +} + +/** The widget's refresh button. Not exported: only the widget's own pending intent reaches it. */ +internal class MobileWidgetActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_REFRESH) return + val pending: PendingResult? = goAsync() + MobileWidgets.transfers(context).refresh { pending?.finish() } + } + + internal companion object { + const val ACTION_REFRESH = "io.putdotio.android.widgets.action.REFRESH" + + fun refresh(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_REFRESH).setClass(context, MobileWidgetActionReceiver::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } +} + +/** What the Transfers widget reads: the session and the account's transfers, never a token. */ +internal class TransfersWidgetSource( + val authState: StateFlow, + /** The session once it settles, restoring a stored one first; null while it is still unsettled. */ + val settledSession: suspend () -> MobileAuthState?, + /** The first page of the signed-in account's transfers, newest first. */ + val load: suspend () -> PutioResult>, + /** put.io rejected the session; the app signs it out as anywhere else. */ + val rejectSession: (MobileAuthSessionId) -> Unit, +) + +/** + * Keeps every placed Transfers widget on the signed-in session's transfers. Content is pushed only + * while the session that read it is still signed in, under one lock with [clear], so a read that + * outlives its session can never show that account's names afterwards. Nothing is stored: the + * launcher holds the only copy, and the next push replaces it. + */ +internal class TransfersWidgetUpdater( + context: Context, + private val source: TransfersWidgetSource, + private val scope: CoroutineScope, + private val clock: () -> Long = System::currentTimeMillis, + private val timeout: Duration = REFRESH_TIMEOUT, +) { + // Only the application context is retained; lint cannot see that the process-wide holder keeps nothing else. + @SuppressLint("StaticFieldLeak") + private val appContext: Context = context.applicationContext + private val lock = Any() + private var refreshing: Job? = null + + /** What this process last pushed, and for which session; null until it pushes anything. */ + private var shown: Pair? = null + + private val manager get() = AppWidgetManager.getInstance(appContext) + private val widgetIds: IntArray + get() = manager.getAppWidgetIds(ComponentName(appContext, MobileTransfersWidgetProvider::class.java)) + + /** + * Reads the account's transfers and shows them, replacing any refresh still running. Does + * nothing, and reads nothing, while no widget is placed. [onDone] runs once it settles. + */ + fun refresh(onDone: () -> Unit = {}) { + if (widgetIds.isEmpty()) { + onDone() + return + } + synchronized(lock) { + refreshing?.cancel() + refreshing = scope.launch { + try { + withTimeoutOrNull(timeout) { refreshNow() } ?: pushIfUnknown(TransfersWidgetContent.Unavailable) + } finally { + onDone() + } + } + } + } + + /** The session ended: every widget shows Sign in at once, whatever a refresh is still reading. */ + fun clear() { + synchronized(lock) { + refreshing?.cancel() + push(null, TransfersWidgetContent.SignedOut) + } + } + + /** The app's own Transfers screen read [items] for [key]; the widget shows them without another request. */ + fun show(key: MobileSessionKey, items: List) { + if (widgetIds.isNotEmpty()) publish(key, items.toTransfersWidgetContent(clock())) + } + + private suspend fun refreshNow() { + when (val state = source.settledSession()) { + is MobileAuthState.SignedIn -> refreshSignedIn(state) + null, is MobileAuthState.ValidationUnavailable -> pushIfUnknown(TransfersWidgetContent.Unavailable) + // Signed out, or a sign-in still waiting on the browser. + else -> synchronized(lock) { + if (source.authState.value !is MobileAuthState.SignedIn) push(null, TransfersWidgetContent.SignedOut) + } + } + } + + private suspend fun refreshSignedIn(state: MobileAuthState.SignedIn) { + val key = MobileSessionKey(state.account.userId, state.sessionId) + // A sign-in replaces Sign in with Loading; any other refresh leaves what the launcher shows until it lands. + val current = synchronized(lock) { shown } + if (current != null && current.first != key) publish(key, TransfersWidgetContent.Loading) + when (val result = source.load()) { + is PutioResult.Success -> publish(key, result.value.toTransfersWidgetContent(clock())) + is PutioResult.Failure -> when (result.failure) { + is PutioFailure.AuthenticationRequired -> source.rejectSession(state.sessionId) + // Rows already shown stay, with the time they were read. + else -> synchronized(lock) { + if (shown?.let { it.first == key && it.second is TransfersWidgetContent.Ready } != true) { + publishLocked(key, TransfersWidgetContent.Unavailable) + } + } + } + } + } + + private fun publish(key: MobileSessionKey, content: TransfersWidgetContent) { + synchronized(lock) { publishLocked(key, content) } + } + + /** Only the session that read [content] may show it, and only while it is still signed in. */ + private fun publishLocked(key: MobileSessionKey, content: TransfersWidgetContent) { + if (source.authState.value.sessionKey() == key) push(key, content) + } + + /** A cold process cannot tell what the launcher shows, so a failure there says so rather than keep stale rows. */ + private fun pushIfUnknown(content: TransfersWidgetContent) { + synchronized(lock) { + val current = shown + if (current == null || current.second !is TransfersWidgetContent.Ready) push(current?.first, content) + } + } + + private fun push(key: MobileSessionKey?, content: TransfersWidgetContent) { + val ids = widgetIds + if (ids.isNotEmpty()) manager.updateAppWidget(ids, transfersWidgetViews(appContext, content)) + shown = key to content + } + + private companion object { + /** Restoring a session reads put.io, then the transfers; past this the widget says put.io is unreachable. */ + val REFRESH_TIMEOUT = 25.seconds + } +} + +/** The process's widget updaters, shared by the providers, the auth runtime and the Transfers screen. */ +internal object MobileWidgets { + @Volatile + private var transfers: TransfersWidgetUpdater? = null + + /** A proof lane or test installs its own updater over a controlled session. */ + @Volatile + internal var transfersForTest: TransfersWidgetUpdater? = null + + fun transfers(context: Context): TransfersWidgetUpdater = + transfersForTest ?: transfers ?: synchronized(this) { + transfers ?: createTransfers(context.applicationContext).also { transfers = it } + } + + fun sessionStarted(context: Context) = transfers(context).refresh() + + fun sessionLeft(context: Context) = transfers(context).clear() + + /** Restoring a stored session in a cold process reads put.io before the transfers can be. */ + private val SESSION_TIMEOUT = 15.seconds + + private fun createTransfers(context: Context): TransfersWidgetUpdater { + val runtime = MobileOAuthRuntime.get(context) + val repository = SdkTransfersRepository(runtime.putioClient) + return TransfersWidgetUpdater( + context = context, + source = TransfersWidgetSource( + authState = runtime.authController.state, + settledSession = { runtime.awaitSettledSession(SESSION_TIMEOUT) }, + load = { + when (val page = repository.load()) { + is PutioResult.Success -> PutioResult.Success(page.value.items) + is PutioResult.Failure -> page + } + }, + rejectSession = runtime::rejectSession, + ), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + ) + } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetViews.kt b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetViews.kt new file mode 100644 index 00000000..7d548abc --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetViews.kt @@ -0,0 +1,84 @@ +package io.putdotio.android.widgets + +import android.content.Context +import android.text.format.DateFormat +import android.view.View +import android.widget.RemoteViews +import io.putdotio.android.MobileDeepLink +import io.putdotio.android.R +import io.putdotio.android.pendingIntent +import java.util.Date + +/** + * The widget's views for [content]. Every text is set on every push, and a hidden row's name is + * emptied: a launcher reapplies new views onto the old ones, so a row left untouched would keep + * the previous account's name. Intents carry token-free product links only. + */ +internal fun transfersWidgetViews(context: Context, content: TransfersWidgetContent): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.widget_transfers) + views.setOnClickPendingIntent(android.R.id.background, MobileDeepLink.Transfers.pendingIntent(context)) + views.setOnClickPendingIntent(R.id.widget_transfers_refresh, MobileWidgetActionReceiver.refresh(context)) + views.setViewVisibility( + R.id.widget_transfers_refresh, + if (content == TransfersWidgetContent.SignedOut) View.GONE else View.VISIBLE, + ) + val message = when (content) { + TransfersWidgetContent.SignedOut -> R.string.mobile_widget_transfers_signed_out + TransfersWidgetContent.Loading -> R.string.mobile_widget_transfers_loading + TransfersWidgetContent.Unavailable -> R.string.mobile_widget_transfers_unavailable + is TransfersWidgetContent.Ready -> R.string.mobile_widget_transfers_empty.takeIf { content.rows.isEmpty() } + } + views.setTextViewText(R.id.widget_transfers_message, message?.let(context::getString).orEmpty()) + views.setViewVisibility(R.id.widget_transfers_message, if (message == null) View.GONE else View.VISIBLE) + val rows = (content as? TransfersWidgetContent.Ready)?.rows.orEmpty() + ROW_VIEWS.forEachIndexed { index, ids -> views.showRow(context, ids, rows.getOrNull(index)) } + val footer = (content as? TransfersWidgetContent.Ready)?.footer(context).orEmpty() + views.setTextViewText(R.id.widget_transfers_footer, footer) + views.setViewVisibility(R.id.widget_transfers_footer, if (footer.isEmpty()) View.GONE else View.VISIBLE) + return views +} + +private fun RemoteViews.showRow(context: Context, ids: RowViews, row: TransfersWidgetRow?) { + setViewVisibility(ids.row, if (row == null) View.GONE else View.VISIBLE) + setTextViewText(ids.name, row?.name.orEmpty()) + val status = row?.let { context.getString(it.status) }.orEmpty() + setTextViewText( + ids.status, + row?.percent?.let { context.getString(R.string.mobile_widget_transfers_status_percent, status, it) } ?: status, + ) + setProgressBar(ids.progress, PROGRESS_MAX, row?.percent ?: 0, row != null && row.percent == null) +} + +private fun TransfersWidgetContent.Ready.footer(context: Context): String { + val time = DateFormat.getTimeFormat(context).format(Date(updatedAtMillis)) + return if (more > 0) { + context.resources.getQuantityString(R.plurals.mobile_widget_transfers_updated_more, more, time, more) + } else { + context.getString(R.string.mobile_widget_transfers_updated, time) + } +} + +private class RowViews(val row: Int, val name: Int, val status: Int, val progress: Int) + +private val ROW_VIEWS = listOf( + RowViews( + R.id.widget_transfers_row_0, + R.id.widget_transfers_row_0_name, + R.id.widget_transfers_row_0_status, + R.id.widget_transfers_row_0_progress, + ), + RowViews( + R.id.widget_transfers_row_1, + R.id.widget_transfers_row_1_name, + R.id.widget_transfers_row_1_status, + R.id.widget_transfers_row_1_progress, + ), + RowViews( + R.id.widget_transfers_row_2, + R.id.widget_transfers_row_2_name, + R.id.widget_transfers_row_2_status, + R.id.widget_transfers_row_2_progress, + ), +).also { check(it.size == TRANSFERS_WIDGET_ROWS) } + +private const val PROGRESS_MAX = 100 diff --git a/mobile/src/main/kotlin/io/putdotio/android/widgets/TransfersWidgetContent.kt b/mobile/src/main/kotlin/io/putdotio/android/widgets/TransfersWidgetContent.kt new file mode 100644 index 00000000..d7ea5e29 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/widgets/TransfersWidgetContent.kt @@ -0,0 +1,53 @@ +package io.putdotio.android.widgets + +import androidx.annotation.StringRes +import io.putdotio.android.transfers.TransferItem +import io.putdotio.android.transfers.statusLabelRes +import kotlin.math.roundToInt + +/** What the Transfers widget shows. Only [Ready] carries names, and only the signed-in session's. */ +internal sealed interface TransfersWidgetContent { + data object SignedOut : TransfersWidgetContent + + data object Loading : TransfersWidgetContent + + /** put.io could not be reached, or the session could not be confirmed, and nothing was shown yet. */ + data object Unavailable : TransfersWidgetContent + + data class Ready( + val rows: List, + /** Active transfers beyond [rows]. */ + val more: Int, + val updatedAtMillis: Long, + ) : TransfersWidgetContent +} + +internal data class TransfersWidgetRow( + val name: String, + @StringRes val status: Int, + /** Whole percent when put.io reports one; the bar is indeterminate otherwise. */ + val percent: Int?, +) { + override fun toString(): String = "TransfersWidgetRow(, status=$status, percent=$percent)" +} + +/** The widget has room for this many rows at its default four-by-two size. */ +internal const val TRANSFERS_WIDGET_ROWS = 3 + +/** Transfers still running, newest first as put.io lists them; finished and failed ones are left to the app. */ +internal fun List.toTransfersWidgetContent(nowMillis: Long): TransfersWidgetContent.Ready { + val active = filterNot { it.status.isTerminal } + return TransfersWidgetContent.Ready( + rows = active.take(TRANSFERS_WIDGET_ROWS).map { item -> + TransfersWidgetRow( + name = item.name, + status = item.statusLabelRes(), + percent = item.percentDone?.takeIf { it.isFinite() }?.roundToInt()?.coerceIn(0, PERCENT), + ) + }, + more = (active.size - TRANSFERS_WIDGET_ROWS).coerceAtLeast(0), + updatedAtMillis = nowMillis, + ) +} + +private const val PERCENT = 100 diff --git a/mobile/src/main/res/drawable/shortcut_add_transfer.xml b/mobile/src/main/res/drawable/shortcut_add_transfer.xml new file mode 100644 index 00000000..380bd485 --- /dev/null +++ b/mobile/src/main/res/drawable/shortcut_add_transfer.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/mobile/src/main/res/drawable/shortcut_downloads.xml b/mobile/src/main/res/drawable/shortcut_downloads.xml new file mode 100644 index 00000000..b221d14b --- /dev/null +++ b/mobile/src/main/res/drawable/shortcut_downloads.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/mobile/src/main/res/drawable/shortcut_search.xml b/mobile/src/main/res/drawable/shortcut_search.xml new file mode 100644 index 00000000..35c922dc --- /dev/null +++ b/mobile/src/main/res/drawable/shortcut_search.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/mobile/src/main/res/drawable/widget_background.xml b/mobile/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..b334a175 --- /dev/null +++ b/mobile/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/src/main/res/layout/widget_transfers.xml b/mobile/src/main/res/layout/widget_transfers.xml new file mode 100644 index 00000000..df9997c1 --- /dev/null +++ b/mobile/src/main/res/layout/widget_transfers.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/values-v31/dimens.xml b/mobile/src/main/res/values-v31/dimens.xml new file mode 100644 index 00000000..239e2a13 --- /dev/null +++ b/mobile/src/main/res/values-v31/dimens.xml @@ -0,0 +1,4 @@ + + + @android:dimen/system_app_widget_background_radius + diff --git a/mobile/src/main/res/values/dimens.xml b/mobile/src/main/res/values/dimens.xml new file mode 100644 index 00000000..77c79bac --- /dev/null +++ b/mobile/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + + 16dp + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index f9c4cab4..bb461f78 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -433,6 +433,28 @@ Download finished Download failed %1$s\n%2$s + Open in Downloads + + put.io transfers + Active transfers and their progress + Loading transfers… + No active transfers + Sign in to put.io to see your transfers. + Couldn’t reach put.io. Tap refresh to try again. + %1$s · %2$d%% + Updated %1$s + + Updated %1$s · %2$d more + Updated %1$s · %2$d more + + Refresh + + Search + Search put.io + Add + Add transfer + Downloads + Downloads Downloading %1$d file Downloading %1$d files diff --git a/mobile/src/main/res/xml-v31/transfers_widget_info.xml b/mobile/src/main/res/xml-v31/transfers_widget_info.xml new file mode 100644 index 00000000..66118152 --- /dev/null +++ b/mobile/src/main/res/xml-v31/transfers_widget_info.xml @@ -0,0 +1,15 @@ + + + diff --git a/mobile/src/main/res/xml/transfers_widget_info.xml b/mobile/src/main/res/xml/transfers_widget_info.xml new file mode 100644 index 00000000..21ad384d --- /dev/null +++ b/mobile/src/main/res/xml/transfers_widget_info.xml @@ -0,0 +1,12 @@ + + + diff --git a/mobile/src/main/shortcuts.xml b/mobile/src/main/shortcuts.xml new file mode 100644 index 00000000..575403f7 --- /dev/null +++ b/mobile/src/main/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileDeepLinksTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileDeepLinksTest.kt index 46d6f87a..dc4a1f7c 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileDeepLinksTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileDeepLinksTest.kt @@ -5,7 +5,9 @@ import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import io.putdotio.android.files.FilesItemId import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -67,12 +69,43 @@ class MobileDeepLinksTest { } @Test - fun routeUrisRoundTripWithoutQueries() { + fun routeUrisRoundTripWithOnlyTheDownloadsAccount() { val links = listOf( MobileDeepLink.Files, MobileDeepLink.File(FilesItemId(42L)), MobileDeepLink.Transfers, - MobileDeepLink.Search, MobileDeepLink.History, MobileDeepLink.Trash, MobileDeepLink.Downloads(), - MobileDeepLink.Downloads(FilesItemId(42L)), + MobileDeepLink.AddTransfer, MobileDeepLink.Search, MobileDeepLink.History, MobileDeepLink.Trash, + MobileDeepLink.Downloads(), MobileDeepLink.Downloads(FilesItemId(42L)), + MobileDeepLink.Downloads(FilesItemId(42L), userId = 7L), + MobileDeepLink.Downloads(FilesItemId(42L), play = true, userId = 7L), ) for (link in links) assertEquals(link, parseMobileDeepLink(link.toRouteUri())) + assertEquals("putio://downloads/42/play?user=7", links.last().toRouteUri().toString()) + } + + @Test + fun theAddTransferShortcutAndNotificationActionsHaveTheirOwnLinks() { + assertEquals(MobileDeepLink.AddTransfer, parseMobileDeepLink("putio://transfers/add".toUri())) + assertNull(parseMobileDeepLink("putio://transfers/remove".toUri())) + assertEquals( + MobileDeepLink.Downloads(FilesItemId(42L), play = true, userId = 7L), + parseMobileDeepLink("putio://downloads/42/play?user=7".toUri()), + ) + // Only Downloads links read an account; anywhere else the query is ignored as before. + assertEquals(MobileDeepLink.Search, parseMobileDeepLink("putio://search?user=7".toUri())) + // A malformed account voids the link rather than widening it to any account. + assertNull(parseMobileDeepLink("putio://downloads/42?user=abc".toUri())) + assertNull(parseMobileDeepLink("putio://downloads/42?user=0".toUri())) + assertNull(parseMobileDeepLink("putio://downloads/42/stop".toUri())) + assertNull(parseMobileDeepLink("putio://downloads/42/play/1".toUri())) + // An opaque URI has no query to read; it opens Files as before instead of throwing. + val opaque = Intent(Intent.ACTION_VIEW, "putio:downloads?user=7".toUri()) + assertEquals(MobileDeepLink.Files, opaque.consumeMobileDeepLink()) + } + + @Test + fun aLinkScopedToAnAccountBelongsOnlyToThatAccount() { + assertTrue(MobileDeepLink.Downloads(FilesItemId(42L), userId = 7L).belongsTo(7L)) + assertFalse(MobileDeepLink.Downloads(FilesItemId(42L), play = true, userId = 7L).belongsTo(8L)) + assertTrue(MobileDeepLink.Downloads(FilesItemId(42L)).belongsTo(8L)) + assertTrue(MobileDeepLink.AddTransfer.belongsTo(8L)) } } diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileShellLinkActionsTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileShellLinkActionsTest.kt new file mode 100644 index 00000000..9088e499 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileShellLinkActionsTest.kt @@ -0,0 +1,167 @@ +package io.putdotio.android + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.auth.MobileAccount +import io.putdotio.android.auth.MobileAuthSessionId +import io.putdotio.android.design.PutioTheme +import io.putdotio.android.downloads.DownloadArtifact +import io.putdotio.android.downloads.DownloadEntry +import io.putdotio.android.downloads.DownloadStatus +import io.putdotio.android.downloads.DownloadsController +import io.putdotio.android.downloads.FakeDownloadEngine +import io.putdotio.android.downloads.FakeDownloadStore +import io.putdotio.android.downloads.MOBILE_DOWNLOADS_ITEM_SHEET_TAG +import io.putdotio.android.downloads.MOBILE_DOWNLOADS_LIST_TAG +import io.putdotio.android.files.FilesBrowserReducer +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.playback.PlaybackNextResult +import io.putdotio.android.playback.PlaybackRepository +import io.putdotio.android.playback.PlaybackRepositoryResult +import io.putdotio.android.playback.PlaybackResolution +import io.putdotio.android.playback.PlaybackTarget +import io.putdotio.android.settings.readyAccountSettingsState +import io.putdotio.android.settings.readyAndroidAppConfigState +import io.putdotio.android.transfers.MOBILE_TRANSFER_ADD_FIELD_TAG +import io.putdotio.android.transfers.MobileTransferDraft +import io.putdotio.android.transfers.TransfersContent +import io.putdotio.android.transfers.transfersState +import io.putdotio.sdk.files.PlaybackConversionState +import io.putdotio.sdk.files.PutioFileType +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Links that shortcuts and notification actions open, routed by the real shell. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], qualifiers = "en-rUS") +class MobileShellLinkActionsTest { + @get:Rule + val compose = createComposeRule() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val store = FakeDownloadStore() + private val resolved = CopyOnWriteArrayList() + private val links = MobileDeepLinkRequests.None + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun playFromANotificationPlaysTheCopyOnThisDevice() { + runBlocking { store.upsert(row(10L, DownloadStatus.Completed(1_024L))) } + setShell() + + compose.runOnIdle { links.receive(MobileDeepLink.Downloads(FilesItemId(10L), play = true, userId = USER)) } + + compose.waitUntil(TIMEOUT) { resolved.isNotEmpty() } + compose.runOnIdle { + assertEquals(FilesItemId(10L), resolved.single().fileId) + assertEquals("file-10.mkv", resolved.single().name) + assertNull(links.pending.value) + } + } + + @Test + fun playWithoutACopyOnThisDeviceOpensTheRowInstead() { + runBlocking { store.upsert(row(11L, DownloadStatus.Missing)) } + setShell() + + compose.runOnIdle { links.receive(MobileDeepLink.Downloads(FilesItemId(11L), play = true, userId = USER)) } + + compose.onNodeWithTag(MOBILE_DOWNLOADS_ITEM_SHEET_TAG).assertIsDisplayed() + compose.runOnIdle { assertTrue(resolved.isEmpty()) } + } + + @Test + fun anotherAccountsNotificationOpensNothing() { + runBlocking { store.upsert(row(10L, DownloadStatus.Completed(1_024L))) } + setShell() + + compose.runOnIdle { links.receive(MobileDeepLink.Downloads(FilesItemId(10L), play = true, userId = OTHER)) } + + compose.waitForIdle() + compose.runOnIdle { + // Dropped, not left pending for a later account, and nothing played or opened. + assertNull(links.pending.value) + assertTrue(resolved.isEmpty()) + } + compose.onNodeWithTag(MOBILE_DOWNLOADS_LIST_TAG).assertDoesNotExist() + compose.onNodeWithTag(MOBILE_DOWNLOADS_ITEM_SHEET_TAG).assertDoesNotExist() + } + + @Test + fun theAddTransferShortcutOpensAnEmptySheetAndAddsNothing() { + val draft = MobileTransferDraft() + setShell(draft) + + compose.runOnIdle { links.receive(MobileDeepLink.AddTransfer) } + + compose.onNodeWithTag(MOBILE_TRANSFER_ADD_FIELD_TAG).assertIsDisplayed() + compose.runOnIdle { + assertEquals("", draft.state.value.input) + assertTrue(draft.state.value.open) + assertNull(links.pending.value) + } + } + + private fun setShell(draft: MobileTransferDraft = MobileTransferDraft()) { + val controller = DownloadsController(store, FakeDownloadEngine(), scope) + compose.setContent { + PutioTheme { + MobileShell( + filesState = FilesBrowserReducer.start().state, + downloadsController = controller, + deepLinkRequests = links, + playbackPlayerFactory = NoAudioSessionFactory, + accountSettingsState = readyAccountSettingsState(), + appConfigState = readyAndroidAppConfigState(), + transfersState = transfersState(TransfersContent.Empty), + account = MobileAccount(userId = USER, username = "user", email = "user@example.com"), + playbackRepository = recording, + sessionId = MobileAuthSessionId(1L), + onFilesEvent = { true }, + onAccountSettingsEvent = {}, + onPlaybackAuthenticationRequired = {}, + onSignOut = {}, + transferDraft = draft, + ) + } + } + } + + private val recording = object : PlaybackRepository { + override suspend fun resolve(target: PlaybackTarget): PlaybackRepositoryResult { + resolved += target + return PlaybackRepositoryResult.Success(PlaybackResolution.Conversion(PlaybackConversionState.Queued)) + } + + override suspend fun findNextVideo(target: PlaybackTarget) = PlaybackNextResult.Ended + } + + private fun row(fileId: Long, status: DownloadStatus) = DownloadEntry( + FilesItemId(fileId), "file-$fileId.mkv", PutioFileType.VIDEO, DownloadArtifact.HLS, status, + createdAt = fileId, accepted = true, + ) + + private companion object { + const val TIMEOUT = 5_000L + const val USER = 42L + const val OTHER = 43L + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileShortcutsTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileShortcutsTest.kt new file mode 100644 index 00000000..f1a41972 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileShortcutsTest.kt @@ -0,0 +1,84 @@ +package io.putdotio.android + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Xml +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import org.xmlpull.v1.XmlPullParser + +/** The launcher's static shortcuts, read with the platform's own intent parser, as the shortcut service does. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileShortcutsTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun eachShortcutOpensItsProductLinkInThisVariantsMainActivity() { + val shortcuts = shortcutIntents() + + assertEquals(listOf("search", "add-transfer", "downloads"), shortcuts.keys.toList()) + for (intent in shortcuts.values) { + assertEquals(Intent.ACTION_VIEW, intent.action) + // Explicit: another installed channel answering putio:// never receives it. + assertEquals(ComponentName(context.packageName, MainActivity::class.java.name), intent.component) + } + assertEquals(MobileDeepLink.Search, parseMobileDeepLink(shortcuts.getValue("search").data)) + assertEquals(MobileDeepLink.AddTransfer, parseMobileDeepLink(shortcuts.getValue("add-transfer").data)) + assertEquals(MobileDeepLink.Downloads(), parseMobileDeepLink(shortcuts.getValue("downloads").data)) + } + + @Test + fun theLauncherActivityPublishesTheShortcuts() { + val activity = context.packageManager.getActivityInfo( + ComponentName(context, MainActivity::class.java), + PackageManager.GET_META_DATA, + ) + assertEquals(R.xml.shortcuts, activity.metaData.getInt("android.app.shortcuts")) + } + + @Test + fun aShortcutOpenedSignedOutWaitsForSignInAsItsLink() { + for ((id, intent) in shortcutIntents()) { + val link = parseMobileDeepLink(intent.data) + Robolectric.buildActivity(MainActivity::class.java, intent).setup().use { controller -> + val activity = controller.get() + // MainActivity holds the link until the signed-in shell routes it; nothing else runs. + assertEquals(id, link, activity.deepLinkRequests.pending.value) + assertNull(activity.intent.data) + } + } + } + + /** Shortcut id to the intent the platform builds from `res/xml/shortcuts.xml`. */ + private fun shortcutIntents(): Map { + val parser = context.resources.getXml(R.xml.shortcuts) + val attributes = Xml.asAttributeSet(parser) + val intents = linkedMapOf() + var shortcutId: String? = null + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.eventType != XmlPullParser.START_TAG) continue + when (parser.name) { + "shortcut" -> shortcutId = parser.getAttributeValue(ANDROID, "shortcutId") + "intent" -> { + // The platform takes the package as written; a resource reference would stay unresolved. + assertEquals(context.packageName, parser.getAttributeValue(ANDROID, "targetPackage")) + intents[requireNotNull(shortcutId)] = Intent.parseIntent(context.resources, parser, attributes) + } + } + } + return intents + } + + private companion object { + const val ANDROID = "http://schemas.android.com/apk/res/android" + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt b/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt new file mode 100644 index 00000000..b7776953 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt @@ -0,0 +1,114 @@ +package io.putdotio.android.downloads + +import android.app.Application +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.files.FilesItemId +import io.putdotio.sdk.files.PutioFileType +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** A failed download notification's Retry, for its own account only. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileDownloadActionReceiverTest { + private val context: Application = ApplicationProvider.getApplicationContext() + private var account: SignedInAccount = SignedInAccount.User(USER) + private val rows = mutableMapOf, DownloadEntry>() + private val started = mutableListOf>() + private val dismissed = mutableListOf>() + private val retries = DownloadNotificationRetries( + signedIn = { account }, + find = { userId, fileId -> rows[userId to fileId.value] }, + start = { userId, entry -> started += userId to entry.fileId }, + dismiss = { userId, fileId -> dismissed += userId to fileId }, + ) + + @After + fun tearDown() { + MobileDownloadActionReceiver.retriesForTest = null + } + + @Test + fun retryQueuesTheOwnersFailedDownloadAgain() = runBlocking { + rows[USER to 11L] = row(11L, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + + assertEquals(DownloadRetryOutcome.STARTED, retries.retry(USER, FilesItemId(11L))) + + assertEquals(listOf(USER to FilesItemId(11L)), started) + } + + @Test + fun retryUnderAnotherAccountOrNoneRetriesNothingAndRemovesTheNotification() = runBlocking { + rows[USER to 11L] = row(11L, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + rows[OTHER to 11L] = row(11L, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + + account = SignedInAccount.User(OTHER) + assertEquals(DownloadRetryOutcome.REFUSED, retries.retry(USER, FilesItemId(11L))) + account = SignedInAccount.Nobody + assertEquals(DownloadRetryOutcome.REFUSED, retries.retry(USER, FilesItemId(11L))) + + assertTrue(started.isEmpty()) + assertEquals(listOf(USER to FilesItemId(11L), USER to FilesItemId(11L)), dismissed) + } + + @Test + fun anUnconfirmedSessionLeavesTheNotificationForLater() = runBlocking { + rows[USER to 11L] = row(11L, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + account = SignedInAccount.Unknown + + assertEquals(DownloadRetryOutcome.UNCONFIRMED, retries.retry(USER, FilesItemId(11L))) + + assertTrue(started.isEmpty()) + assertTrue(dismissed.isEmpty()) + } + + @Test + fun aRowThatIsGoneDeletingOrNoLongerFailedIsNotRetried() = runBlocking { + rows[USER to 12L] = row(12L, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)).copy(removing = true) + rows[USER to 13L] = row(13L, DownloadStatus.Queued) + + for (fileId in listOf(12L, 13L, 14L)) { + assertEquals(DownloadRetryOutcome.NOT_RETRYABLE, retries.retry(USER, FilesItemId(fileId))) + } + + assertTrue(started.isEmpty()) + assertEquals(listOf(12L, 13L, 14L).map { USER to FilesItemId(it) }, dismissed) + } + + @Test + fun theNotificationsPendingIntentReachesTheReceiverWithItsAccountAndFile() { + val received = CompletableDeferred>() + MobileDownloadActionReceiver.retriesForTest = DownloadNotificationRetries( + signedIn = { SignedInAccount.User(USER) }, + find = { _, fileId -> row(fileId.value, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) }, + start = { userId, entry -> received.complete(userId to entry.fileId) }, + dismiss = { _, _ -> }, + ) + + shadowOf(MobileDownloadActionReceiver.retry(context, USER, FilesItemId(11L))).savedIntent + .let(context::sendBroadcast) + shadowOf(Looper.getMainLooper()).idle() + + assertTrue(received.isCompleted) + assertEquals(USER to FilesItemId(11L), runBlocking { received.await() }) + } + + private fun row(fileId: Long, status: DownloadStatus) = DownloadEntry( + FilesItemId(fileId), "file-$fileId.mkv", PutioFileType.VIDEO, DownloadArtifact.HLS, status, + createdAt = fileId, accepted = true, + ) + + private companion object { + const val USER = 7L + const val OTHER = 8L + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadNotificationsTest.kt b/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadNotificationsTest.kt index 4cd4e1e9..b4a92660 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadNotificationsTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadNotificationsTest.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.Application import android.app.Notification import android.app.NotificationManager +import android.content.ComponentName import android.content.Intent import android.net.Uri import androidx.media3.common.util.UnstableApi @@ -16,6 +17,8 @@ import androidx.media3.exoplayer.offline.DownloadRequest import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.putdotio.android.MainActivity +import io.putdotio.android.MobileDeepLink +import io.putdotio.android.parseMobileDeepLink import io.putdotio.android.files.FilesItemId import io.putdotio.sdk.files.PutioFileType import java.io.IOException @@ -75,13 +78,69 @@ class MobileDownloadNotificationsTest { assertEquals(null, finished.publicVersion.extras.getString(Notification.EXTRA_TEXT)) val opened = shadowOf(finished.contentIntent).savedIntent assertEquals(Intent.ACTION_VIEW, opened.action) - assertEquals(Uri.parse("putio://downloads/10"), opened.data) + assertEquals(Uri.parse("putio://downloads/10?user=$USER"), opened.data) assertEquals(MainActivity::class.java.name, opened.component?.className) val failed = posted.single { it.extras.getString(Notification.EXTRA_TITLE) == "Download failed" } assertTrue(failed.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString().contains("Tears.mkv")) assertTrue(failed.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString().contains("Retry")) - assertEquals(Uri.parse("putio://downloads/11"), shadowOf(failed.contentIntent).savedIntent.data) + assertEquals(Uri.parse("putio://downloads/11?user=$USER"), shadowOf(failed.contentIntent).savedIntent.data) + } + + @Test + fun aFinishedDownloadOffersPlayAndAFailedOneRetryEachForItsOwnAccountOnly() { + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + + notifications.changed(download(10L, Download.STATE_COMPLETED)) + notifications.changed(download(11L, Download.STATE_FAILED), UnknownHostException("offline")) + + val posted = shadowOf(manager).allNotifications + val finished = posted.single { it.extras.getString(Notification.EXTRA_TITLE) == "Download finished" } + assertEquals(listOf("Play", "Open in Downloads"), finished.actions.map { it.title.toString() }) + val play = shadowOf(finished.actions[0].actionIntent) + assertTrue(play.isActivity && play.isImmutable) + assertEquals(ComponentName(context, MainActivity::class.java), play.savedIntent.component) + assertEquals(Uri.parse("putio://downloads/10/play?user=$USER"), play.savedIntent.data) + assertEquals( + MobileDeepLink.Downloads(FilesItemId(10L), play = true, userId = USER), + parseMobileDeepLink(play.savedIntent.data), + ) + val open = shadowOf(finished.actions[1].actionIntent) + assertTrue(open.isActivity && open.isImmutable) + assertEquals(Uri.parse("putio://downloads/10?user=$USER"), open.savedIntent.data) + + val failed = posted.single { it.extras.getString(Notification.EXTRA_TITLE) == "Download failed" } + assertEquals(listOf("Try again", "Open in Downloads"), failed.actions.map { it.title.toString() }) + val retry = shadowOf(failed.actions[0].actionIntent) + // A broadcast to the app's own unexported receiver: nothing opens and no other app can send it. + assertTrue(retry.isBroadcast && retry.isImmutable) + assertEquals(ComponentName(context, MobileDownloadActionReceiver::class.java), retry.savedIntent.component) + assertEquals(MobileDownloadActionReceiver.ACTION_RETRY, retry.savedIntent.action) + assertEquals(Uri.parse("putio://downloads/11?user=$USER"), retry.savedIntent.data) + assertTrue(failed.actions.none { it.title.toString() == "Play" }) + // No token or URL of the file reaches any intent. + for (action in finished.actions + failed.actions) { + assertTrue(shadowOf(action.actionIntent).savedIntent.extras?.isEmpty != false) + } + } + + @Test + fun aSessionEndRemovesEveryOtherAccountsOutcomes() { + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + notifications.changed(download(10L, Download.STATE_COMPLETED)) + runBlocking { MobileDownloadStore(context, OTHER).upsert(row(20L, "Other.mkv")) } + signedIn = OTHER + notifications.changed(download(20L, Download.STATE_COMPLETED, user = OTHER)) + assertEquals(2, shadowOf(manager).allNotifications.size) + + // OTHER is already signed in when the collector reports USER's session as ended. + MobileDownloadNotifications.cancelOtherAccounts(context, signedInUser = OTHER) + assertEquals(listOf("Other.mkv"), shadowOf(manager).allNotifications.map { + it.extras.getString(Notification.EXTRA_TEXT) + }) + + MobileDownloadNotifications.cancelOtherAccounts(context, signedInUser = null) + assertTrue(shadowOf(manager).allNotifications.isEmpty()) } @Test @@ -145,8 +204,8 @@ class MobileDownloadNotificationsTest { createdAt = fileId, accepted = true, ) - private fun download(fileId: Long, state: Int) = Download( - DownloadRequest.Builder("$USER:$fileId", Uri.parse("https://api.put.io/v2/files/$fileId/stream")).build(), + private fun download(fileId: Long, state: Int, user: Long = USER) = Download( + DownloadRequest.Builder("$user:$fileId", Uri.parse("https://api.put.io/v2/files/$fileId/stream")).build(), state, 0L, 0L, diff --git a/mobile/src/test/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetTest.kt b/mobile/src/test/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetTest.kt new file mode 100644 index 00000000..37c1c5a0 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/widgets/MobileTransfersWidgetTest.kt @@ -0,0 +1,283 @@ +package io.putdotio.android.widgets + +import android.app.Application +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.os.Looper +import android.view.View +import android.widget.FrameLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.MainActivity +import io.putdotio.android.PutioFailure +import io.putdotio.android.PutioResult +import io.putdotio.android.R +import io.putdotio.android.auth.MobileAccount +import io.putdotio.android.auth.MobileAuthSessionId +import io.putdotio.android.auth.MobileAuthState +import io.putdotio.android.auth.MobileSessionKey +import io.putdotio.android.transfers.AppTransferStatus +import io.putdotio.android.transfers.TransferId +import io.putdotio.android.transfers.TransferItem +import io.putdotio.sdk.errors.PutioConfigurationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** The Transfers widget per session state, against Robolectric's widget host, which reapplies views like a launcher. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], qualifiers = "en-rUS") +class MobileTransfersWidgetTest { + private val context: Application = ApplicationProvider.getApplicationContext() + private val widgets = shadowOf(AppWidgetManager.getInstance(context)) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val auth = MutableStateFlow(MobileAuthState.SignedOut()) + private val loads = mutableListOf() + private val rejected = mutableListOf() + private var next: suspend () -> PutioResult> = { PutioResult.Success(emptyList()) } + private val updater = TransfersWidgetUpdater( + context = context, + source = TransfersWidgetSource( + authState = auth, + settledSession = { auth.value }, + load = { + loads += auth.value + next() + }, + rejectSession = { rejected += it }, + ), + scope = scope, + clock = { NOW }, + ) + + @After + fun tearDown() { + MobileWidgets.transfersForTest = null + scope.cancel() + } + + @Test + fun signedInTheWidgetListsActiveTransfersWithProgressAndOpensTransfers() { + auth.value = signedIn(USER, session = 1L) + next = { + PutioResult.Success( + listOf( + transfer(1L, "ubuntu.iso", AppTransferStatus.Downloading, percent = 42.4), + transfer(2L, "done.mkv", AppTransferStatus.Completed, percent = 100.0), + transfer(3L, "debian.iso", AppTransferStatus.Queued, percent = null), + transfer(4L, "arch.iso", AppTransferStatus.Seeding, percent = 100.0), + transfer(5L, "fedora.iso", AppTransferStatus.Waiting, percent = 0.0), + transfer(6L, "broken.iso", AppTransferStatus.Failed, percent = 10.0), + ), + ) + } + val widget = place() + + assertEquals(listOf("ubuntu.iso", "debian.iso", "arch.iso"), names(widget)) + assertEquals("Downloading · 42%", text(widget, R.id.widget_transfers_row_0_status)) + assertEquals(42, widget.findViewById(R.id.widget_transfers_row_0_progress).progress) + assertEquals("Queued", text(widget, R.id.widget_transfers_row_1_status)) + assertTrue(widget.findViewById(R.id.widget_transfers_row_1_progress).isIndeterminate) + assertTrue(text(widget, R.id.widget_transfers_footer).endsWith(" · 1 more")) + assertEquals(View.VISIBLE, widget.findViewById(R.id.widget_transfers_refresh).visibility) + assertEquals(View.GONE, widget.findViewById(R.id.widget_transfers_message).visibility) + + widget.findViewById(android.R.id.background).performClick() + val opened = shadowOf(context).nextStartedActivity + assertEquals(ComponentName(context, MainActivity::class.java), opened.component) + assertEquals(Intent.ACTION_VIEW, opened.action) + assertEquals("putio://transfers".toUri(), opened.data) + } + + @Test + fun signedOutTheWidgetAsksToSignInAndReadsNothing() { + val widget = place() + + assertEquals("Sign in to put.io to see your transfers.", text(widget, R.id.widget_transfers_message)) + assertTrue(names(widget).isEmpty()) + assertEquals(View.GONE, widget.findViewById(R.id.widget_transfers_refresh).visibility) + assertTrue(loads.isEmpty()) + } + + @Test + fun signOutClearsEveryNameAtOnce() { + auth.value = signedIn(USER, session = 1L) + next = { PutioResult.Success(listOf(transfer(1L, "private-name.iso", AppTransferStatus.Downloading, 5.0))) } + val widget = place() + assertEquals(listOf("private-name.iso"), names(widget)) + + auth.value = MobileAuthState.SignedOut() + updater.clear() + + assertEquals("Sign in to put.io to see your transfers.", text(widget, R.id.widget_transfers_message)) + // The host reapplied onto the same views: hidden rows must not keep the old text either. + assertEquals(listOf("", "", ""), rowTexts(widget)) + assertEquals("", text(widget, R.id.widget_transfers_footer)) + } + + @Test + fun aReadThatOutlivesItsSessionNeverShowsThatAccountsNames() { + auth.value = signedIn(USER, session = 1L) + val gate = CompletableDeferred() + next = { + gate.await() + PutioResult.Success(listOf(transfer(1L, "first-account.iso", AppTransferStatus.Downloading, 5.0))) + } + val widget = place() + assertEquals(1, loads.size) + + // Account switch while the first account's read is still out. + auth.value = signedIn(OTHER, session = 2L) + gate.complete(Unit) + idle() + + assertFalse(rowTexts(widget).contains("first-account.iso")) + } + + @Test + fun anAccountSwitchShowsOnlyTheNextAccountsTransfers() { + auth.value = signedIn(USER, session = 1L) + next = { PutioResult.Success(listOf(transfer(1L, "first.iso", AppTransferStatus.Downloading, 5.0))) } + val widget = place() + + auth.value = MobileAuthState.SignedOut() + updater.clear() + auth.value = signedIn(OTHER, session = 2L) + next = { PutioResult.Success(listOf(transfer(9L, "second.iso", AppTransferStatus.Queued, null))) } + updater.refresh() + idle() + + assertEquals(listOf("second.iso"), names(widget)) + assertFalse(rowTexts(widget).contains("first.iso")) + } + + @Test + fun aRejectedSessionIsSignedOutAndAFailedReadKeepsTheRowsShown() { + auth.value = signedIn(USER, session = 1L) + next = { PutioResult.Success(listOf(transfer(1L, "kept.iso", AppTransferStatus.Downloading, 5.0))) } + val widget = place() + + next = { PutioResult.Failure(PutioFailure.NetworkUnavailable(IllegalStateException("offline"))) } + updater.refresh() + idle() + assertEquals(listOf("kept.iso"), names(widget)) + + next = { PutioResult.Failure(PutioFailure.AuthenticationRequired(PutioConfigurationException("rejected"))) } + updater.refresh() + idle() + assertEquals(listOf(MobileAuthSessionId(1L)), rejected) + } + + @Test + fun withoutAPlacedWidgetNothingIsRead() { + auth.value = signedIn(USER, session = 1L) + + updater.refresh() + updater.show(MobileSessionKey(USER, MobileAuthSessionId(1L)), emptyList()) + idle() + + assertTrue(loads.isEmpty()) + } + + @Test + fun theTransfersScreensRowsReachTheWidgetOnlyForTheSignedInSession() { + auth.value = signedIn(USER, session = 1L) + val widget = place() + assertEquals("No active transfers", text(widget, R.id.widget_transfers_message)) + + val stale = listOf(transfer(1L, "stale.iso", AppTransferStatus.Downloading, 1.0)) + updater.show(MobileSessionKey(OTHER, MobileAuthSessionId(2L)), stale) + assertTrue(names(widget).isEmpty()) + + val live = listOf(transfer(2L, "live.iso", AppTransferStatus.Downloading, 1.0)) + updater.show(MobileSessionKey(USER, MobileAuthSessionId(1L)), live) + assertEquals(listOf("live.iso"), names(widget)) + } + + @Test + fun theRefreshButtonIsAnExplicitImmutableBroadcastToTheAppsOwnReceiver() { + val refresh = shadowOf(MobileWidgetActionReceiver.refresh(context)) + + assertTrue(refresh.isBroadcast && refresh.isImmutable) + assertEquals(ComponentName(context, MobileWidgetActionReceiver::class.java), refresh.savedIntent.component) + assertEquals(MobileWidgetActionReceiver.ACTION_REFRESH, refresh.savedIntent.action) + } + + @Test + fun renderedViewsCarryNoNameOutsideTheReadyState() { + for (content in listOf( + TransfersWidgetContent.SignedOut, + TransfersWidgetContent.Loading, + TransfersWidgetContent.Unavailable, + )) { + val view = transfersWidgetViews(context, content).apply(context, FrameLayout(context)) + assertEquals(listOf("", "", ""), rowTexts(view)) + } + } + + /** Places one widget; the host sends the update a launcher sends, which runs a refresh. */ + private fun place(): View { + MobileWidgets.transfersForTest = updater + val id = widgets.createWidget(MobileTransfersWidgetProvider::class.java, R.layout.widget_transfers) + idle() + return widgets.getViewFor(id) + } + + private fun idle() = shadowOf(Looper.getMainLooper()).idle() + + private fun text(view: View, id: Int): String = view.findViewById(id).text.toString() + + private fun rowTexts(view: View): List = ROW_NAMES.map { text(view, it) } + + private fun names(view: View): List = + ROW_NAMES.zip(ROWS).filter { (_, row) -> view.findViewById(row).visibility == View.VISIBLE } + .map { (name, _) -> text(view, name) } + + private companion object { + const val USER = 7L + const val OTHER = 8L + const val NOW = 1_700_000_000_000L + val ROWS = listOf(R.id.widget_transfers_row_0, R.id.widget_transfers_row_1, R.id.widget_transfers_row_2) + val ROW_NAMES = listOf( + R.id.widget_transfers_row_0_name, + R.id.widget_transfers_row_1_name, + R.id.widget_transfers_row_2_name, + ) + + fun signedIn(userId: Long, session: Long) = MobileAuthState.SignedIn( + account = MobileAccount(userId = userId, username = "user$userId", email = "user$userId@example.com"), + sessionId = MobileAuthSessionId(session), + ) + + fun transfer(id: Long, name: String, status: AppTransferStatus, percent: Double?) = TransferItem( + id = TransferId(id), + name = name, + status = status, + fileId = null, + sizeBytes = null, + percentDone = percent, + downloadSpeedBytesPerSecond = null, + uploadSpeedBytesPerSecond = null, + estimatedSecondsRemaining = null, + availability = null, + errorMessage = null, + createdAt = "2026-10-04T00:00:00", + userFileExists = null, + ) + } +} From e5851fb745ec5eecc7457049e96a98a6394c8d8b Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 5 Oct 2026 02:23:20 +0300 Subject: [PATCH 2/4] fix(mobile): find the widget's home page in the proof and guard devices without widgets --- .../src/test/kotlin/GenerateShortcutsTest.kt | 2 +- docs/harness.md | 6 ++++- .../android/MobileHomeScreenProofTest.kt | 23 +++++++++++++++++-- .../android/widgets/MobileTransfersWidget.kt | 8 ++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/build-logic/src/test/kotlin/GenerateShortcutsTest.kt b/build-logic/src/test/kotlin/GenerateShortcutsTest.kt index 0ab65561..87397f20 100644 --- a/build-logic/src/test/kotlin/GenerateShortcutsTest.kt +++ b/build-logic/src/test/kotlin/GenerateShortcutsTest.kt @@ -6,7 +6,7 @@ import org.junit.Test class GenerateShortcutsTest { @Test fun everyPlaceholderBecomesTheVariantsApplicationId() { - val template = """""" + val template = """""".repeat(2) assertEquals( """""".repeat(2), renderShortcuts(template, "io.put.putio.mobile.nightly.debug"), diff --git a/docs/harness.md b/docs/harness.md index 5497ae29..8aa8fcd2 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -1405,7 +1405,8 @@ selector at a time, and require `OK (1 test)` for each. Start Screenshots land in `homescreen-proof-/`: - `theTransfersWidgetFollowsTheSessionAndForgetsItOnSignOut` pins the widget - through the launcher's dialog the first time, then captures + through the launcher's dialog the first time, swipes to the home page the + launcher put it on, then captures `01-widget-signed-out`, `02-widget-signed-in` (three rows and `1 more`), `03-widget-cleared-on-sign-out` (no row name anywhere on screen) and `04-widget-next-account` @@ -1419,6 +1420,9 @@ Screenshots land in `homescreen-proof-/`: sign-in on a signed-out install. The receiver's Media3 start is a stand-in; routing after sign-in is `MobileShellLinkActionsTest` +`am force-stop` cancels every pending intent the app made, the widget's +included, so after one the widget's taps do nothing until its next update. + ## Shared-with-me items proof Behaviour: [Shared-with-me items](./behavior.md#shared-with-me-items). diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt index ff68ef83..ba33161a 100644 --- a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileHomeScreenProofTest.kt @@ -73,7 +73,10 @@ class MobileHomeScreenProofTest { @get:Rule val optIn = TestRule { base, _ -> object : Statement() { override fun evaluate() { - assumeTrue("Home-screen proof requires opt-in", arguments.getString("putio.homescreen.enabled") == "true") + assumeTrue( + "Home-screen proof requires opt-in", + arguments.getString("putio.homescreen.enabled") == "true", + ) require(Build.VERSION.SDK_INT == 37) { "Home-screen proof requires API 37" } runId() // The shade and the launcher are other apps' windows; reading them needs every window. @@ -126,7 +129,7 @@ class MobileHomeScreenProofTest { awaitCondition("the widget placed") { widgets.getAppWidgetIds(provider).isNotEmpty() } } } - home() + showWidgetPage() awaitText(SIGNED_OUT) screenshot("01-widget-signed-out") @@ -260,6 +263,20 @@ class MobileHomeScreenProofTest { private fun awaitNotification(name: String) = awaitNode("the notification for $name") { it.text?.toString()?.lines()?.contains(name) == true } + /** The launcher may have placed the widget on a later home page. */ + private fun showWidgetPage() { + home() + repeat(HOME_PAGES) { + if (findNode(SIGNED_OUT) != null || findNode(WIDGET_TITLE) != null) return + val display = context.resources.displayMetrics + val y = display.heightPixels / 2 + instrumentation.uiAutomation.executeShellCommand( + "input swipe ${display.widthPixels * 9 / 10} $y ${display.widthPixels / 10} $y 300", + ).use { FileInputStream(it.fileDescriptor).readBytes() } + SystemClock.sleep(SETTLE_MS) + } + } + private fun awaitText(text: String) = awaitNode("'$text' on screen") { it.text?.toString() == text } private fun findNode(text: String): AccessibilityNodeInfo? = nodes().firstOrNull { it.text?.toString() == text } @@ -333,6 +350,8 @@ class MobileHomeScreenProofTest { const val IDLE_MS = 300L const val SETTLE_MS = 1_500L const val SIGNED_OUT = "Sign in to put.io to see your transfers." + const val WIDGET_TITLE = "Transfers" + const val HOME_PAGES = 3 val ADD_BUTTON = Regex("(?i)add( to home screen)?") val FIRST_ACCOUNT = listOf( diff --git a/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt index 786117ea..c66297da 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt @@ -90,9 +90,11 @@ internal class TransfersWidgetUpdater( /** What this process last pushed, and for which session; null until it pushes anything. */ private var shown: Pair? = null - private val manager get() = AppWidgetManager.getInstance(appContext) + /** Null on a device without home-screen widgets, which then never has one placed. */ + private val manager: AppWidgetManager? get() = AppWidgetManager.getInstance(appContext) private val widgetIds: IntArray - get() = manager.getAppWidgetIds(ComponentName(appContext, MobileTransfersWidgetProvider::class.java)) + get() = manager?.getAppWidgetIds(ComponentName(appContext, MobileTransfersWidgetProvider::class.java)) + ?: IntArray(0) /** * Reads the account's transfers and shows them, replacing any refresh still running. Does @@ -177,7 +179,7 @@ internal class TransfersWidgetUpdater( private fun push(key: MobileSessionKey?, content: TransfersWidgetContent) { val ids = widgetIds - if (ids.isNotEmpty()) manager.updateAppWidget(ids, transfersWidgetViews(appContext, content)) + if (ids.isNotEmpty()) manager?.updateAppWidget(ids, transfersWidgetViews(appContext, content)) shown = key to content } From d9e10e0829df22f10b0691ed195b785dbb201be2 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 5 Oct 2026 02:26:57 +0300 Subject: [PATCH 3/4] fix(mobile): tell lint the generated shortcuts use their icons and labels --- mobile/src/main/res/raw/keep.xml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 mobile/src/main/res/raw/keep.xml diff --git a/mobile/src/main/res/raw/keep.xml b/mobile/src/main/res/raw/keep.xml new file mode 100644 index 00000000..a0fd7cd5 --- /dev/null +++ b/mobile/src/main/res/raw/keep.xml @@ -0,0 +1,5 @@ + + + From db372ddc3592cb03276798720053fd4f05caee2a Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 5 Oct 2026 03:12:17 +0300 Subject: [PATCH 4/4] fix(mobile): keep a failed download's notification until Try again starts the service The receiver waits at most 5 s for the session, inside the time Android allows a foreground service start after a notification tap, and the outcome notification goes only after the download service has the request. Widget and retry broadcasts end by 8 s; the widget's picker preview shows sample rows. --- docs/behavior.md | 25 +- .../io/putdotio/android/BroadcastHold.kt | 39 +++ .../downloads/MobileDownloadActionReceiver.kt | 14 +- .../android/downloads/MobileDownloadEngine.kt | 14 +- .../android/widgets/MobileTransfersWidget.kt | 13 +- .../src/main/res/layout/widget_transfers.xml | 230 +++++++++--------- .../res/layout/widget_transfers_preview.xml | 165 +++++++++++++ mobile/src/main/res/values/strings.xml | 6 + .../res/xml-v31/transfers_widget_info.xml | 2 +- .../io/putdotio/android/BroadcastHoldTest.kt | 41 ++++ .../MobileDownloadActionReceiverTest.kt | 33 +++ 11 files changed, 444 insertions(+), 138 deletions(-) create mode 100644 mobile/src/main/kotlin/io/putdotio/android/BroadcastHold.kt create mode 100644 mobile/src/main/res/layout/widget_transfers_preview.xml create mode 100644 mobile/src/test/kotlin/io/putdotio/android/BroadcastHoldTest.kt diff --git a/docs/behavior.md b/docs/behavior.md index 42988831..c3fd021c 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -975,11 +975,14 @@ under another account they open nothing (see [Deep links](#deep-links)). Try again is an explicit, immutable broadcast to an unexported receiver, so no other app can send it. It hands the download back to Media3 without opening the app, and the row follows Media3's state as after any background change, -but only for the account that owns it, once the session settles (a stored one -is restored first, for up to 15 s), and only while the row is still failed or -missing. Under another account or none, or for a row that is gone, it removes -the notification instead; while the session cannot be confirmed it does -nothing. Each file has one slot, cleared by a retry, a delete or the end of +but only for the account that owns it, once the session settles, and only while +the row is still failed or missing. A stored session is restored first, for up +to 5 s: Android lets an app start its download service for about 10 s after a +notification tap. Under another account or none, or for a row that is gone, it +removes the notification instead. While the session cannot be confirmed, or +when the system refuses to start the service, it does nothing and the +notification stays; the notification goes only once the service has the +request, and a second tap finds the session restored. Each file has one slot, cleared by a retry, a delete or the end of its account's session: when a session ends, every outcome of an account other than the one signed in then is removed. Only the account signed in now is notified, so one account's @@ -1014,7 +1017,7 @@ copies, concurrency, progress polling while shown), `MobileDownloadsScreenTest` shown and hidden events), `MobileShellDownloadsTest` (bulk delete never reaches put.io, notification links, the subtitle setting a Files download carries), `MobileDownloadStoreTest` (unreadable rows kept), `MobileDownloadActionReceiverTest` (Try again for the -owner only), `MobileDownloadEngineTest` (queue order and limit +owner only, a refused start keeps the notification), `BroadcastHoldTest`, `MobileDownloadEngineTest` (queue order and limit across process recreation, recovery of every row state, rows rebuilt for downloads an unreadable index lost, low-storage pause, reconcile, sign-out parking, account isolation, close before reconcile, in-memory progress and its denominator, the @@ -1037,7 +1040,8 @@ one; a footer says when the rows were read and how many more are running. With none it reads No active transfers. A tap opens Transfers through `putio://transfers`, and a refresh button reads again. Signed out it reads Sign in to put.io to see your transfers, with no refresh button, and a tap opens -sign-in, then Transfers. +sign-in, then Transfers. On Android 12 and later the widget picker previews it +with sample rows. It reads the transfers through the SDK in the signed-in session, never with a token of its own: when it is placed, every 30 minutes (`updatePeriodMillis`, @@ -1046,7 +1050,9 @@ whenever the Transfers screen loads rows, which it shows without a request of its own. With no widget placed nothing is read. A refresh replaces one still running and gives up after 25 s. In a process the app has not started it restores the stored session first, for up to 15 s, as the download service -does. A 401 signs the session out as anywhere else. A failed read keeps rows +does. The broadcast that asked for it ends after 8 s at the latest, within the +10 s a foreground-queue broadcast gets, and a refresh still running then goes +on best-effort; the next update tries again if the process dies first. A 401 signs the session out as anywhere else. A failed read keeps rows already shown, with the time they were read; with none shown, or when the session cannot be confirmed, it reads Couldn't reach put.io. @@ -1064,7 +1070,8 @@ generated design tokens' XML colors (`@color/putio_*`). Tests: `MobileTransfersWidgetTest` (each auth state against Robolectric's widget host, which reapplies views as launchers do: sign-out, a read that outlives its session, an account switch, a 401, failed reads, no widget, the -Transfers screen's rows, intents). +Transfers screen's rows, intents), `BroadcastHoldTest` (the broadcast ends +by 8 s, once). ## TV Back diff --git a/mobile/src/main/kotlin/io/putdotio/android/BroadcastHold.kt b/mobile/src/main/kotlin/io/putdotio/android/BroadcastHold.kt new file mode 100644 index 00000000..0c103a90 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/BroadcastHold.kt @@ -0,0 +1,39 @@ +package io.putdotio.android + +import android.content.BroadcastReceiver +import android.os.Handler +import android.os.Looper +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Keeps a receiver's broadcast open while its work runs, but ends it after [limitMillis] at the + * latest: one delivered on the foreground queue gets 10 s before the system reports it stuck. Work + * still running then goes on best-effort. [release] may be called any number of times. + */ +internal class BroadcastHold( + private val finish: () -> Unit, + limitMillis: Long = BROADCAST_HOLD_MILLIS, + private val handler: Handler = Handler(Looper.getMainLooper()), +) { + private val released = AtomicBoolean(false) + private val timeout = Runnable(::release) + + init { + handler.postDelayed(timeout, limitMillis) + } + + fun release() { + if (!released.compareAndSet(false, true)) return + handler.removeCallbacks(timeout) + finish() + } +} + +/** Under the 10 s a foreground-queue broadcast gets, with room for the main thread to run the release. */ +internal const val BROADCAST_HOLD_MILLIS = 8_000L + +/** [BroadcastReceiver.goAsync] behind a [BroadcastHold]; outside a broadcast, as in a test, there is nothing to end. */ +internal fun BroadcastReceiver.holdBroadcast(): BroadcastHold { + val pending: BroadcastReceiver.PendingResult? = goAsync() + return BroadcastHold({ pending?.finish() }) +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt index e5ce4f6b..3ce2b6b2 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiver.kt @@ -8,6 +8,7 @@ import io.putdotio.android.MobileDeepLink import io.putdotio.android.auth.MobileAuthState import io.putdotio.android.auth.MobileOAuthRuntime import io.putdotio.android.files.FilesItemId +import io.putdotio.android.holdBroadcast import io.putdotio.android.parseMobileDeepLink import io.putdotio.android.toRouteUri import kotlin.time.Duration.Companion.seconds @@ -28,13 +29,12 @@ internal class MobileDownloadActionReceiver : BroadcastReceiver() { val fileId = link?.fileId if (userId == null || fileId == null) return val retries = retriesForTest ?: production(context.applicationContext) - // Null when called outside a broadcast, as a test may. - val pending: PendingResult? = goAsync() + val hold = holdBroadcast() scope.launch { try { retries.retry(userId, fileId) } finally { - pending?.finish() + hold.release() } } } @@ -48,8 +48,12 @@ internal class MobileDownloadActionReceiver : BroadcastReceiver() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - /** Restoring a session in a cold process reads put.io; past this the tap does nothing. */ - private val SESSION_TIMEOUT = 15.seconds + /** + * Restoring a session in a cold process reads put.io. Android lets an app start a foreground + * service for about 10 s after a notification tap, so past this the tap does nothing and the + * notification stays for another tap, which finds the session already restored. + */ + private val SESSION_TIMEOUT = 5.seconds /** An explicit, immutable broadcast; the data keeps one pending intent per account and file. */ fun retry(context: Context, userId: Long, fileId: FilesItemId): PendingIntent = diff --git a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt index 593b2e4b..973f1706 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/downloads/MobileDownloadEngine.kt @@ -243,18 +243,26 @@ internal class MobileDownloadEngine( /** * Hands [userId]'s download to Media3 through the download service: a new request, or a failed or - * missing one again, which joins the back of the queue. Its last outcome notification goes. + * missing one again, which joins the back of the queue. Its last outcome notification goes once the + * service has the request; a start the system refuses throws and leaves it, Try again included. */ @androidx.annotation.OptIn(markerClass = [UnstableApi::class]) -internal fun sendDownloadRequest(context: Context, userId: Long, entry: DownloadEntry) { +internal fun sendDownloadRequest( + context: Context, + userId: Long, + entry: DownloadEntry, + send: (DownloadRequest) -> Unit = { + DownloadService.sendAddDownload(context, MobileDownloadService::class.java, it, true) + }, +) { val url = entry.artifact.apiUrl(entry.fileId, subtitlesHidden = entry.subtitlesHidden == true) val request = DownloadRequest.Builder(downloadContentId(userId, entry.fileId), url.toUri()) .setMimeType(if (entry.artifact == DownloadArtifact.HLS) MimeTypes.APPLICATION_M3U8 else null) // The name rides along so a row lost from the app's index can be rebuilt with it. .setData(entry.name.encodeToByteArray()) .build() + send(request) MobileDownloadNotifications.cancel(context, userId, entry.fileId) - DownloadService.sendAddDownload(context, MobileDownloadService::class.java, request, true) } /** This user's file id in a `userId:fileId` request id; null for another account's request. */ diff --git a/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt index c66297da..38f9944d 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/widgets/MobileTransfersWidget.kt @@ -15,6 +15,7 @@ import io.putdotio.android.auth.MobileAuthState import io.putdotio.android.auth.MobileOAuthRuntime import io.putdotio.android.auth.MobileSessionKey import io.putdotio.android.auth.sessionKey +import io.putdotio.android.holdBroadcast import io.putdotio.android.transfers.TransferItem import io.putdotio.android.transfers.SdkTransfersRepository import kotlin.time.Duration @@ -27,12 +28,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -/** The home-screen Transfers widget. The system asks for an update every 30 minutes and when one is placed. */ +/** + * The home-screen Transfers widget. The system asks for an update every 30 minutes and when one is + * placed; the broadcast ends after 8 s at the latest, and a refresh still running goes on best-effort. + */ class MobileTransfersWidgetProvider : AppWidgetProvider() { override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - // Null when called outside a broadcast, as a test may. - val pending: PendingResult? = goAsync() - MobileWidgets.transfers(context).refresh { pending?.finish() } + MobileWidgets.transfers(context).refresh(holdBroadcast()::release) } } @@ -40,8 +42,7 @@ class MobileTransfersWidgetProvider : AppWidgetProvider() { internal class MobileWidgetActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != ACTION_REFRESH) return - val pending: PendingResult? = goAsync() - MobileWidgets.transfers(context).refresh { pending?.finish() } + MobileWidgets.transfers(context).refresh(holdBroadcast()::release) } internal companion object { diff --git a/mobile/src/main/res/layout/widget_transfers.xml b/mobile/src/main/res/layout/widget_transfers.xml index df9997c1..d9c22069 100644 --- a/mobile/src/main/res/layout/widget_transfers.xml +++ b/mobile/src/main/res/layout/widget_transfers.xml @@ -48,139 +48,141 @@ android:textColor="@color/putio_text_secondary" android:textSize="14sp" /> + + + android:orientation="horizontal"> - + + - - - - - - - + android:layout_marginStart="8dp" + android:maxLines="1" + android:textColor="@color/putio_text_secondary" + android:textSize="12sp" /> + + + + + + + android:orientation="horizontal"> - - - - - - - - + android:layout_weight="1" + android:ellipsize="end" + android:maxLines="1" + android:textColor="@color/putio_text" + android:textSize="14sp" /> + + + + + + + + + android:orientation="horizontal"> - + + - - - - - - - + android:layout_marginStart="8dp" + android:maxLines="1" + android:textColor="@color/putio_text_secondary" + android:textSize="12sp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index bb461f78..d455b38b 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -448,6 +448,12 @@ Updated %1$s · %2$d more Refresh + + ubuntu-26.04-desktop-amd64.iso + Downloading · 42% + Big Buck Bunny (2008) 1080p + Sintel (2010) 4K + Seeding · 100% Search Search put.io diff --git a/mobile/src/main/res/xml-v31/transfers_widget_info.xml b/mobile/src/main/res/xml-v31/transfers_widget_info.xml index 66118152..5ec5b5ad 100644 --- a/mobile/src/main/res/xml-v31/transfers_widget_info.xml +++ b/mobile/src/main/res/xml-v31/transfers_widget_info.xml @@ -7,7 +7,7 @@ android:minHeight="110dp" android:minResizeWidth="180dp" android:minResizeHeight="110dp" - android:previewLayout="@layout/widget_transfers" + android:previewLayout="@layout/widget_transfers_preview" android:resizeMode="horizontal|vertical" android:targetCellWidth="4" android:targetCellHeight="2" diff --git a/mobile/src/test/kotlin/io/putdotio/android/BroadcastHoldTest.kt b/mobile/src/test/kotlin/io/putdotio/android/BroadcastHoldTest.kt new file mode 100644 index 00000000..10851142 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/BroadcastHoldTest.kt @@ -0,0 +1,41 @@ +package io.putdotio.android + +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** A receiver's broadcast ends within the 10 s a foreground-queue delivery gets, exactly once. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class BroadcastHoldTest { + private var finished = 0 + + @Test + fun workStillRunningLetsTheBroadcastEndAfterEightSeconds() { + val hold = BroadcastHold({ finished++ }) + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(BROADCAST_HOLD_MILLIS - 1)) + assertEquals(0, finished) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1)) + assertEquals(1, finished) + + // The work finishing later ends nothing twice. + hold.release() + assertEquals(1, finished) + } + + @Test + fun workThatFinishesFirstEndsTheBroadcastThenAndOnlyThen() { + val hold = BroadcastHold({ finished++ }) + + hold.release() + assertEquals(1, finished) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(BROADCAST_HOLD_MILLIS * 2)) + assertEquals(1, finished) + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt b/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt index b7776953..fa4047c4 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/downloads/MobileDownloadActionReceiverTest.kt @@ -1,6 +1,8 @@ package io.putdotio.android.downloads +import android.Manifest import android.app.Application +import android.app.NotificationManager import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -21,6 +23,7 @@ import org.robolectric.annotation.Config @Config(sdk = [35]) class MobileDownloadActionReceiverTest { private val context: Application = ApplicationProvider.getApplicationContext() + private val notifications = context.getSystemService(NotificationManager::class.java) private var account: SignedInAccount = SignedInAccount.User(USER) private val rows = mutableMapOf, DownloadEntry>() private val started = mutableListOf>() @@ -35,6 +38,7 @@ class MobileDownloadActionReceiverTest { @After fun tearDown() { MobileDownloadActionReceiver.retriesForTest = null + notifications.cancelAll() } @Test @@ -84,6 +88,35 @@ class MobileDownloadActionReceiverTest { assertEquals(listOf(12L, 13L, 14L).map { USER to FilesItemId(it) }, dismissed) } + @Test + fun aStartTheSystemRefusesKeepsTheNotificationForAnotherTry() = runBlocking { + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + val failed = row(11L, DownloadStatus.Failed(DownloadFailureReason.NETWORK, 0L)) + val sent = mutableListOf() + var refuse = true + val withService = DownloadNotificationRetries( + signedIn = { SignedInAccount.User(USER) }, + find = { _, _ -> failed }, + start = { userId, entry -> + sendDownloadRequest(context, userId, entry) { request -> + // Android 12+ refuses a foreground service start once the tap's allowance has passed. + check(!refuse) { "ForegroundServiceStartNotAllowedException" } + sent += request.id + } + }, + dismiss = { userId, fileId -> MobileDownloadNotifications.cancel(context, userId, fileId) }, + ) + MobileDownloadNotifications.post(context, USER, failed, DownloadOutcome.Failed(DownloadFailureReason.NETWORK)) + + assertEquals(DownloadRetryOutcome.UNCONFIRMED, withService.retry(USER, FilesItemId(11L))) + assertEquals(1, notifications.activeNotifications.size) + + refuse = false + assertEquals(DownloadRetryOutcome.STARTED, withService.retry(USER, FilesItemId(11L))) + assertEquals(listOf("$USER:11"), sent) + assertTrue(notifications.activeNotifications.isEmpty()) + } + @Test fun theNotificationsPendingIntentReachesTheReceiverWithItsAccountAndFile() { val received = CompletableDeferred>()