diff --git a/README.md b/README.md index 562d8d11..1e45dd10 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,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 +- Tablets and keyboards, proven on a tablet-size emulator with faked data so + far: keyboard navigation and shortcuts in Files and the player, listed in the + Keyboard Shortcuts Helper; files drag out to other apps, and links, magnet + links and `.torrent` files drag in to Add transfer; split screen, freeform + resizing and a keyboard arriving keep navigation and the playing video - 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/RobolectricOffline.kt b/build-logic/src/main/kotlin/RobolectricOffline.kt index 4febc733..68c2233e 100644 --- a/build-logic/src/main/kotlin/RobolectricOffline.kt +++ b/build-logic/src/main/kotlin/RobolectricOffline.kt @@ -1,31 +1,83 @@ +import java.io.IOException +import java.nio.file.Files +import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileCollection +import org.gradle.api.provider.Provider import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType import org.gradle.process.CommandLineArgumentProvider +import org.gradle.work.DisableCachingByDefault /** - * Runs Robolectric against the android-all jar Gradle resolved, instead of the - * one Robolectric downloads from Maven Central into `~/.m2` while tests run. + * Runs Robolectric against the android-all jars Gradle resolved, instead of the + * ones Robolectric downloads from Maven Central into `~/.m2` while tests run. * That download sat outside every build cache and failed CI on network errors; - * Gradle's copy is cached with the rest of the dependencies. Robolectric looks - * the jar up by file name in `robolectric.dependency.dir`, so the directory of - * Gradle's resolved file is enough. + * Gradle's copies are cached with the rest of the dependencies. Robolectric looks + * each jar up by file name in one `robolectric.dependency.dir`, and Gradle keeps + * every artifact in its own directory, so the jars are linked into one. */ fun Project.useOfflineRobolectric() { - val androidAll = configurations.create("robolectricAndroidAll") { - isCanBeConsumed = false - isCanBeResolved = true - } val libs = extensions.getByType().named("libs") - dependencies.add(androidAll.name, libs.findLibrary("robolectric-android-all").get()) + // One configuration per SDK: in a single one Gradle would keep only the newer of two versions. + val androidAll = files( + mapOf( + "robolectricAndroidAll" to "robolectric-android-all", + "robolectricAndroidAllApi26" to "robolectric-android-all-api26", + ).map { (name, alias) -> + configurations.create(name) { + isCanBeConsumed = false + isCanBeResolved = true + }.also { dependencies.add(it.name, libs.findLibrary(alias).get()) } + }, + ) + val linked = tasks.register("linkRobolectricAndroidAll") { + jars.from(androidAll) + directory.set(layout.buildDirectory.dir("robolectric-android-all")) + } tasks.withType().configureEach { - jvmArgumentProviders.add(RobolectricOfflineArguments(androidAll)) + dependsOn(linked) + jvmArgumentProviders.add(RobolectricOfflineArguments(androidAll, linked.flatMap { it.directory })) + } +} + +/** Links each resolved android-all jar into one directory under its own file name. */ +@DisableCachingByDefault(because = "Links files from Gradle's dependency cache; nothing to store") +abstract class LinkRobolectricAndroidAll : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.NAME_ONLY) + abstract val jars: ConfigurableFileCollection + + @get:OutputDirectory + abstract val directory: DirectoryProperty + + @TaskAction + fun link() { + val target = directory.get().asFile.toPath() + target.toFile().listFiles()?.forEach { Files.deleteIfExists(it.toPath()) } + jars.forEach { jar -> + val link = target.resolve(jar.name) + // Robolectric wants a regular file, so a hard link; a copy where the cache is on another volume. + try { + Files.createLink(link, jar.toPath()) + } catch (_: IOException) { + Files.copy(jar.toPath(), link) + } catch (_: UnsupportedOperationException) { + Files.copy(jar.toPath(), link) + } + } } } @@ -33,9 +85,11 @@ class RobolectricOfflineArguments( @get:InputFiles @get:PathSensitive(PathSensitivity.NAME_ONLY) val androidAll: FileCollection, + @get:Internal + val directory: Provider, ) : CommandLineArgumentProvider { override fun asArguments(): Iterable = listOf( "-Drobolectric.offline=true", - "-Drobolectric.dependency.dir=${androidAll.singleFile.parent}", + "-Drobolectric.dependency.dir=${directory.get().asFile}", ) } diff --git a/docs/behavior.md b/docs/behavior.md index e8e13108..7a85d852 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -757,6 +757,115 @@ Tests: `MobileDocumentsProviderTest`, `OfflineOriginalsTest`, (opt-in synthetic device proof; see [Harness](./harness.md#documents-provider-proof)). +## Keyboard and shortcuts + +Mobile only; TV keeps its D-pad rules. With a hardware keyboard, Tab and the +arrow keys walk focus through Files, which outlines the focused row, and Enter +opens the row as a tap would: a folder opens, media plays, any other file opens +its actions. A folder reached from the keyboard puts focus on the row last +focused there, such as the video that just played, else on its first visible +row. When the row that last held focus leaves the listing (moved to Trash, +say) and no row holds focus, focus goes to the row now in its place; focus the +viewer moved elsewhere, to the navigation rail say, stays there. + +Shortcuts act only on keys no focused control used, so a text field keeps +Backspace and the arrows, and a focused button keeps Enter and Space: + +- Backspace or Escape goes up a folder or back, through the same Back as the + system gesture. With nothing left to close they do nothing, rather than + leave the app. +- Ctrl+F opens Search with the cursor in its field, from any screen but the + player. +- F5 or Ctrl+R refreshes Files as pull-to-refresh does; neither runs while a + sheet is open or an operation is under way. +- Delete acts on the focused row as its sheet's delete row would: Move to + trash at once when the account's Trash is on, Delete permanently after its + confirmation when it is off. While the setting is unknown it opens the + sheet, which says why it cannot delete yet. Items shared with the viewer + have no delete, so the key does nothing on them. +- The player takes focus when it opens. Space plays or pauses, Left and Right + seek 10 s as the on-screen buttons do (a held arrow keeps seeking), and + Escape or Backspace leaves. + +Only bare keys and Ctrl are taken: Android's own shortcuts use Meta or Alt, +and Shift combinations are left alone. The system Keyboard Shortcuts Helper +(Meta + /) lists them under Files and Player. On Android 8 and 8.1, which lack +the platform's unhandled-key callback, androidx's `KeyEventDispatcher` delivers +them; `ComponentActivity`, which both apps' activities extend, routes keys +through it. + +Tests: `MobileKeyboardShortcutsTest` (keys through the window's real input +stages, on API 35 and API 26), `FilesKeyboardFocusTest`, +`MobilePlayerKeyboardTest`, device proof `MobileTabletPowerProofTest` +([Harness](./harness.md#tablet-proof)). + +## Drag and drop + +Mobile only. A file row, not a folder, drags out to another app: with a mouse +or touchpad as soon as the pressed pointer moves, with touch or a stylus after a +long press. The long press still opens the actions sheet; moving on starts the +drag and closes it. The drop receives one +`content://.drag//` URI, labelled with +the file's name and typed from its extension: no token, account id or API URL. +The drag grants global read only (`DRAG_FLAG_GLOBAL` and +`DRAG_FLAG_GLOBAL_URI_READ`; no write, persistable or prefix grant), so the +other app reads it only through the drop's `DragAndDropPermissions` and not +after releasing them. The provider is not exported. + +Nothing is downloaded or copied for a drag. Opening the URI hands out a +read-only proxy descriptor at once, and reads stream the original from put.io +with ranged requests, as the [system file picker](#documents-provider) does: +the API's download endpoint with the session in the header, never in the URL. +Every read checks that the session that started the drag is still the signed-in +one; leaving it stops reads in progress and forgets its drags. A drag nobody +took is forgotten when it ends; a dropped file stays readable by the app it +landed in for as long as the drop's grant lasts and the app keeps its 32 latest +drags. + +Links, magnet links and `.torrent` files dragged in from another app open the +Add transfer sheet through [Share-in](#share-in)'s intake and its rules: nothing +is added until Add, a new drop asks before replacing an unfinished draft, and a +`.torrent` is read off the main thread under the drop's grant, released once +the read is over or a newer intake cancelled it, and refused from this app's +own providers. While such a drag is +under way the signed-in screen shows "Drop to add a transfer" across its pane. +Item text and `http`, `https` and `magnet` URIs are read as links; a content +URI counts as a torrent only when the drag labels it `application/x-bittorrent` +or `application/octet-stream`, and is never read as text. Other files, images +for example, do not light the target, because the app has no file upload. An +open sheet or dialog is its own window and takes no drops, and nothing is +taken during playback. + +Dragging an item onto a folder inside the app does not move it; Move stays in +the actions sheet. + +Tests: `MobileDragProviderTest` (payload and grant flags; opening fetches +nothing; ranged reads; a session change or end stops reads), +`MobileTransferDropTest`, `MobileTransferDraftTest` (a cancelled read still +releases the grant), device proof `MobileTabletPowerProofTest` +([Harness](./harness.md#tablet-proof)). + +## Multi-window + +MainActivity handles orientation, screen size, layout and keyboard changes +itself, so split screen, freeform resizing, rotation, picture-in-picture and a +keyboard being attached reach Compose without recreating the Activity: +navigation, open sheets, scroll positions and the playing video's player carry +on. Changes that still recreate it (density, font scale, locale) restore +navigation and the player from saved state as before. Layout follows the +window, not the display: the bar, rail or drawer and Account's device class +come from the window's size, so a pane 600 dp or wider gets the rail when it is +tall enough. A paused split-screen or freeform window is still on screen, so +video keeps playing until the window stops; a pause or play made while it is +paused, from a headset say, counts as the viewer's, so resuming the window keeps +it. The video window requests no orientation in multi-window mode. + +Tests: `MobileMultiWindowTest` (resizing, rotation and a keyboard keep the +Activity; a density change recreates it and keeps its intake; the shell keeps +its destination across resizing and recreation), `MobilePlayerKeyboardTest` +(split-screen pause), `MobileAdaptiveNavigationTest`, device proof +`MobileTabletPowerProofTest` ([Harness](./harness.md#tablet-proof)). + ## Launcher entry `MainActivity` answers each launcher's `ACTION_MAIN` query: `LAUNCHER` on both diff --git a/docs/harness.md b/docs/harness.md index 5365ff6d..6db9006f 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -1214,6 +1214,54 @@ device's one-time immersive-mode hint is acknowledged if it shows. `am instrumen app first, and Home, the taps and the relaunch act on the whole device, so use an emulator you booted with no other proof running. Remove the fixture and the screenshot directories afterwards. +## Tablet proof + +Behaviour: [Keyboard and shortcuts](./behavior.md#keyboard-and-shortcuts), +[Drag and drop](./behavior.md#drag-and-drop) and +[Multi-window](./behavior.md#multi-window). `MobileTabletPowerProofTest` mounts +the production shell in the debug-only `MobileTabletProofActivity`, which takes +MainActivity's configuration changes, over faked Files, a local video, and a +controlled session whose drag-out reads stream a generated JPEG from an +in-process download source. +It makes no API calls, so report it as synthetic proof. The other app is the +androidTest package's `DragProbeActivity`: it runs in the test package's own +process, so it is written in Java against the framework only, and its torrent +provider uses an authority outside the app's prefix, because put.io refuses +torrents from providers named after its own package. + +The harness has no tablet AVD. Create and boot your own phone AVD +(`scripts/emulator.sh create phone --name `, then `boot`), with a +hardware keyboard and a tablet-size display: + +```bash +sed -i '' 's/^hw.keyboard=no$/hw.keyboard=yes/' ~/.android/avd/.avd/config.ini # before booting +adb -s shell wm size 2560x1600 && adb -s shell wm density 320 # 1280x800 dp +``` + +Restore with `wm size reset` and `wm density reset`. Push a landscape MP4 under +the app's external files directory, for example `ffmpeg -f lavfi -i +testsrc2=size=1280x720:rate=30:duration=90 -f lavfi -i +sine=frequency=440:duration=90 -c:v libx264 -pix_fmt yuv420p -c:a aac +-shortest tablet-proof.mp4`. Install both APKs and run each selector with +`putio.tablet.enabled=true`, `putio.tablet.runId=` and +`putio.tablet.video=`; require `OK (1 test)`: + +- `keyboardDrivesFilesSearchTrashAndThePlayer`: Tab into the listing, Enter + into a folder, Backspace up, Ctrl+F and Escape, F5, Delete to Trash with focus + kept in the listing, the Keyboard Shortcuts Helper, then Enter on the video, + Space, Right twice, Space and Escape back to its row. +- `dragAndDropWithAnotherAppInSplitScreenKeepsTheVideoAndShell`: plays the + video, opens the probe beside put.io with `FLAG_ACTIVITY_LAUNCH_ADJACENT`, and + checks the same Activity and player play on in the split. It then drags + `poster.jpg` into the probe, which reports the bytes and that it could not + read before requesting the drop's permissions, could not write, and could not + read after releasing them; then it drags the probe's `.torrent` and magnet + link into put.io. + +Screenshots land in `tablet-proof-/`. Android's one-time "Viewing full +screen" hint takes key focus from the player, so the proof answers it. The +emulator may still show the on-screen keyboard beside the hardware one. + ## Mobile share-in proof Behaviour: [Share-in](./behavior.md#share-in). Never use real credentials in diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ac47965..1cae3c4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,8 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr # The SDK 35 runtime every Robolectric test pins with @Config(sdk = [35]); its version is the one # the robolectric release above expects, and a mismatch fails offline tests by name. robolectric-android-all = { module = "org.robolectric:android-all-instrumented", version = "15-robolectric-13954326-i7" } +# minSdk's runtime, for the few tests that pin behaviour below API 28 with @Config(sdk = [26]); same instrumentation. +robolectric-android-all-api26 = { module = "org.robolectric:android-all-instrumented", version = "8.0.0_r4-robolectric-r1-i7" } android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } detekt-gradle-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } diff --git a/mobile/src/androidTest/AndroidManifest.xml b/mobile/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..abdbc018 --- /dev/null +++ b/mobile/src/androidTest/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/mobile/src/androidTest/java/io/putdotio/android/probe/DragProbeActivity.java b/mobile/src/androidTest/java/io/putdotio/android/probe/DragProbeActivity.java new file mode 100644 index 00000000..343b523d --- /dev/null +++ b/mobile/src/androidTest/java/io/putdotio/android/probe/DragProbeActivity.java @@ -0,0 +1,250 @@ +package io.putdotio.android.probe; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.view.DragAndDropPermissions; +import android.view.DragEvent; +import android.view.Gravity; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.security.MessageDigest; + +/** + * Another app beside put.io in split screen, in its own package and process: it offers a magnet link + * and a .torrent file to drag into put.io, and takes files dragged out of put.io, reporting what it + * could read and when. Written against the framework only, because the test package's own process + * does not load the app's libraries. + */ +public final class DragProbeActivity extends Activity { + public static final String ACTION_RECEIVED = "io.putdotio.android.probe.RECEIVED"; + public static final String ACTION_FINISH = "io.putdotio.android.probe.FINISH"; + public static final String EXTRA_REPLY_PACKAGE = "replyPackage"; + public static final String MAGNET = + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Harbor%20film"; + + private TextView result; + private ImageView preview; + private View dropArea; + private String replyPackage; + + private final BroadcastReceiver finisher = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + finish(); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + replyPackage = getIntent().getStringExtra(EXTRA_REPLY_PACKAGE); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(Color.rgb(0x22, 0x26, 0x2E)); + int padding = dp(24); + root.setPadding(padding, padding * 2, padding, padding); + + root.addView(text("Another app", 24, Color.WHITE)); + root.addView(text("Long-press a chip to drag it into put.io.", 14, Color.LTGRAY)); + + TextView magnet = chip("Magnet link · Harbor film"); + magnet.setOnLongClickListener(view -> view.startDragAndDrop( + ClipData.newPlainText("Harbor film", MAGNET), + new View.DragShadowBuilder(view), null, View.DRAG_FLAG_GLOBAL)); + root.addView(magnet); + + TextView torrent = chip("Harbor film.torrent"); + torrent.setOnLongClickListener(view -> { + ClipDescription description = new ClipDescription( + "Harbor film.torrent", new String[] {"application/x-bittorrent"}); + ClipData clip = new ClipData(description, new ClipData.Item(DragProbeProvider.torrentUri(this))); + return view.startDragAndDrop(clip, new View.DragShadowBuilder(view), null, + View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ); + }); + root.addView(torrent); + + LinearLayout drop = new LinearLayout(this); + drop.setOrientation(LinearLayout.VERTICAL); + drop.setGravity(Gravity.CENTER); + drop.setPadding(padding, padding, padding, padding); + drop.setBackground(outline(Color.rgb(0x8A, 0x93, 0xA6))); + drop.addView(text("Drop a put.io file here", 18, Color.WHITE)); + preview = new ImageView(this); + preview.setAdjustViewBounds(true); + preview.setMaxHeight(dp(220)); + drop.addView(preview); + result = text("", 14, Color.LTGRAY); + drop.addView(result); + LinearLayout.LayoutParams dropParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f); + dropParams.topMargin = dp(24); + root.addView(drop, dropParams); + dropArea = drop; + drop.setOnDragListener(this::onDrag); + + setContentView(root); + registerReceiver(finisher, new IntentFilter(ACTION_FINISH), Context.RECEIVER_EXPORTED); + } + + @Override + protected void onDestroy() { + unregisterReceiver(finisher); + super.onDestroy(); + } + + private boolean onDrag(View view, DragEvent event) { + switch (event.getAction()) { + case DragEvent.ACTION_DRAG_STARTED: + return event.getLocalState() == null; + case DragEvent.ACTION_DRAG_ENTERED: + dropArea.setBackground(outline(Color.rgb(0xFD, 0xCE, 0x45))); + return true; + case DragEvent.ACTION_DRAG_EXITED: + case DragEvent.ACTION_DRAG_ENDED: + dropArea.setBackground(outline(Color.rgb(0x8A, 0x93, 0xA6))); + return true; + case DragEvent.ACTION_DROP: + receive(event); + return true; + default: + return true; + } + } + + /** Reads the dropped URI as any app would, and checks the grant covers that drop and nothing more. */ + private void receive(DragEvent event) { + ClipData clip = event.getClipData(); + Uri uri = clip.getItemAt(0).getUri(); + String label = String.valueOf(clip.getDescription().getLabel()); + boolean readBeforeRequest = canRead(uri); + DragAndDropPermissions permissions = requestDragAndDropPermissions(event); + new Thread(() -> { + Intent report = new Intent(ACTION_RECEIVED).setPackage(replyPackage) + .putExtra("uri", String.valueOf(uri)) + .putExtra("label", label) + .putExtra("readBeforeRequest", readBeforeRequest) + .putExtra("granted", permissions != null); + byte[] bytes = null; + try { + report.putExtra("name", displayName(uri)).putExtra("type", getContentResolver().getType(uri)); + try (InputStream input = getContentResolver().openInputStream(uri)) { + bytes = readAll(input); + } + report.putExtra("size", bytes.length).putExtra("sha256", sha256(bytes)); + report.putExtra("writeWithGrant", canWrite(uri)); + } catch (Exception error) { + report.putExtra("error", error.getClass().getName()); + } finally { + if (permissions != null) permissions.release(); + } + report.putExtra("readAfterRelease", canRead(uri)); + byte[] shown = bytes; + runOnUiThread(() -> show(report, shown)); + sendBroadcast(report); + }).start(); + } + + private void show(Intent report, byte[] bytes) { + Bitmap image = bytes == null ? null : BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + preview.setImageBitmap(image); + String text = report.hasExtra("error") + ? "Could not read the drop: " + report.getStringExtra("error") + : "Received " + report.getStringExtra("name") + " · " + report.getStringExtra("type") + + " · " + report.getIntExtra("size", 0) + " bytes\n" + + "Readable before the drop's grant: " + report.getBooleanExtra("readBeforeRequest", true) + "\n" + + "Writable with it: " + report.getBooleanExtra("writeWithGrant", true) + "\n" + + "Readable after releasing it: " + report.getBooleanExtra("readAfterRelease", true); + result.setText(text); + } + + private boolean canRead(Uri uri) { + try (InputStream ignored = getContentResolver().openInputStream(uri)) { + return true; + } catch (Exception error) { + return false; + } + } + + private boolean canWrite(Uri uri) { + try { + getContentResolver().openFileDescriptor(uri, "w").close(); + return true; + } catch (Exception error) { + return false; + } + } + + private String displayName(Uri uri) { + try (Cursor cursor = getContentResolver().query( + uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) { + return cursor != null && cursor.moveToFirst() ? cursor.getString(0) : null; + } + } + + private static byte[] readAll(InputStream input) throws java.io.IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) output.write(buffer, 0, read); + return output.toByteArray(); + } + + private static String sha256(byte[] bytes) throws Exception { + StringBuilder hex = new StringBuilder(); + for (byte value : MessageDigest.getInstance("SHA-256").digest(bytes)) { + hex.append(String.format("%02x", value)); + } + return hex.toString(); + } + + private TextView text(String value, int sp, int color) { + TextView view = new TextView(this); + view.setText(value); + view.setTextSize(sp); + view.setTextColor(color); + view.setPadding(0, dp(4), 0, dp(4)); + return view; + } + + private TextView chip(String label) { + TextView view = text(label, 18, Color.BLACK); + view.setPadding(dp(20), dp(14), dp(20), dp(14)); + GradientDrawable background = new GradientDrawable(); + background.setColor(Color.rgb(0xFD, 0xCE, 0x45)); + background.setCornerRadius(dp(24)); + view.setBackground(background); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + params.topMargin = dp(16); + view.setLayoutParams(params); + return view; + } + + private GradientDrawable outline(int color) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setStroke(dp(3), color); + drawable.setCornerRadius(dp(16)); + return drawable; + } + + private int dp(int value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } +} diff --git a/mobile/src/androidTest/java/io/putdotio/android/probe/DragProbeProvider.java b/mobile/src/androidTest/java/io/putdotio/android/probe/DragProbeProvider.java new file mode 100644 index 00000000..20c7ef8a --- /dev/null +++ b/mobile/src/androidTest/java/io/putdotio/android/probe/DragProbeProvider.java @@ -0,0 +1,75 @@ +package io.putdotio.android.probe; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** The probe's one file: a small .torrent other apps may read through a drag's grant. */ +public final class DragProbeProvider extends ContentProvider { + static final String NAME = "Harbor film.torrent"; + static final byte[] TORRENT = ("d8:announce19:udp://tracker.local4:infod6:lengthi1024e4:name11:Harbor film" + + "12:piece lengthi16384e6:pieces20:01234567890123456789ee").getBytes(StandardCharsets.US_ASCII); + + /** + * Outside the app's own authority prefix: put.io refuses to read a torrent from any provider named + * after its package, which the test package's name would otherwise share. + */ + static final String AUTHORITY = "io.putdotio.probe.files"; + + public static Uri torrentUri(Context context) { + return new Uri.Builder().scheme("content").authority(AUTHORITY).appendPath(NAME).build(); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] args, String sortOrder) { + MatrixCursor cursor = new MatrixCursor(new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}); + cursor.addRow(new Object[] {NAME, (long) TORRENT.length}); + return cursor; + } + + @Override + public String getType(Uri uri) { + return "application/x-bittorrent"; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + File file = new File(getContext().getCacheDir(), "probe.torrent"); + try (FileOutputStream output = new FileOutputStream(file)) { + output.write(TORRENT); + } catch (IOException error) { + throw new FileNotFoundException(error.toString()); + } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(Uri uri, String selection, String[] args) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] args) { + throw new UnsupportedOperationException(); + } +} diff --git a/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt new file mode 100644 index 00000000..6f07a310 --- /dev/null +++ b/mobile/src/androidTest/kotlin/io/putdotio/android/MobileTabletPowerProofTest.kt @@ -0,0 +1,647 @@ +package io.putdotio.android + +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.UiAutomation +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.Rect +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.accessibility.AccessibilityWindowInfo +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +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.design.PutioTheme +import io.putdotio.android.files.FilesBrowserController +import io.putdotio.android.files.FilesContent +import io.putdotio.android.files.FilesCopyId +import io.putdotio.android.files.FilesCursor +import io.putdotio.android.files.FilesDeleteMode +import io.putdotio.android.files.FilesFolder +import io.putdotio.android.files.FilesItem +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.files.FilesPage +import io.putdotio.android.files.FilesRepository +import io.putdotio.android.files.FilesSort +import io.putdotio.android.files.MOBILE_FILES_LIST_TAG +import io.putdotio.android.playback.MobilePlayerFactory +import io.putdotio.android.playback.PlaybackMediaType +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.playback.audioAttributes +import io.putdotio.android.playback.playbackRenderersFactory +import io.putdotio.android.search.MOBILE_SEARCH_FIELD_TAG +import io.putdotio.android.settings.readyAccountSettingsState +import io.putdotio.android.settings.readyAndroidAppConfigState +import io.putdotio.android.share.MobileFileDragOut +import io.putdotio.android.share.MobileFileShareService +import io.putdotio.android.share.MobileShareDependencies +import io.putdotio.android.transfers.MOBILE_TRANSFER_DROP_TARGET_TAG +import io.putdotio.android.transfers.MobileTransferDraft +import io.putdotio.sdk.errors.PutioApiErrorEnvelope +import io.putdotio.sdk.errors.PutioApiException +import io.putdotio.sdk.errors.PutioRequestData +import io.putdotio.sdk.files.FileDeleteResult +import io.putdotio.sdk.files.FileMoveError +import io.putdotio.sdk.files.PlaybackSource +import io.putdotio.sdk.files.PlaybackSourceKind +import io.putdotio.sdk.files.PlaybackSubtitles +import io.putdotio.sdk.files.PutioCredentialUrl +import io.putdotio.sdk.files.PutioFileType +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.File +import java.security.MessageDigest +import java.util.UUID +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.junit.runners.model.Statement + +/** + * Synthetic tablet proof on a large-screen emulator through the production shell and player over + * faked repositories and a local video: hardware-keyboard navigation and shortcuts, then drag and + * drop with another app in split screen (the test package's drag probe, its own process) while the + * playing video and the shell keep their state through the split. The drag-out export downloads from + * an in-process source under a controlled session. No API calls, no sign-in. + */ +@RunWith(AndroidJUnit4::class) +class MobileTabletPowerProofTest { + private val compose = createAndroidComposeRule() + private val optIn = TestRule { base, _ -> + object : Statement() { + override fun evaluate() { + assumeTrue("Tablet proof requires opt-in", arguments.getString("putio.tablet.enabled") == "true") + require(Build.VERSION.SDK_INT == 37) { "Tablet proof requires API 37" } + require(context.resources.configuration.smallestScreenWidthDp >= 600) { + "Tablet proof requires a large screen; see docs/harness.md" + } + runId() + base.evaluate() + } + } + } + + @get:Rule val rules: RuleChain = RuleChain.outerRule(optIn).around(compose) + + private val poster = posterJpeg() + private val files = ProofFiles(posterSize = poster.size.toLong()) + private val players = ProofPlayers() + private val draft = MobileTransferDraft() + + @Test + fun keyboardDrivesFilesSearchTrashAndThePlayer() = withShell { + screenshot("keys-01-files") + + // The first key leaves touch mode; Tab walks in until a row holds focus. + repeat(MAX_TABS) { if (!rowFocused("Shows")) press(KeyEvent.KEYCODE_TAB) } + row("Shows").assertIsFocused() + screenshot("keys-02-row-focused") + + press(KeyEvent.KEYCODE_ENTER) + awaitCondition("Shows opened") { rowFocused("Episode 1.mp4") } + screenshot("keys-03-folder-opened-first-row-focused") + + press(KeyEvent.KEYCODE_DEL) + awaitCondition("back at root") { rowFocused("Shows") } + screenshot("keys-04-backspace-went-up") + + press(KeyEvent.KEYCODE_F, CTRL) + compose.onNodeWithTag(MOBILE_SEARCH_FIELD_TAG).assertIsFocused() + screenshot("keys-05-ctrl-f-search") + press(KeyEvent.KEYCODE_ESCAPE) + awaitCondition("Files again") { rowExists("Shows") } + + val loads = files.loads.get() + press(KeyEvent.KEYCODE_F5) + awaitCondition("F5 refreshed") { files.loads.get() > loads } + + focusRow("notes.txt") + screenshot("keys-06-notes-focused") + press(KeyEvent.KEYCODE_FORWARD_DEL) + compose.waitUntil(TIMEOUT_MS) { files.deleted == listOf(NOTES.id to FilesDeleteMode.TRASH) } + compose.onNodeWithText("Moved to Trash").assertExists() + // The focused row left the listing; keyboard focus stays in it, on the row now in its place. + awaitCondition("a row keeps focus") { rowFocused("poster.jpg") } + screenshot("keys-07-delete-moved-to-trash") + + compose.activity.requestShowKeyboardShortcuts() + SystemClock.sleep(SETTLE_MS) + screenshot("keys-08-shortcuts-helper") + compose.activity.dismissKeyboardShortcutsHelper() + SystemClock.sleep(SETTLE_MS) + + focusRow("Harbor film.mp4") + press(KeyEvent.KEYCODE_ENTER) + awaitCondition("video playing") { player { it.isPlaying } == true } + dismissFullScreenHint() + screenshot("keys-09-enter-played-the-video") + press(KeyEvent.KEYCODE_SPACE) + awaitCondition("space paused") { player { it.playWhenReady } == false } + val paused = requireNotNull(player { it.currentPosition }) + press(KeyEvent.KEYCODE_DPAD_RIGHT, pauseMs = 0) + press(KeyEvent.KEYCODE_DPAD_RIGHT, pauseMs = 0) + awaitCondition("arrows seeked") { requireNotNull(player { it.currentPosition }) >= paused + 19_000L } + screenshot("keys-10-space-paused-arrows-seeked") + press(KeyEvent.KEYCODE_SPACE) + awaitCondition("space played") { player { it.playWhenReady } == true } + press(KeyEvent.KEYCODE_ESCAPE) + awaitCondition("left the player") { rowExists("Harbor film.mp4") } + assertEquals(1, players.created.get()) + screenshot("keys-11-escape-left-the-player") + } + + @Test + fun dragAndDropWithAnotherAppInSplitScreenKeepsTheVideoAndShell() = withShell { + val host = compose.activity + focusRow("Harbor film.mp4") + press(KeyEvent.KEYCODE_ENTER) + awaitCondition("video playing") { player { it.isPlaying } == true } + dismissFullScreenHint() + screenshot("drag-01-video-full-screen") + + val reports = ProbeReports() + try { + host.startActivity( + Intent().setClassName(probePackage, PROBE_ACTIVITY) + .putExtra("replyPackage", context.packageName) + .addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or + Intent.FLAG_ACTIVITY_MULTIPLE_TASK, + ), + ) + awaitCondition("probe beside put.io") { probeWindow() != null && host.isInMultiWindowMode } + SystemClock.sleep(SETTLE_MS) + // The split resized the window without recreating it: the same Activity and player play on. + assertSame(host, compose.activity) + assertEquals(1, players.created.get()) + awaitCondition("video still playing") { player { it.isPlaying } == true } + screenshot("drag-02-video-keeps-playing-in-split-screen") + + // The app opened beside put.io holds key focus; a tap on put.io's pane gives it back. + tap(hostCenter()) + press(KeyEvent.KEYCODE_ESCAPE) + awaitCondition("Files in the split") { rowExists("poster.jpg") } + screenshot("drag-03-files-in-split-screen") + + touchDrag(from = rowCenter("poster.jpg"), to = probeCenter("Drop a put.io file here")) + val report = reports.next() + assertEquals("poster.jpg", report.getStringExtra("name")) + assertEquals("image/jpeg", report.getStringExtra("type")) + assertEquals(poster.size, report.getIntExtra("size", -1)) + assertEquals(sha256(poster), report.getStringExtra("sha256")) + val uri = Uri.parse(report.getStringExtra("uri")) + assertEquals("${context.packageName}.drag", uri.authority) + assertFalse(report.getStringExtra("uri").orEmpty().contains(PROOF_TOKEN)) + assertTrue("the drop's grant", report.getBooleanExtra("granted", false)) + assertFalse("readable before the grant", report.getBooleanExtra("readBeforeRequest", true)) + assertFalse("writable", report.getBooleanExtra("writeWithGrant", true)) + assertFalse("readable after release", report.getBooleanExtra("readAfterRelease", true)) + SystemClock.sleep(SETTLE_MS) + screenshot("drag-04-file-dropped-into-the-other-app") + + touchDrag(from = probeCenter("Harbor film.torrent"), to = hostCenter()) { + compose.waitUntil(TIMEOUT_MS) { tagExists(MOBILE_TRANSFER_DROP_TARGET_TAG) } + screenshot("drag-05-drop-target-shown") + } + awaitCondition("torrent draft") { draft.state.value.torrent != null } + assertEquals("Harbor film.torrent", draft.state.value.torrent?.fileName) + SystemClock.sleep(SETTLE_MS) + screenshot("drag-06-torrent-opened-add-transfer") + + // An open sheet is its own window and takes no drops, so the next drag starts from Transfers. + compose.onNodeWithText("Cancel").performClick() + // A drag registers its windows when it starts; wait until the sheet's window is gone. + awaitCondition("the sheet's window to close") { appWindowCount() == 1 } + SystemClock.sleep(SETTLE_MS) + touchDrag(from = probeCenter("Magnet link"), to = hostCenter()) + // Cancel kept the torrent as a draft, so the drop asks before replacing it, as a share does. + compose.waitUntil(TIMEOUT_MS) { compose.onAllNodes(hasText("Use shared link")).fetchSemanticsNodes().isNotEmpty() } + screenshot("drag-07-replace-the-torrent-draft") + compose.onNodeWithText("Use shared link").performClick() + awaitCondition("magnet draft") { draft.state.value.input.startsWith("magnet:") } + SystemClock.sleep(SETTLE_MS) + screenshot("drag-08-magnet-opened-add-transfer") + } finally { + context.sendBroadcast(Intent(PROBE_FINISH).setPackage(probePackage)) + reports.close() + } + } + + private fun withShell(block: () -> Unit) { + val auth = MutableStateFlow(SignedIn) + MobileFileShareService.dependenciesForTest = MobileShareDependencies( + http = OkHttpClient.Builder().addInterceptor(PosterSource(poster)).build(), + authState = auth, + accessToken = { PROOF_TOKEN }, + ) + val video = File(requireNotNull(arguments.getString("putio.tablet.video"))).canonicalFile + val externalFiles = requireNotNull(context.getExternalFilesDir(null)).canonicalFile + require(video.isFile && video.toPath().startsWith(externalFiles.toPath())) { "Fixture must be under $externalFiles" } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val controller = FilesBrowserController(files, scope) + uiAutomation.serviceInfo = uiAutomation.serviceInfo.apply { + flags = flags or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + } + try { + compose.setContent { + val filesState by controller.state.collectAsState() + PutioTheme { + MobileShell( + transferDraft = draft, + filesState = filesState, + filesRepository = files, + accountSettingsState = readyAccountSettingsState(), + appConfigState = readyAndroidAppConfigState(), + account = Account, + playbackRepository = LocalVideo(video), + playbackPlayerFactory = players, + sessionId = Session, + onFilesEvent = controller::dispatch, + onAccountSettingsEvent = {}, + onPlaybackAuthenticationRequired = {}, + onShareItem = {}, + fileDragOut = MobileFileDragOut(context, MobileSessionKey(Account.userId, Session)), + onSignOut = {}, + ) + } + } + compose.waitUntil(TIMEOUT_MS) { controller.state.value.current.content is FilesContent.Ready } + block() + } finally { + controller.close() + scope.cancel() + MobileFileShareService.dependenciesForTest = null + } + } + + /** Reads the screen's player on its own thread. */ + private fun player(read: (Player) -> T): T? { + var value: T? = null + instrumentation.runOnMainSync { value = players.current?.let(read) } + return value + } + + private fun row(name: String) = + compose.onNode(hasText(name) and hasAnyAncestor(hasTestTag(MOBILE_FILES_LIST_TAG))) + + private fun rowExists(name: String): Boolean = + compose.onAllNodes(hasText(name) and hasAnyAncestor(hasTestTag(MOBILE_FILES_LIST_TAG))) + .fetchSemanticsNodes().isNotEmpty() + + private fun rowFocused(name: String): Boolean = rowExists(name) && runCatching { row(name).assertIsFocused() }.isSuccess + + private fun tagExists(tag: String): Boolean = compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() + + /** Arrows walk the listing to [name]; the order is Shows, Harbor film.mp4, poster.jpg, notes.txt. */ + private fun focusRow(name: String) { + repeat(MAX_TABS) { if (ROOT_ORDER.none(::rowFocused)) press(KeyEvent.KEYCODE_TAB) } + repeat(ROOT_ORDER.size * 2) { + if (rowFocused(name)) return + val at = ROOT_ORDER.indexOfFirst(::rowFocused) + press(if (at < ROOT_ORDER.indexOf(name)) KeyEvent.KEYCODE_DPAD_DOWN else KeyEvent.KEYCODE_DPAD_UP) + } + row(name).assertIsFocused() + } + + private fun press(keyCode: Int, meta: Int = 0, pauseMs: Long = KEY_PAUSE_MS) { + val down = SystemClock.uptimeMillis() + instrumentation.sendKeySync(KeyEvent(down, down, KeyEvent.ACTION_DOWN, keyCode, 0, meta)) + instrumentation.sendKeySync(KeyEvent(down, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, 0, meta)) + compose.waitForIdle() + SystemClock.sleep(pauseMs) + } + + /** A finger held past the long-press timeout, then moved across windows and lifted. */ + private fun touchDrag(from: PointF, to: PointF, beforeRelease: () -> Unit = {}) { + val downTime = SystemClock.uptimeMillis() + inject(MotionEvent.ACTION_DOWN, downTime, from) + SystemClock.sleep(LONG_PRESS_HOLD_MS) + for (step in 1..DRAG_STEPS) { + val fraction = step / DRAG_STEPS.toFloat() + inject(MotionEvent.ACTION_MOVE, downTime, PointF(from.x + (to.x - from.x) * fraction, from.y + (to.y - from.y) * fraction)) + SystemClock.sleep(DRAG_STEP_MS) + } + SystemClock.sleep(SETTLE_MS) + beforeRelease() + inject(MotionEvent.ACTION_UP, downTime, to) + SystemClock.sleep(SETTLE_MS) + } + + private fun tap(point: PointF) { + val downTime = SystemClock.uptimeMillis() + inject(MotionEvent.ACTION_DOWN, downTime, point) + inject(MotionEvent.ACTION_UP, downTime, point) + SystemClock.sleep(SETTLE_MS) + } + + private fun inject(action: Int, downTime: Long, point: PointF) { + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, point.x, point.y, 0) + event.source = InputDevice.SOURCE_TOUCHSCREEN + try { + check(uiAutomation.injectInputEvent(event, true)) { "Injection refused at $point" } + } finally { + event.recycle() + } + } + + private fun rowCenter(name: String): PointF = screenPoint(row(name).fetchSemanticsNode().boundsInWindow.center) + + private fun hostCenter(): PointF { + val origin = IntArray(2).also(compose.activity.window.decorView::getLocationOnScreen) + val view = compose.activity.window.decorView + return PointF(origin[0] + view.width / 2f, origin[1] + view.height / 2f) + } + + private fun screenPoint(offset: androidx.compose.ui.geometry.Offset): PointF { + val origin = IntArray(2).also(compose.activity.window.decorView::getLocationOnScreen) + return PointF(origin[0] + offset.x, origin[1] + offset.y) + } + + /** Android's first-use full-screen hint takes key focus from the player; the proof answers it once. */ + private fun dismissFullScreenHint() { + SystemClock.sleep(SETTLE_MS) + uiAutomation.windows.filter { it.root?.packageName != context.packageName } + .firstNotNullOfOrNull { it.root?.findAccessibilityNodeInfosByText("Got it")?.firstOrNull() } + ?.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK) + SystemClock.sleep(SETTLE_MS) + } + + private fun appWindowCount(): Int { + val dump = uiAutomation.executeShellCommand("dumpsys window windows") + val text = android.os.ParcelFileDescriptor.AutoCloseInputStream(dump).bufferedReader().use { it.readText() } + return Regex("""Window #\d+ Window\{\w+ u0 ${Regex.escape(context.packageName)}/""").findAll(text).count() + } + + private fun probeWindow(): AccessibilityWindowInfo? = + uiAutomation.windows.firstOrNull { it.root?.packageName == probePackage } + + private fun probeCenter(text: String): PointF { + val node = requireNotNull(probeWindow()?.root?.findAccessibilityNodeInfosByText(text)?.firstOrNull()) { + "No \"$text\" in the probe" + } + val bounds = Rect().also(node::getBoundsInScreen) + return PointF(bounds.exactCenterX(), bounds.exactCenterY()) + } + + private fun awaitCondition(label: String, condition: () -> Boolean) { + val deadline = SystemClock.uptimeMillis() + TIMEOUT_MS + while (!condition()) { + check(SystemClock.uptimeMillis() < deadline) { "Timed out waiting for $label" } + compose.waitForIdle() + SystemClock.sleep(POLL_MS) + } + } + + private fun screenshot(label: String) { + compose.waitForIdle() + instrumentation.waitForIdleSync() + uiAutomation.waitForIdle(100, 3_000) + // Holds each state long enough for a screen recording to show it. + SystemClock.sleep(STEP_PAUSE_MS) + val directory = File(requireNotNull(context.getExternalFilesDir(null)), "tablet-proof-${runId()}") + check(directory.mkdirs() || directory.isDirectory) + val bitmap = requireNotNull(uiAutomation.takeScreenshot()) + try { + File(directory, "$label.png").outputStream().use { check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) } + } finally { + bitmap.recycle() + } + } + + /** The probe's reports, which it broadcasts to this app after each drop. */ + private inner class ProbeReports : Closeable { + private val queue = LinkedBlockingQueue() + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + queue += intent + } + } + + init { + context.registerReceiver(receiver, IntentFilter(PROBE_RECEIVED), Context.RECEIVER_EXPORTED) + } + + fun next(): Intent = requireNotNull(queue.poll(TIMEOUT_MS, TimeUnit.MILLISECONDS)) { "The probe reported no drop" } + + override fun close() = context.unregisterReceiver(receiver) + } + + private fun runId(): UUID = UUID.fromString(requireNotNull(arguments.getString("putio.tablet.runId"))) + private val arguments get() = InstrumentationRegistry.getArguments() + private val instrumentation get() = InstrumentationRegistry.getInstrumentation() + private val uiAutomation: UiAutomation get() = instrumentation.uiAutomation + private val context: Context get() = instrumentation.targetContext + private val probePackage: String get() = instrumentation.context.packageName + + private companion object { + const val CTRL = KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON + const val MAX_TABS = 16 + const val KEY_PAUSE_MS = 700L + const val STEP_PAUSE_MS = 1_200L + const val SETTLE_MS = 1_500L + const val LONG_PRESS_HOLD_MS = 1_200L + const val DRAG_STEPS = 40 + const val DRAG_STEP_MS = 20L + const val POLL_MS = 100L + const val TIMEOUT_MS = 20_000L + const val PROOF_TOKEN = "tablet-proof-token" + const val PARTIAL_CONTENT = 206 + const val PROBE_ACTIVITY = "io.putdotio.android.probe.DragProbeActivity" + const val PROBE_RECEIVED = "io.putdotio.android.probe.RECEIVED" + const val PROBE_FINISH = "io.putdotio.android.probe.FINISH" + val Session = MobileAuthSessionId(1L) + val Account = MobileAccount(userId = 1L, username = "tablet", email = "tablet@example.com") + val SignedIn = MobileAuthState.SignedIn(Account, Session) + val SHOWS = item(1, "Shows", PutioFileType.FOLDER) + val FILM = item(2, "Harbor film.mp4", PutioFileType.VIDEO) + val POSTER = item(3, "poster.jpg", PutioFileType.IMAGE) + val NOTES = item(4, "notes.txt", PutioFileType.TEXT) + val EPISODE = item(5, "Episode 1.mp4", PutioFileType.VIDEO).copy(parentId = SHOWS.id) + val ROOT_ORDER = listOf(SHOWS, FILM, POSTER, NOTES).map(FilesItem::name) + + fun item(id: Long, name: String, type: PutioFileType) = + FilesItem(FilesItemId(id), FilesFolder.Root.id, name, type, 48_000_000L, "2026-10-04T12:00:00Z") + + fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + fun posterJpeg(): ByteArray { + val bitmap = Bitmap.createBitmap(960, 540, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + drawColor(Color.rgb(0xFD, 0xCE, 0x45)) + drawText("poster.jpg from put.io", 60f, 290f, Paint().apply { textSize = 64f; color = Color.BLACK }) + } + return ByteArrayOutputStream().use { output -> + check(bitmap.compress(Bitmap.CompressFormat.JPEG, 90, output)) + bitmap.recycle() + output.toByteArray() + } + } + } + + /** Root and one folder; Trash removes an item, and an exact-ID read then reports it gone. */ + private class ProofFiles(posterSize: Long) : FilesRepository { + val loads = AtomicInteger() + private val root = mutableListOf(SHOWS, FILM, POSTER.copy(sizeBytes = posterSize), NOTES) + + @Volatile var deleted: List> = emptyList() + private set + + override suspend fun loadFolder(folderId: FilesItemId): PutioResult { + loads.incrementAndGet() + return PutioResult.Success(FilesPage(if (folderId == SHOWS.id) listOf(EPISODE) else root.toList(), null)) + } + + override suspend fun delete(itemId: FilesItemId, mode: FilesDeleteMode): PutioResult { + deleted = deleted + (itemId to mode) + root.removeAll { it.id == itemId } + return PutioResult.Success(FileDeleteResult(status = "OK")) + } + + override suspend fun resolveItem(itemId: FilesItemId): PutioResult = + (root + EPISODE).firstOrNull { it.id == itemId }?.let { PutioResult.Success(it) } + ?: PutioResult.Failure(notFound(itemId)) + + override suspend fun loadNextPage(cursor: FilesCursor): PutioResult = error("One page") + override suspend fun loadMoveDestinations(folderId: FilesItemId, cursor: FilesCursor?) = error("No move") + override suspend fun persistSort(folderId: FilesItemId, sort: FilesSort) = error("No sort") + override suspend fun rename(itemId: FilesItemId, name: String) = error("No rename") + override suspend fun move(itemId: FilesItemId, destinationId: FilesItemId): PutioResult> = + error("No move") + override suspend fun startCopy(itemId: FilesItemId, destinationId: FilesItemId) = error("No copy") + override suspend fun checkCopy(copyId: FilesCopyId) = error("No copy") + + private fun notFound(itemId: FilesItemId) = PutioApiException( + request = PutioRequestData("GET", "https://api.put.io/v2/files/${itemId.value}"), + resolvedStatusCode = NOT_FOUND, + httpStatusCode = NOT_FOUND, + resolvedErrorType = "NotFound", + envelope = PutioApiErrorEnvelope(statusCode = NOT_FOUND, errorType = "NotFound"), + responseBody = "{}", + message = "Synthetic missing item", + ).toPutioFailure() + + private companion object { + const val NOT_FOUND = 404 + } + } + + /** Resolves every video to the caller's local fixture. */ + private class LocalVideo(file: File) : PlaybackRepository { + // The SDK owns production URLs. This proof reads only its caller-owned local fixture. + private val url = PutioCredentialUrl::class.java.getDeclaredConstructor(String::class.java) + .newInstance(Uri.fromFile(file).toString()) + + override suspend fun resolve(target: PlaybackTarget): PlaybackRepositoryResult = + PlaybackRepositoryResult.Success( + PlaybackResolution.Ready( + PlaybackSource( + fileId = target.fileId.value, + kind = PlaybackSourceKind.ORIGINAL, + url = url, + startFromSeconds = 0.0, + subtitles = PlaybackSubtitles.None, + ), + ), + ) + + override suspend fun findNextVideo(target: PlaybackTarget) = PlaybackNextResult.Ended + } + + /** + * The production renderers on a player that reads local files; the production player streams + * through the download cache's HTTP source. It never attaches to a real audio session. + */ + private class ProofPlayers : MobilePlayerFactory { + val created = AtomicInteger() + + @Volatile var current: Player? = null + private set + + override fun create(context: Context, mediaType: PlaybackMediaType): Player = + ExoPlayer.Builder(context, playbackRenderersFactory(context)) + .setAudioAttributes(mediaType.audioAttributes(), false) + .build() + .also { + created.incrementAndGet() + current = it + } + + override fun connectAudio(context: Context, onResult: (Result) -> Unit): Closeable { + onResult(Result.failure(IllegalStateException("No audio in the tablet proof"))) + return Closeable {} + } + + override fun stopAudio(context: Context) = Unit + } + + /** Serves poster.jpg for the drag-out reads, whole or from a range; the session travels only in the header. */ + private class PosterSource(private val body: ByteArray) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + check(request.header("Authorization") == "Token $PROOF_TOKEN") + check(!request.url.toString().contains(PROOF_TOKEN)) + val start = request.header("Range")?.removePrefix("bytes=")?.substringBefore('-')?.toIntOrNull() ?: 0 + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(PARTIAL_CONTENT) + .message("Partial Content") + .header("Content-Range", "bytes $start-${body.size - 1}/${body.size}") + .body(body.copyOfRange(start, body.size).toResponseBody("image/jpeg".toMediaType())) + .build() + } + } +} diff --git a/mobile/src/debug/AndroidManifest.xml b/mobile/src/debug/AndroidManifest.xml index d347f181..8341e816 100644 --- a/mobile/src/debug/AndroidManifest.xml +++ b/mobile/src/debug/AndroidManifest.xml @@ -12,5 +12,10 @@ android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|assetsPaths" android:exported="false" android:supportsPictureInPicture="true" /> + + diff --git a/mobile/src/debug/kotlin/io/putdotio/android/MobileTabletProofActivity.kt b/mobile/src/debug/kotlin/io/putdotio/android/MobileTabletProofActivity.kt new file mode 100644 index 00000000..9b8fcd4f --- /dev/null +++ b/mobile/src/debug/kotlin/io/putdotio/android/MobileTabletProofActivity.kt @@ -0,0 +1,17 @@ +package io.putdotio.android + +import android.view.KeyboardShortcutGroup +import android.view.Menu +import androidx.activity.ComponentActivity + +/** + * Hosts the tablet proof's production shell over faked repositories. It takes the same configuration + * changes as MainActivity, so split-screen resizing reaches the shell as it does in the app, and it + * fills the Keyboard Shortcuts Helper the same way. + */ +class MobileTabletProofActivity : ComponentActivity() { + override fun onProvideKeyboardShortcuts(data: MutableList, menu: Menu?, deviceId: Int) { + super.onProvideKeyboardShortcuts(data, menu, deviceId) + data += mobileKeyboardShortcutGroups(this) + } +} diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 0131a3be..02a4e7d1 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -29,11 +29,12 @@ android:label="@string/app_name" android:supportsRtl="false" android:theme="@style/Theme.Putio"> - + @@ -118,6 +119,12 @@ + + , menu: Menu?, deviceId: Int) { + super.onProvideKeyboardShortcuts(data, menu, deviceId) + data += mobileKeyboardShortcutGroups(this) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -95,14 +102,7 @@ class MainActivity : BasePutioActivity() { launch.shareLaunchConsumed = true val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 if (consumed || fromHistory) return - when (incoming) { - is MobileIncomingTransfer.Ready -> transferDraft.receive(incoming.transfer) - is MobileIncomingTransfer.Torrent -> { - val resolver = applicationContext.contentResolver - val ownPackage = packageName - transferDraft.receiveLater { resolver.readMobileTorrent(incoming.uri, ownPackage) } - } - } + transferDraft.receive(incoming, applicationContext.contentResolver, packageName) } private fun consumeDeepLink(intent: Intent?, restoredConsumed: Boolean, freshIntent: Boolean = false) { diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileKeyboardShortcuts.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileKeyboardShortcuts.kt new file mode 100644 index 00000000..4ed14712 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileKeyboardShortcuts.kt @@ -0,0 +1,113 @@ +package io.putdotio.android + +import android.content.Context +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.KeyboardShortcutInfo +import android.view.View +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat + +/** What a hardware key asks for once no focused control used it. */ +internal enum class MobileKeyCommand { Back, Search, Refresh, Delete, Open, PlayPause, SeekBack, SeekForward } + +internal enum class MobileShortcutScope(@StringRes val labelRes: Int) { + Files(R.string.mobile_shortcuts_files), + Player(R.string.mobile_shortcuts_player), +} + +internal class MobileKeyboardShortcut( + val scope: MobileShortcutScope, + @StringRes val labelRes: Int, + val keyCode: Int, + val ctrl: Boolean, + val command: MobileKeyCommand, +) + +/** + * The shortcuts the app takes, which also fill the system Keyboard Shortcuts Helper. Android's own + * shortcuts use Meta or Alt, so these take only bare keys and Ctrl. Enter is listed but belongs to the + * focused row, which opens on it like a tap. + */ +internal val MobileKeyboardShortcuts: List = listOf( + files(R.string.mobile_shortcut_open, KeyEvent.KEYCODE_ENTER, MobileKeyCommand.Open), + files(R.string.mobile_shortcut_back, KeyEvent.KEYCODE_DEL, MobileKeyCommand.Back), + files(R.string.mobile_shortcut_back, KeyEvent.KEYCODE_ESCAPE, MobileKeyCommand.Back), + files(R.string.mobile_shortcut_search, KeyEvent.KEYCODE_F, MobileKeyCommand.Search, ctrl = true), + files(R.string.mobile_shortcut_refresh, KeyEvent.KEYCODE_R, MobileKeyCommand.Refresh, ctrl = true), + files(R.string.mobile_shortcut_refresh, KeyEvent.KEYCODE_F5, MobileKeyCommand.Refresh), + files(R.string.mobile_shortcut_delete, KeyEvent.KEYCODE_FORWARD_DEL, MobileKeyCommand.Delete), + player(R.string.mobile_shortcut_play_pause, KeyEvent.KEYCODE_SPACE, MobileKeyCommand.PlayPause), + player(R.string.mobile_shortcut_seek_back, KeyEvent.KEYCODE_DPAD_LEFT, MobileKeyCommand.SeekBack), + player(R.string.mobile_shortcut_seek_forward, KeyEvent.KEYCODE_DPAD_RIGHT, MobileKeyCommand.SeekForward), + player(R.string.mobile_shortcut_back, KeyEvent.KEYCODE_ESCAPE, MobileKeyCommand.Back), + player(R.string.mobile_shortcut_back, KeyEvent.KEYCODE_DEL, MobileKeyCommand.Back), +) + +private fun files(@StringRes label: Int, keyCode: Int, command: MobileKeyCommand, ctrl: Boolean = false) = + MobileKeyboardShortcut(MobileShortcutScope.Files, label, keyCode, ctrl, command) + +private fun player(@StringRes label: Int, keyCode: Int, command: MobileKeyCommand) = + MobileKeyboardShortcut(MobileShortcutScope.Player, label, keyCode, ctrl = false, command) + +/** The command [keyCode] with [metaState] gives in [scope]; Shift, Alt, Meta and any other combination give none. */ +internal fun mobileKeyCommand(scope: MobileShortcutScope, keyCode: Int, metaState: Int): MobileKeyCommand? { + val ctrl = when { + KeyEvent.metaStateHasNoModifiers(metaState) -> false + KeyEvent.metaStateHasModifiers(metaState, KeyEvent.META_CTRL_ON) -> true + else -> return null + } + return MobileKeyboardShortcuts.firstOrNull { + it.scope == scope && it.keyCode == keyCode && it.ctrl == ctrl && it.command != MobileKeyCommand.Open + }?.command +} + +internal fun mobileKeyboardShortcutGroups(context: Context): List = + MobileShortcutScope.entries.map { scope -> + KeyboardShortcutGroup( + context.getString(scope.labelRes), + MobileKeyboardShortcuts.filter { it.scope == scope }.map { + KeyboardShortcutInfo( + context.getString(it.labelRes), + it.keyCode, + if (it.ctrl) KeyEvent.META_CTRL_ON else 0, + ) + }, + ) + } + +/** + * Runs [onCommand] for keys of [scope] that reached the window unused, so a text field keeps Backspace + * and a focused button keeps Enter. Returning true consumes the key, Escape included, which Android + * would otherwise turn into Back and leave the app from its top screen. A held key repeats only when + * [repeats] says so. + */ +@Composable +internal fun MobileUnhandledKeyEffect( + scope: MobileShortcutScope, + enabled: Boolean = true, + repeats: (MobileKeyCommand) -> Boolean = { false }, + onCommand: (MobileKeyCommand) -> Boolean, +) { + val view = LocalView.current + val currentEnabled by rememberUpdatedState(enabled) + val currentRepeats by rememberUpdatedState(repeats) + val currentOnCommand by rememberUpdatedState(onCommand) + DisposableEffect(view, scope) { + val listener = ViewCompat.OnUnhandledKeyEventListenerCompat { _: View, event: KeyEvent -> + val command = mobileKeyCommand(scope, event.keyCode, event.metaState) + when { + !currentEnabled || command == null || event.action != KeyEvent.ACTION_DOWN -> false + event.repeatCount > 0 && !currentRepeats(command) -> true + else -> currentOnCommand(command) + } + } + ViewCompat.addOnUnhandledKeyEventListener(view, listener) + onDispose { ViewCompat.removeOnUnhandledKeyEventListener(view, listener) } + } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt index 618a2309..f1942098 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileNavigation.kt @@ -61,6 +61,7 @@ import io.putdotio.android.settings.AppDiagnostics import io.putdotio.android.settings.TunnelRouteOption import io.putdotio.android.settings.confirmedShowSubtitles import io.putdotio.android.settings.confirmedTrashEnabled +import io.putdotio.android.share.MobileFileDragOut import io.putdotio.android.sharing.MOBILE_PUBLIC_LINKS_ROUTE import io.putdotio.android.sharing.MobilePublicLinks import io.putdotio.android.sharing.MobilePublicLinksScreen @@ -106,6 +107,9 @@ internal fun MobileNavHost( modifier: Modifier = Modifier, deviceClass: AppDiagnostics.DeviceClass = AppDiagnostics.DeviceClass.Phone, publicLinks: MobilePublicLinks? = null, + focusSearch: Boolean = false, + onSearchFocused: () -> Unit = {}, + fileDragOut: MobileFileDragOut? = null, ) { val currentTransfersState by rememberUpdatedState(transfersState) val currentTransfersSessionId by rememberUpdatedState(transfersSessionId) @@ -137,6 +141,7 @@ internal fun MobileNavHost( onViewTrash = trashController?.let { { navController.navigateToTrash() } }, moveTargetStore = moveTargetStore, publicLinks = publicLinks, + fileDragOut = fileDragOut, ) } composable(MobileDestination.Search.route) { @@ -153,6 +158,8 @@ internal fun MobileNavHost( onRecentEdit = searchHistoryActions.onRecentEdit, onRecentRetry = searchHistoryActions.onRecentRetry, onHistoryEvent = searchHistoryActions.onHistoryEvent, + focusSearch = focusSearch, + onSearchFocused = onSearchFocused, ) } composable(MobileDestination.Transfers.route) { diff --git a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt index a5c44d5c..0732cc4c 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/MobileShell.kt @@ -1,6 +1,7 @@ package io.putdotio.android import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.StringRes import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -87,8 +88,10 @@ import io.putdotio.android.settings.TunnelRouteOption import io.putdotio.android.settings.confirmedDefaultSort import io.putdotio.android.sharing.MOBILE_PUBLIC_LINKS_ROUTE import io.putdotio.android.sharing.MobilePublicLinks +import io.putdotio.android.share.MobileFileDragOut import io.putdotio.android.sharing.PublicLinksController import io.putdotio.android.transfers.MobileTransferDraft +import io.putdotio.android.transfers.MobileTransferDropZone import io.putdotio.android.transfers.TransferFileId import io.putdotio.android.transfers.TransferMutation import io.putdotio.android.transfers.TransferNavigation @@ -152,6 +155,7 @@ internal fun MobileShell( onDismissNavigationFailure: () -> Unit = {}, onSignOut: () -> Unit, transferDraft: MobileTransferDraft = remember { MobileTransferDraft() }, + fileDragOut: MobileFileDragOut? = null, ) { val navController = rememberNavController() var rejectedNavigation by remember(sessionId) { mutableStateOf(null) } @@ -201,6 +205,14 @@ internal fun MobileShell( BackHandler(enabled = isPlayback) { navController.popBackStack() } + var searchFocusPending by remember { mutableStateOf(false) } + MobileShellKeyboardEffect( + searchEnabled = !isPlayback, + onSearch = { + navController.navigateTo(MobileDestination.Search) + searchFocusPending = true + }, + ) val playbackFileId = if (isPlayback) backStackEntry?.arguments?.getLong("fileId") else null val resolvingTransfer = transfersState.navigation as? TransferNavigation.Resolving @@ -239,58 +251,67 @@ internal fun MobileShell( // One NavHost call site in one slot: playback only hides the chrome, so destinations keep saved state. key(transfersSessionId) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val navigationLayout = mobileNavigationLayout(maxWidth, maxHeight) - MobileNavigationContainer( - layout = navigationLayout, - selectedDestination = selectedDestination, - onDestination = { navController.navigateTo(it) }, - enabled = !isPlayback, - ) { openNavigation -> - MobileChrome( - visible = !isPlayback, + MobileTransferDropZone( + enabled = !isPlayback, + draft = transferDraft, + onOwnDragEnded = { drag, dropped -> fileDragOut?.ended(drag, dropped) }, + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val navigationLayout = mobileNavigationLayout(maxWidth, maxHeight) + MobileNavigationContainer( layout = navigationLayout, - openNavigation = openNavigation, - navController = navController, selectedDestination = selectedDestination, - filesState = filesState, - playbackPlayerFactory = playbackPlayerFactory, - onFilesEvent = { onFilesEvent(it) }, - onFilesBack = onFilesBack, - inactiveNotice = account.inactiveNotice, - ) { contentModifier -> - MobileNavHost( - transferDraft = transferDraft, + onDestination = { navController.navigateTo(it) }, + enabled = !isPlayback, + ) { openNavigation -> + MobileChrome( + visible = !isPlayback, + layout = navigationLayout, + openNavigation = openNavigation, navController = navController, + selectedDestination = selectedDestination, filesState = filesState, - filesRepository = filesRepository, - trashController = trashController, - downloadsController = downloadsController, - downloadsState = downloadsState, - onShareItem = onShareItem, - accountSettingsState = accountSettingsState, - appConfigState = appConfigState, - searchHistoryState = searchHistoryState, - transfersState = transfersState, - transfersSessionId = transfersSessionId, - account = account, - playbackRepository = playbackRepository, playbackPlayerFactory = playbackPlayerFactory, - sessionId = sessionId, onFilesEvent = { onFilesEvent(it) }, - onAccountSettingsEvent = onAccountSettingsEvent, - loadTunnelRoutes = loadTunnelRoutes, - deviceClass = if (maxWidth >= 600.dp) AppDiagnostics.DeviceClass.Tablet - else AppDiagnostics.DeviceClass.Phone, - onAppConfigEvent = onAppConfigEvent, - onPlaybackAuthenticationRequired = onPlaybackAuthenticationRequired, - onFilesAuthenticationRequired = onFilesAuthenticationRequired, - searchHistoryActions = searchHistoryActions, - onTransfersEvent = onTransfersEvent, - onSignOut = onSignOut, - modifier = contentModifier, - publicLinks = publicLinks, - ) + onFilesBack = onFilesBack, + inactiveNotice = account.inactiveNotice, + ) { contentModifier -> + MobileNavHost( + transferDraft = transferDraft, + navController = navController, + filesState = filesState, + filesRepository = filesRepository, + trashController = trashController, + downloadsController = downloadsController, + downloadsState = downloadsState, + onShareItem = onShareItem, + accountSettingsState = accountSettingsState, + appConfigState = appConfigState, + searchHistoryState = searchHistoryState, + transfersState = transfersState, + transfersSessionId = transfersSessionId, + account = account, + playbackRepository = playbackRepository, + playbackPlayerFactory = playbackPlayerFactory, + sessionId = sessionId, + onFilesEvent = { onFilesEvent(it) }, + onAccountSettingsEvent = onAccountSettingsEvent, + loadTunnelRoutes = loadTunnelRoutes, + deviceClass = if (maxWidth >= 600.dp) AppDiagnostics.DeviceClass.Tablet + else AppDiagnostics.DeviceClass.Phone, + onAppConfigEvent = onAppConfigEvent, + onPlaybackAuthenticationRequired = onPlaybackAuthenticationRequired, + onFilesAuthenticationRequired = onFilesAuthenticationRequired, + searchHistoryActions = searchHistoryActions, + onTransfersEvent = onTransfersEvent, + onSignOut = onSignOut, + modifier = contentModifier, + publicLinks = publicLinks, + focusSearch = searchFocusPending, + onSearchFocused = { searchFocusPending = false }, + fileDragOut = fileDragOut, + ) + } } } } @@ -307,6 +328,26 @@ internal fun MobileShell( } } +/** + * Backspace and Escape go up a folder or back wherever no control used them, through the same Back + * the system gesture takes; with nothing left to close they do nothing rather than leave the app. + * Ctrl+F opens Search and puts the cursor in its field. + */ +@Composable +private fun MobileShellKeyboardEffect(searchEnabled: Boolean, onSearch: () -> Unit) { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + MobileUnhandledKeyEffect(MobileShortcutScope.Files) { command -> + when (command) { + MobileKeyCommand.Back -> { + backDispatcher?.takeIf { it.hasEnabledCallbacks() }?.onBackPressed() + true + } + MobileKeyCommand.Search -> searchEnabled.also { if (it) onSearch() } + else -> false + } + } +} + @Composable private fun TrashRestoreEffects( trashState: TrashState?, diff --git a/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt b/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt index 08d7057a..f9b6251e 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/SignedInMobileRoot.kt @@ -14,6 +14,7 @@ import io.putdotio.android.account.MobileAndroidAppConfigViewModel import io.putdotio.android.auth.MobileAuthController import io.putdotio.android.auth.MobileAuthSessionId import io.putdotio.android.auth.MobileAuthState +import io.putdotio.android.auth.MobileSessionKey import io.putdotio.android.auth.MobileOAuthRuntime import io.putdotio.android.downloads.DownloadsController import io.putdotio.android.downloads.DownloadsEvent @@ -52,6 +53,7 @@ import io.putdotio.android.settings.authoritativeSessionFailure import io.putdotio.android.settings.confirmedHistoryEnabled import io.putdotio.android.settings.confirmedResumePlayback import io.putdotio.android.settings.putioFailure +import io.putdotio.android.share.MobileFileDragOut import io.putdotio.android.share.MobileFileShareService import io.putdotio.android.sharing.MobilePublicLinksViewModel import io.putdotio.android.sharing.PublicLinksController @@ -303,6 +305,9 @@ private fun SignedInMobileSession( deepLinkRequests = deepLinkRequests, onOpenFile = searchHistorySession::openFile, onShareItem = { item -> MobileFileShareService.start(appContext, item.id, item.name) }, + fileDragOut = remember(appContext, account.userId, sessionId) { + MobileFileDragOut(appContext, MobileSessionKey(account.userId, sessionId)) + }, downloadsController = downloadsController, publicLinksController = publicLinksController, sessionId = sessionId, 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..3b665364 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/auth/MobileOAuthRuntime.kt @@ -14,6 +14,7 @@ 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.MobileFileDrags import io.putdotio.android.share.MobileFileShareService import io.putdotio.sdk.PutioClient import io.putdotio.sdk.PutioConfig @@ -190,6 +191,7 @@ class MobileOAuthRuntime internal constructor( applicationScope = applicationScope, onSessionLeft = { session -> MobileFileShareService.endSession(context, session) + MobileFileDrags.endSession(session) MobileDocumentsProvider.sessionLeft(context) }, onSessionStarted = { MobileDocumentsProvider.sessionStarted(context) }, diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt index 4bb9d3c3..4118fde5 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesActions.kt @@ -425,7 +425,43 @@ private fun FilesRenameCompletion?.confirms( this?.intent == FilesFolderOperationIntent.Rename(itemId, submittedName) && requestId.value != previousRequestValue -private fun confirmedDeleteMode(trashEnabled: Boolean?): FilesDeleteMode? = +/** + * The Delete key on a focused row, acting as the sheet's delete row would: Move to trash goes at once, + * Delete permanently confirms first. [mode] is the setting when the key was pressed; if the account's + * Trash setting has changed since, nothing is sent. + */ +@Composable +internal fun MobileFilesKeyDelete( + item: FilesItem, + folderId: FilesItemId, + mode: FilesDeleteMode, + confirmedTrashEnabled: Boolean?, + operation: FilesFolderOperation, + onEvent: (FilesBrowserEvent) -> Unit, + onDismiss: () -> Unit, +) { + var submitted by rememberSaveable { mutableStateOf(false) } + val settingMatches = confirmedDeleteMode(confirmedTrashEnabled) == mode + val submit = { + if (!submitted && settingMatches && operation.canStartOperation) { + submitted = true + onEvent(FilesBrowserEvent.Delete(folderId, item.id, mode)) + } + onDismiss() + } + if (mode == FilesDeleteMode.PERMANENT && settingMatches) { + MobileFilesDeleteConfirmation( + item = item, + enabled = !submitted && operation.canStartOperation, + onDismiss = onDismiss, + onConfirm = submit, + ) + } else { + SideEffect(submit) + } +} + +internal fun confirmedDeleteMode(trashEnabled: Boolean?): FilesDeleteMode? = when (trashEnabled) { true -> FilesDeleteMode.TRASH false -> FilesDeleteMode.PERMANENT diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDragSource.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDragSource.kt new file mode 100644 index 00000000..9a039739 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesDragSource.kt @@ -0,0 +1,41 @@ +package io.putdotio.android.files + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.pointerInput + +/** + * Starts a drag out of a row: with a mouse or touchpad as soon as the pressed pointer moves, with + * touch or a stylus only when it moves after a long press, as Android's drag-and-drop guidance asks. + * It only watches the pointer, so taps, the long-press sheet and scrolling work as before: a touch + * that moves before the long press is a scroll and never drags. The gesture is set up once, so + * [onDragStart] must read anything that changes through state. + */ +internal fun Modifier.mobileFileDragSource(enabled: Boolean, onDragStart: () -> Unit): Modifier = + if (!enabled) { + this + } else { + pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val mouse = down.type == PointerType.Mouse + if (mouse && !currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val armedAt = down.uptimeMillis + if (mouse) 0L else viewConfiguration.longPressTimeoutMillis + var change = down + // Follow the press until it lifts or leaves the touch slop; only then can it be a drag. + do { + change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } + ?: return@awaitEachGesture + val moved = (change.position - down.position).getDistance() > viewConfiguration.touchSlop + } while (change.pressed && !moved) + if (change.pressed && change.uptimeMillis >= armedAt) { + change.consume() + onDragStart() + } + } + } + } diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt index 30dda16a..545eb052 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesRoute.kt @@ -17,6 +17,7 @@ import io.putdotio.android.AuthoritativeSessionFailureEffect import io.putdotio.android.authoritativeSessionFailure import io.putdotio.android.downloads.DownloadsState import io.putdotio.android.playback.dispatch +import io.putdotio.android.share.MobileFileDragOut import io.putdotio.android.sharing.MobilePublicLinkSheet import io.putdotio.android.sharing.MobilePublicLinks @@ -34,6 +35,7 @@ internal fun MobileFilesRoute( onViewTrash: (() -> Unit)? = null, moveTargetStore: FilesMoveTargetStore? = null, publicLinks: MobilePublicLinks? = null, + fileDragOut: MobileFileDragOut? = null, ) { key(repository, state.current.folder.id.value) { var movingItemId by rememberSaveable { mutableStateOf(null) } @@ -58,6 +60,7 @@ internal fun MobileFilesRoute( onViewTrash = onViewTrash, onCopyItem = if (repository == null) null else { item -> copyingItemId = item.id.value }, onPublicLinkItem = publicLinks?.let { { item -> publicLinkItemId = item.id.value } }, + fileDragOut = fileDragOut, ) if (movingItem != null && repository != null) { MobileFilesMoveSession( diff --git a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt index 3e6c839e..436c583f 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/files/MobileFilesScreen.kt @@ -4,6 +4,7 @@ import android.content.Context import android.text.format.DateUtils import android.text.format.Formatter import androidx.annotation.StringRes +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -17,7 +18,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -38,17 +39,30 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.InputMode import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -68,7 +82,10 @@ import io.putdotio.android.LoadNextPageNearEnd import io.putdotio.android.LoadNextPageNow import io.putdotio.android.MobileEmptyState import io.putdotio.android.MobileErrorState +import io.putdotio.android.MobileKeyCommand import io.putdotio.android.MobileLoadingState +import io.putdotio.android.MobileShortcutScope +import io.putdotio.android.MobileUnhandledKeyEffect import io.putdotio.android.PutioFailure import io.putdotio.android.R import io.putdotio.android.apiReason @@ -77,6 +94,8 @@ import io.putdotio.android.downloads.DownloadPauseReason import io.putdotio.android.downloads.DownloadStatus import io.putdotio.android.downloads.DownloadsState import io.putdotio.android.parsePutioTimestamp +import io.putdotio.android.share.MobileFileDragOut +import io.putdotio.android.share.MobileFileDragShadow import kotlin.math.roundToInt import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -104,6 +123,7 @@ internal fun MobileFilesScreen( onViewTrash: (() -> Unit)? = null, onCopyItem: ((FilesItem) -> Unit)? = null, onPublicLinkItem: ((FilesItem) -> Unit)? = null, + fileDragOut: MobileFileDragOut? = null, ) { val current = state.current when (val content = current.content) { @@ -130,6 +150,7 @@ internal fun MobileFilesScreen( onViewTrash, onCopyItem, onPublicLinkItem, + fileDragOut, ) } } @@ -166,32 +187,65 @@ private fun MobileRefreshableFilesContent( onViewTrash: (() -> Unit)? = null, onCopyItem: ((FilesItem) -> Unit)? = null, onPublicLinkItem: ((FilesItem) -> Unit)? = null, + fileDragOut: MobileFileDragOut? = null, ) { val operation = state.current.operation val currentOperation by rememberUpdatedState(operation) var selectedItemId by rememberSaveable { mutableStateOf(null) } - val selectedItem = (content as? FilesContent.Ready)?.items?.firstOrNull { it.id.value == selectedItemId } + // Delete on a focused row goes straight to the sheet's delete step, in the mode the setting had then. + var keyDelete by rememberSaveable { mutableStateOf(null) } + var focusedItemId by remember { mutableStateOf(null) } + val items = (content as? FilesContent.Ready)?.items.orEmpty() + val selectedItem = items.firstOrNull { it.id.value == selectedItemId } LaunchedEffect(selectedItemId, selectedItem) { if (selectedItem == null) selectedItemId = null } + MobileFilesKeyboardEffect( + selectionOpen = selectedItemId != null, + operation = operation, + focusedItem = items.firstOrNull { it.id.value == focusedItemId }, + confirmedTrashEnabled = confirmedTrashEnabled, + onRefresh = { onEvent(FilesBrowserEvent.Refresh) }, + onDelete = { item, mode -> + keyDelete = mode + selectedItemId = item.id.value + }, + ) + val dismissSelection = { + selectedItemId = null + keyDelete = null + } if (selectedItem != null) { key(selectedItem.id.value) { - MobileFilesActions( - item = selectedItem, - folderId = state.current.folder.id, - operation = operation, - renameCompletion = state.current.renameCompletion, - onEvent = onEvent, - onDismiss = { selectedItemId = null }, - confirmedTrashEnabled = confirmedTrashEnabled, - onMoveItem = onMoveItem, - downloadStatus = downloads.entry(selectedItem.id)?.status, - onDownloadItem = onDownloadItem, - onShareItem = onShareItem, - onCopyItem = onCopyItem, - canStartCopy = state.canStartCopy, - onPublicLinkItem = onPublicLinkItem, - ) + val deleteMode = keyDelete + if (deleteMode != null) { + MobileFilesKeyDelete( + item = selectedItem, + folderId = state.current.folder.id, + mode = deleteMode, + confirmedTrashEnabled = confirmedTrashEnabled, + operation = operation, + onEvent = onEvent, + onDismiss = dismissSelection, + ) + } else { + MobileFilesActions( + item = selectedItem, + folderId = state.current.folder.id, + operation = operation, + renameCompletion = state.current.renameCompletion, + onEvent = onEvent, + onDismiss = dismissSelection, + confirmedTrashEnabled = confirmedTrashEnabled, + onMoveItem = onMoveItem, + downloadStatus = downloads.entry(selectedItem.id)?.status, + onDownloadItem = onDownloadItem, + onShareItem = onShareItem, + onCopyItem = onCopyItem, + canStartCopy = state.canStartCopy, + onPublicLinkItem = onPublicLinkItem, + ) + } } } val isRefreshing = @@ -250,6 +304,12 @@ private fun MobileRefreshableFilesContent( }, operation = operation, downloads = downloads, + onItemFocusChanged = { id, focused -> + focusedItemId = focusedItemId.afterFocusChange(id.value, focused) + }, + fileDragOut = fileDragOut, + // A touch drag starts after the long press opened the sheet; the drag replaces it. + onDragStarted = dismissSelection, ) } @@ -269,6 +329,41 @@ private fun MobileRefreshableFilesContent( } } +/** + * F5 or Ctrl+R refreshes as pull-to-refresh does, and Delete acts on the focused row as its sheet's + * delete row would; neither runs while a sheet is open or an operation is under way. Delete waits for + * a known Trash setting by opening the sheet, which says why it cannot delete yet. + */ +@Composable +private fun MobileFilesKeyboardEffect( + selectionOpen: Boolean, + operation: FilesFolderOperation, + focusedItem: FilesItem?, + confirmedTrashEnabled: Boolean?, + onRefresh: () -> Unit, + onDelete: (FilesItem, FilesDeleteMode?) -> Unit, +) { + val idle = !selectionOpen && operation.canStartOperation + MobileUnhandledKeyEffect(MobileShortcutScope.Files) { command -> + when (command) { + MobileKeyCommand.Refresh -> true.also { if (idle) onRefresh() } + MobileKeyCommand.Delete -> { + val item = focusedItem?.takeIf { idle && it.acceptsOwnerActions && it.id.value > 0L } + item?.let { onDelete(it, confirmedDeleteMode(confirmedTrashEnabled)) } + item != null + } + else -> false + } + } +} + +private fun T?.afterFocusChange(itemId: T, focused: Boolean): T? = + when { + focused -> itemId + this == itemId -> null + else -> this + } + @Composable private fun MobileFilesFolderStatus( state: FilesBrowserState, @@ -526,12 +621,16 @@ private fun MobileFilesList( operation: FilesFolderOperation, modifier: Modifier = Modifier, downloads: DownloadsState = DownloadsState(), + onItemFocusChanged: (FilesItemId, Boolean) -> Unit = { _, _ -> }, + fileDragOut: MobileFileDragOut? = null, + onDragStarted: () -> Unit = {}, ) { val viewport = content.viewport val listState = rememberLazyListState( initialFirstVisibleItemIndex = viewport.firstVisibleItemIndex, initialFirstVisibleItemScrollOffset = viewport.firstVisibleItemScrollOffset, ) + val keyboardFocus = rememberFilesKeyboardFocus(content.items, viewport.firstVisibleItemIndex) val currentOnEvent by rememberUpdatedState(onEvent) val pendingItemId = operation.pendingItemId() val actionsEnabled = operation.canStartOperation @@ -567,34 +666,47 @@ private fun MobileFilesList( .fillMaxSize() .testTag(MOBILE_FILES_LIST_TAG), ) { - items( + itemsIndexed( items = content.items, - key = { it.id.value }, - ) { item -> - MobileFilesRow( + key = { _, item -> item.id.value }, + ) { index, item -> + MobileFilesListRow( item = item, - highlighted = item.id == revealItemId, - downloadStatus = downloads.rowStatus(item.id), - onActions = if (hasActions(item)) { { onActions(item) } } else null, - actionsEnabled = actionsEnabled, - onClick = when { - item.id == pendingItemId -> null - item.isFolder -> { - { onEvent(FilesBrowserEvent.OpenFolder(item.id)) } - } + takeEntryFocus = keyboardFocus.takesEntry(index), + onEntryFocused = keyboardFocus::entered, + fileDragOut = fileDragOut, + onDragStarted = onDragStarted, + onFocusChanged = { focused -> + keyboardFocus.onRowFocus(item.id, index, focused) + onItemFocusChanged(item.id, focused) + }, + ) { rowModifier -> + MobileFilesRow( + item = item, + modifier = rowModifier, + highlighted = item.id == revealItemId, + downloadStatus = downloads.rowStatus(item.id), + onActions = if (hasActions(item)) { { onActions(item) } } else null, + actionsEnabled = actionsEnabled, + onClick = when { + item.id == pendingItemId -> null + item.isFolder -> { + { onEvent(FilesBrowserEvent.OpenFolder(item.id)) } + } - item.isPlayable -> { - { onPlayMedia(item) } - } + item.isPlayable -> { + { onPlayMedia(item) } + } - else -> null - }, - onClickLabel = when { - item.isFolder -> stringResource(R.string.mobile_files_open_folder, item.name) - item.isPlayable -> stringResource(R.string.mobile_files_play_media, item.name) - else -> null - }, - ) + else -> null + }, + onClickLabel = when { + item.isFolder -> stringResource(R.string.mobile_files_open_folder, item.name) + item.isPlayable -> stringResource(R.string.mobile_files_play_media, item.name) + else -> null + }, + ) + } HorizontalDivider(modifier = Modifier.padding(start = FILES_DIVIDER_INSET)) } if (content.paging != FilesPaging.Complete) { @@ -610,6 +722,104 @@ private fun MobileFilesList( } } +/** + * Where keyboard focus goes in a listing. A folder reached from the keyboard focuses the row last + * focused there, such as the video that just played, else its first visible row. When the row that + * last held focus leaves the listing (moved to Trash, say) and no row holds focus, focus goes to the + * row now in its place; focus the viewer moved elsewhere, to the navigation rail say, stays there. + */ +@Stable +internal class FilesKeyboardFocus( + entryIndex: Int, + entryPending: Boolean, + private val lastFocusedId: MutableState, +) { + private var entryIndex by mutableIntStateOf(entryIndex) + private var entryPending by mutableStateOf(entryPending) + private var focusedRow: FilesItemId? = null + private var lastFocusedIndex: Int? = null + + fun takesEntry(index: Int): Boolean = entryPending && index == entryIndex + + fun entered() { + entryPending = false + } + + fun onRowFocus(id: FilesItemId, index: Int, focused: Boolean) { + if (focused) { + lastFocusedIndex = index + lastFocusedId.value = id.value + } + focusedRow = focusedRow.afterFocusChange(id, focused) + } + + fun afterListingChange(keyboardInput: Boolean, items: List) { + val index = lastFocusedIndex?.takeIf { keyboardInput && items.isNotEmpty() } ?: return + val left = items.none { it.id.value == lastFocusedId.value } + // A removed row reports no focus change, so a focused row no longer listed has lost it too. + val rowHoldsFocus = focusedRow?.let { row -> items.any { it.id == row } } == true + if (left && !rowHoldsFocus) { + focusedRow = null + entryIndex = index.coerceAtMost(items.lastIndex) + entryPending = true + } + } +} + +@Composable +private fun rememberFilesKeyboardFocus(items: List, firstVisibleIndex: Int): FilesKeyboardFocus { + val keyboardInput = LocalInputModeManager.current.inputMode == InputMode.Keyboard + val lastFocusedId = rememberSaveable { mutableStateOf(null) } + val focus = remember { + val entry = items.indexOfFirst { it.id.value == lastFocusedId.value }.takeIf { it >= 0 } ?: firstVisibleIndex + FilesKeyboardFocus(entry, keyboardInput, lastFocusedId) + } + LaunchedEffect(items) { + // Focus moves and drops as rows leave during this frame; decide once it has settled. + withFrameNanos {} + focus.afterListingChange(keyboardInput, items) + } + return focus +} + +/** + * A row's keyboard and pointer extras: the focus a folder entered from the keyboard puts on it, focus + * reporting for the Delete key, and dragging a file out of the app. + */ +@Composable +private fun MobileFilesListRow( + item: FilesItem, + takeEntryFocus: Boolean, + onEntryFocused: () -> Unit, + fileDragOut: MobileFileDragOut?, + onDragStarted: () -> Unit, + onFocusChanged: (Boolean) -> Unit, + row: @Composable (Modifier) -> Unit, +) { + val entryFocus = if (takeEntryFocus) remember { FocusRequester() } else null + entryFocus?.let { requester -> + LaunchedEffect(requester) { + requester.requestFocus() + onEntryFocused() + } + } + val view = LocalView.current + val density = LocalDensity.current + val shadowBackground = MaterialTheme.colorScheme.inverseSurface.toArgb() + val shadowForeground = MaterialTheme.colorScheme.inverseOnSurface.toArgb() + val startDrag = rememberUpdatedState { + val shadow = + MobileFileDragShadow(item.name, density.density, density.fontScale, shadowBackground, shadowForeground) + if (fileDragOut?.start(view, item, shadow) == true) onDragStarted() + } + row( + Modifier + .then(entryFocus?.let { Modifier.focusRequester(it) } ?: Modifier) + .onFocusChanged { onFocusChanged(it.hasFocus) } + .mobileFileDragSource(fileDragOut != null && !item.isFolder && item.id.value > 0L) { startDrag.value() }, + ) +} + private fun FilesFolderOperation.pendingItemId(): FilesItemId? { val pendingIntent = when (this) { is FilesFolderOperation.Loading -> intent.takeIf { @@ -644,6 +854,9 @@ internal fun MobileFilesRow( ) { val metadata = formatFilesItemMetadata(LocalContext.current, item) val actionsLabel = stringResource(R.string.mobile_files_actions, item.name) + // Touch never focuses a row; a keyboard-focused one is outlined so it reads at a glance. + var focused by remember { mutableStateOf(false) } + val outline = MaterialTheme.colorScheme.primary val interaction = if (onActions != null && actionsEnabled) { Modifier.combinedClickable( onClickLabel = onClickLabel, @@ -672,6 +885,8 @@ internal fun MobileFilesRow( }, modifier = modifier .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .focusOutline(focused, outline) .then(interaction) .semantics { if (highlighted) selected = true }, colors = if (highlighted) { @@ -857,6 +1072,9 @@ private fun MobileFilesPaging( } } +private fun Modifier.focusOutline(focused: Boolean, color: Color): Modifier = + if (focused) border(FILES_FOCUS_OUTLINE, color) else this + private fun formatFilesItemMetadata( context: Context, item: FilesItem, @@ -913,4 +1131,5 @@ private val FILES_ICON_SIZE = 24.dp private val FILES_PROGRESS_SPACING = 6.dp private val FILES_PROGRESS_HEIGHT = 4.dp private val FILES_BADGE_ICON_SIZE = 16.dp +private val FILES_FOCUS_OUTLINE = 2.dp private const val PERCENT_SCALE = 100f diff --git a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt index 85c5a3b9..f58d39d6 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/playback/MobilePlayerScreen.kt @@ -43,7 +43,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned @@ -71,6 +75,9 @@ import androidx.media3.common.util.Util import androidx.media3.ui.compose.ContentFrame import io.putdotio.android.MobileEmptyState import io.putdotio.android.MobileErrorState +import io.putdotio.android.MobileKeyCommand +import io.putdotio.android.MobileShortcutScope +import io.putdotio.android.mobileKeyCommand import io.putdotio.android.MobileLoadingState import io.putdotio.android.PutioFailure import io.putdotio.android.R @@ -683,8 +690,11 @@ private fun PlayerLifecycleEffects( LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { // Entering picture-in-picture pauses the Activity and the video plays on in the window. The // Activity's own flag is already set when the pause arrives; mode listeners hear it later. + // A paused split-screen or freeform window is still on screen too; Android's multi-window + // guidance keeps video going until the window stops. val enteringPictureInPicture = pictureInPicture.value || activity?.isInPictureInPictureMode == true - if (playerState.ownsPlayer && !playerState.playerReleased && !enteringPictureInPicture) { + val stillVisible = enteringPictureInPicture || activity?.isInMultiWindowMode == true + if (playerState.ownsPlayer && !playerState.playerReleased && !stillVisible) { val update = playerRetentionUpdate( event = PlayerRetentionEvent.LifecyclePause, @@ -758,9 +768,14 @@ private fun PlayerListenerEffect( pictureInPicture: State, ) { val lifecycle = LocalLifecycleOwner.current.lifecycle + val activity = LocalActivity.current val player = playerState.player DisposableEffect(player) { - fun lifecycleState() = lifecycle.currentState.withPictureInPicture(pictureInPicture.value) + // A started split-screen or freeform window is on screen as a picture-in-picture one is, so a pause + // or play made there, from a headset say, is what the next resume keeps. + fun lifecycleState() = lifecycle.currentState.withPictureInPicture( + pictureInPicture.value || activity?.isInMultiWindowMode == true, + ) // A picked track is found again in each new track list, and automatic subtitles find the // account's default. The pick is read live: tracks can change before recomposition // passes it back as retainedSubtitleSelection. @@ -940,10 +955,19 @@ private fun MobileReadyPlayerContent( currentOnPositionChanged.value(request.targetPositionMillis) player.seekTo(request.targetPositionMillis) } + // The player holds keyboard focus itself, so Space and the arrows reach it before any control is + // focused; Tab still walks the controls, and a focused control keeps the keys it uses. + val keyFocus = remember { FocusRequester() } + LaunchedEffect(keyFocus) { keyFocus.requestFocus() } Box( Modifier .fillMaxSize() + .onKeyEvent { event -> + playerKeyCommand(event.nativeKeyEvent) { command -> player.runKeyCommand(command, ::seek, onBack) } + } + .focusRequester(keyFocus) + .focusTarget() .focusGroup() .testTag(MOBILE_PLAYER_TAG) .observePlayerControlInteraction( @@ -1029,6 +1053,29 @@ private fun MobileReadyPlayerContent( } } +/** + * Space plays or pauses, Left and Right seek like the 10-second buttons, and Escape or Backspace + * leave. A held arrow keeps seeking; a held Space or Escape acts once. The release of a taken key is + * taken too. + */ +internal fun playerKeyCommand(event: KeyEvent, run: (MobileKeyCommand) -> Unit): Boolean { + val command = mobileKeyCommand(MobileShortcutScope.Player, event.keyCode, event.metaState) ?: return false + val repeats = command == MobileKeyCommand.SeekBack || command == MobileKeyCommand.SeekForward + if (event.action == KeyEvent.ACTION_DOWN && (event.repeatCount == 0 || repeats)) run(command) + return true +} + +@UnstableApi +private fun Media3Player.runKeyCommand(command: MobileKeyCommand, seek: (SeekDirection) -> Unit, onBack: () -> Unit) { + when (command) { + MobileKeyCommand.PlayPause -> Util.handlePlayPauseButtonAction(this) + MobileKeyCommand.SeekBack -> seek(SeekDirection.Backward) + MobileKeyCommand.SeekForward -> seek(SeekDirection.Forward) + MobileKeyCommand.Back -> onBack() + else -> Unit + } +} + @UnstableApi @Composable private fun BoxScope.MobileVideoLayers( diff --git a/mobile/src/main/kotlin/io/putdotio/android/search/MobileSearchHistoryScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/search/MobileSearchHistoryScreen.kt index 2fd409a3..ca8c2f05 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/search/MobileSearchHistoryScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/search/MobileSearchHistoryScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -37,6 +38,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -87,8 +90,12 @@ internal fun MobileSearchHistoryScreen( onRecentRetry: () -> Unit, onHistoryEvent: (HistoryEvent) -> Unit, modifier: Modifier = Modifier, + focusSearch: Boolean = false, + onSearchFocused: () -> Unit = {}, ) { var selectedTab by rememberSaveable { mutableIntStateOf(SEARCH_TAB) } + // Ctrl+F lands on the Search tab even when History was open. + LaunchedEffect(focusSearch) { if (focusSearch) selectedTab = SEARCH_TAB } Column(modifier = modifier.fillMaxSize()) { PrimaryTabRow(selectedTabIndex = selectedTab) { Tab( @@ -115,6 +122,8 @@ internal fun MobileSearchHistoryScreen( recentSearchFailure = recentSearchFailure, onRecentRetry = onRecentRetry, modifier = Modifier.weight(1f), + focusField = focusSearch, + onFieldFocused = onSearchFocused, ) } else { MobileHistoryContent( @@ -139,7 +148,16 @@ private fun MobileSearchContent( recentSearchFailure: PutioFailure?, onRecentRetry: () -> Unit, modifier: Modifier = Modifier, + focusField: Boolean = false, + onFieldFocused: () -> Unit = {}, ) { + val fieldFocus = remember { FocusRequester() } + LaunchedEffect(focusField) { + if (focusField) { + fieldFocus.requestFocus() + onFieldFocused() + } + } Column(modifier = modifier.fillMaxSize()) { OutlinedTextField( value = state.query, @@ -147,6 +165,7 @@ private fun MobileSearchContent( modifier = Modifier .fillMaxWidth() .padding(16.dp) + .focusRequester(fieldFocus) .testTag(MOBILE_SEARCH_FIELD_TAG), label = { Text(stringResource(R.string.mobile_search_label)) }, placeholder = { Text(stringResource(R.string.mobile_search_placeholder)) }, diff --git a/mobile/src/main/kotlin/io/putdotio/android/share/MobileDragProvider.kt b/mobile/src/main/kotlin/io/putdotio/android/share/MobileDragProvider.kt new file mode 100644 index 00000000..efa5511a --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/share/MobileDragProvider.kt @@ -0,0 +1,79 @@ +package io.putdotio.android.share + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Handler +import android.os.HandlerThread +import android.os.ParcelFileDescriptor +import android.os.storage.StorageManager +import android.provider.OpenableColumns +import java.io.FileNotFoundException +import java.io.IOException + +/** + * Serves a file dragged out of Files to the app it was dropped on. Not exported: only the drop's read + * grant reaches it. Opening hands out a read-only proxy descriptor at once; reads stream the original + * from put.io with ranges, as the system file picker's documents do, only while the session that + * started the drag is the signed-in one. + */ +class MobileDragProvider : ContentProvider() { + private val dependencies by lazy { MobileFileShareService.shareDependencies(requireNotNull(context)) } + + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri): String? = drag(uri)?.mimeType + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + val drag = drag(uri) ?: return null + val columns = projection ?: arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + return MatrixCursor(columns, 1).apply { + addRow( + columns.map { column -> + when (column) { + OpenableColumns.DISPLAY_NAME -> drag.name + OpenableColumns.SIZE -> drag.sizeBytes.takeIf { it >= 0L } + else -> null + } + }, + ) + } + } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + if (ParcelFileDescriptor.parseMode(mode) != ParcelFileDescriptor.MODE_READ_ONLY) { + throw SecurityException("Dragged files are read-only") + } + val key = uri.pathSegments.firstOrNull() ?: throw FileNotFoundException("No such drag") + val storage = requireNotNull(context).getSystemService(StorageManager::class.java) + // One thread per open file, so a read waiting on the network holds up no other; it starts only once + // the drag opened, so a refused open leaves no thread behind. + val thread = HandlerThread("putio-drag") + val reader = MobileFileDrags.open(key, dependencies) { thread.quitSafely() } + thread.start() + return try { + storage.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, reader, Handler(thread.looper)) + } catch (error: IOException) { + reader.onRelease() + throw FileNotFoundException("Could not open a descriptor").apply { initCause(error) } + } + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = throw UnsupportedOperationException() + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException() + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException() + + private fun drag(uri: Uri): MobileFileDrags.Drag? = uri.pathSegments.firstOrNull()?.let { MobileFileDrags[it] } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileDragOut.kt b/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileDragOut.kt new file mode 100644 index 00000000..8894ea9f --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileDragOut.kt @@ -0,0 +1,183 @@ +package io.putdotio.android.share + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ContentResolver +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Point +import android.graphics.RectF +import android.net.Uri +import android.text.TextPaint +import android.text.TextUtils +import android.view.View +import io.putdotio.android.auth.MobileAuthSessionId +import io.putdotio.android.auth.MobileSessionKey +import io.putdotio.android.auth.sessionKey +import io.putdotio.android.documents.DocumentReader +import io.putdotio.android.documents.RemoteOriginals +import io.putdotio.android.documents.mimeTypeOf +import io.putdotio.android.files.FilesItem +import io.putdotio.android.files.FilesItemId +import java.io.FileNotFoundException +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** The drag's own marker, visible only inside this app: which drag of ours this is. */ +internal class MobileFileDrag(val key: String, val itemId: FilesItemId) + +/** Other apps may read the dropped URI, and only read it; no prefix, write or persistable grant. */ +internal const val MOBILE_FILE_DRAG_FLAGS: Int = View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ + +/** + * The drag payload: one content URI on this app's drag provider, named by a random key and the file's + * safe name. No token, account id or API URL travels with it. + */ +internal fun mobileFileDragClip(authority: String, key: String, name: String, mimeType: String): ClipData { + val uri = Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(authority) + .appendPath(key) + .appendPath(name.sanitizedFileName()) + .build() + return ClipData(ClipDescription(name, arrayOf(mimeType)), ClipData.Item(uri)) +} + +internal fun mobileDragAuthority(context: Context): String = "${context.packageName}.drag" + +/** + * Files dragged out of the app, by the random key in their URI. Nothing is copied: opening a drag's URI + * streams the original from put.io with ranged reads, as the system file picker does, for the session that + * started the drag and only while it is the signed-in one. Leaving that session stops every read. + */ +internal object MobileFileDrags { + class Drag( + val session: MobileSessionKey, + val fileId: FilesItemId, + val name: String, + val sizeBytes: Long, + val mimeType: String, + ) { + val readers: MutableSet = ConcurrentHashMap.newKeySet() + } + + private val lock = Any() + private val drags = LinkedHashMap() + + fun begin(session: MobileSessionKey, item: FilesItem): String = + UUID.randomUUID().toString().also { key -> + val drag = Drag(session, item.id, item.name.sanitizedFileName(), item.sizeBytes, mimeTypeOf(item.name)) + val evicted = mutableListOf() + synchronized(lock) { + drags[key] = drag + // A dropped file stays readable; only the latest drags are kept, so this never grows. + while (drags.size > MAX_DRAGS) drags.remove(drags.keys.first())?.let(evicted::add) + } + evicted.forEach(::stop) + } + + operator fun get(key: String): Drag? = synchronized(lock) { drags[key] } + + /** A drag nobody took; its URI never reached another app. */ + fun forget(key: String) { + synchronized(lock) { drags.remove(key) }?.let(::stop) + } + + /** Leaving a session stops its readers and forgets its drags; a later session's drags stay. */ + fun endSession(session: MobileAuthSessionId?) { + val ended = synchronized(lock) { + drags.filterValues { it.session.sessionId == session }.keys.mapNotNull(drags::remove) + } + ended.forEach(::stop) + } + + /** + * A reader for the drag behind [key]. It returns at once; bytes are fetched as they are read, and each + * read checks that the drag's session is still the signed-in one. + */ + fun open(key: String, dependencies: MobileShareDependencies, onReleased: () -> Unit): DocumentReader { + val drag = get(key)?.takeIf { dependencies.authState.value.sessionKey() == it.session } + ?: throw FileNotFoundException("No such drag") + val isCurrent = { dependencies.authState.value.sessionKey() == drag.session } + val bytes = RemoteOriginals(dependencies.http, dependencies.accessToken).bytes(drag.fileId, drag.sizeBytes) + lateinit var reader: DocumentReader + reader = DocumentReader(bytes, drag.session, isCurrent) { + drag.readers -= reader + onReleased() + } + drag.readers += reader + // A session that ended while this opened has already stopped the drag's other readers. + if (get(key) !== drag) { + reader.revoke() + throw FileNotFoundException("The session ended") + } + return reader + } + + internal fun clearForTest() { + synchronized(lock) { drags.values.toList().also { drags.clear() } }.forEach(::stop) + } + + private fun stop(drag: Drag) { + drag.readers.forEach(DocumentReader::revoke) + } + + private const val MAX_DRAGS = 32 +} + +/** + * Starts dragging a file out of Files for [session]. The drop target receives the provider URI with a + * read grant for that drop; the bytes stream from put.io only when it reads them. + */ +internal class MobileFileDragOut(private val context: Context, private val session: MobileSessionKey) { + fun start(view: View, item: FilesItem, shadow: View.DragShadowBuilder): Boolean { + if (item.isFolder || item.id.value <= 0L) return false + val key = MobileFileDrags.begin(session, item) + val clip = mobileFileDragClip(mobileDragAuthority(context), key, item.name, mimeTypeOf(item.name)) + val started = view.startDragAndDrop(clip, shadow, MobileFileDrag(key, item.id), MOBILE_FILE_DRAG_FLAGS) + if (!started) MobileFileDrags.forget(key) + return started + } + + /** A drag nobody took is forgotten; a dropped one stays readable by the app it landed in. */ + fun ended(drag: MobileFileDrag, dropped: Boolean) { + if (!dropped) MobileFileDrags.forget(drag.key) + } +} + +/** The file's name on a rounded chip under the pointer; the row itself stays where it is. */ +internal class MobileFileDragShadow( + name: String, + density: Float, + fontScale: Float, + background: Int, + foreground: Int, +) : View.DragShadowBuilder() { + private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = foreground + textSize = SHADOW_TEXT_SP * density * fontScale + } + private val label = TextUtils.ellipsize(name, paint, SHADOW_MAX_TEXT_DP * density, TextUtils.TruncateAt.END) + private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = background } + private val padding = SHADOW_PADDING_DP * density + private val width = (paint.measureText(label, 0, label.length) + padding * 2).toInt() + private val height = (paint.fontSpacing + padding * 2).toInt() + + override fun onProvideShadowMetrics(outShadowSize: Point, outShadowTouchPoint: Point) { + outShadowSize.set(width, height) + outShadowTouchPoint.set(width / 2, height / 2) + } + + override fun onDrawShadow(canvas: Canvas) { + val radius = height / 2f + canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), radius, radius, fill) + canvas.drawText(label, 0, label.length, padding, padding - paint.fontMetrics.ascent, paint) + } + + private companion object { + const val SHADOW_TEXT_SP = 14f + const val SHADOW_MAX_TEXT_DP = 280f + const val SHADOW_PADDING_DP = 12f + } +} diff --git a/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileShareService.kt b/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileShareService.kt index 844d6677..17cd71f7 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileShareService.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/share/MobileFileShareService.kt @@ -57,7 +57,7 @@ import okhttp3.Request * whatever earlier processes left. */ class MobileFileShareService : Service() { - private val dependencies by lazy { dependenciesForTest ?: MobileShareDependencies.from(this) } + private val dependencies by lazy { shareDependencies(this) } private val scope by lazy { CoroutineScope(SupervisorJob() + dependencies.main) } private val exporter by lazy { MobileShareExporter(this, dependencies) } private val notifications = MobileShareNotifications(this) @@ -199,6 +199,10 @@ class MobileFileShareService : Service() { @Volatile internal var dependenciesForTest: MobileShareDependencies? = null + /** The network, session and clock the service and the drag provider share. */ + internal fun shareDependencies(context: Context): MobileShareDependencies = + dependenciesForTest ?: MobileShareDependencies.from(context) + fun start(context: Context, fileId: FilesItemId, name: String) { val intent = Intent(context, MobileFileShareService::class.java) .putExtra(EXTRA_FILE_ID, fileId.value) diff --git a/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDraft.kt b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDraft.kt index 0161078a..276396da 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDraft.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDraft.kt @@ -91,11 +91,15 @@ class MobileTransferDraft private constructor( * Reads a shared `.torrent` off the main thread; the result arrives like any other share. A newer * intake or a session change cancels the read so none lands out of order. A provider may ignore the * interrupt, so the lock is held until the read actually returns: at most one runs at a time. + * [onDone] runs once the read has finished, failed or been cancelled. */ - internal fun receiveLater(read: () -> MobileSharedTransfer) { + internal fun receiveLater(onDone: () -> Unit = {}, read: () -> MobileSharedTransfer) { pendingRead?.cancel() pendingRead = viewModelScope.launch { deliver(readLock.withLock { runInterruptible(ioDispatcher) { read() } }) + }.apply { + // Also when a newer intake cancels this read before it began. + invokeOnCompletion { onDone() } } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDrop.kt b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDrop.kt new file mode 100644 index 00000000..dc47df10 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDrop.kt @@ -0,0 +1,61 @@ +package io.putdotio.android.transfers + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ContentResolver +import android.net.Uri + +/** + * Whether a drag carries something the Add transfer sheet takes: links as text, or a `.torrent` + * file, which file managers often label as plain bytes. Other files have no intake, so the drop + * target does not light up for them. + */ +internal fun ClipDescription.isMobileTransferDrop(): Boolean = + mimeTypes().any { ClipDescription.compareMimeTypes(it, "text/*") || it.isTorrentLike() } + +/** + * What a drop asks the intake for, read as untrusted input like a share: the first content URI when + * the drag says it carries a torrent, otherwise every text or link item as shared text. A drop with + * neither gives nothing. Item text is read as given; a content URI is never opened here. + */ +internal fun ClipData.toMobileIncomingTransfer(): MobileIncomingTransfer? { + val items = (0 until itemCount).map(::getItemAt) + val torrent = items + .takeIf { description?.mimeTypes().orEmpty().any { it.isTorrentLike() } } + ?.firstNotNullOfOrNull { it.uri?.takeIf { uri -> uri.scheme == ContentResolver.SCHEME_CONTENT } } + val text = items + .mapNotNull { item -> item.text?.toString() ?: item.uri?.takeIf(Uri::isLink)?.toString() } + .filter { it.isNotBlank() } + return when { + torrent != null -> MobileIncomingTransfer.Torrent(torrent) + text.isEmpty() -> null + else -> MobileIncomingTransfer.Ready(parseMobileSharedTransfer(text.joinToString("\n"))) + } +} + +/** + * Takes an intent's or a drop's payload into the draft. A torrent is read from [resolver] off the main + * thread, never from this app's own providers, and [afterRead] runs once that read is over, including + * when a newer intake cancelled it before it began. + */ +internal fun MobileTransferDraft.receive( + incoming: MobileIncomingTransfer, + resolver: ContentResolver, + ownPackage: String, + afterRead: () -> Unit = {}, +) { + when (incoming) { + is MobileIncomingTransfer.Ready -> receive(incoming.transfer) + is MobileIncomingTransfer.Torrent -> + receiveLater(read = { resolver.readMobileTorrent(incoming.uri, ownPackage) }, onDone = afterRead) + } +} + +private fun ClipDescription.mimeTypes(): List = (0 until mimeTypeCount).map(::getMimeType) + +private fun String.isTorrentLike(): Boolean = this == TORRENT_MEDIA_TYPE || this == OCTET_STREAM + +private fun Uri.isLink(): Boolean = scheme?.lowercase() in LINK_SCHEMES + +private const val OCTET_STREAM = "application/octet-stream" +private val LINK_SCHEMES = setOf("http", "https", "magnet") diff --git a/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDropZone.kt b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDropZone.kt new file mode 100644 index 00000000..e03a2887 --- /dev/null +++ b/mobile/src/main/kotlin/io/putdotio/android/transfers/MobileTransferDropZone.kt @@ -0,0 +1,159 @@ +package io.putdotio.android.transfers + +import android.app.Activity +import android.view.DragEvent +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.putdotio.android.R +import io.putdotio.android.share.MobileFileDrag + +internal const val MOBILE_TRANSFER_DROP_TARGET_TAG = "mobile-transfer-drop-target" + +internal enum class MobileDropHighlight { None, Available, Over } + +/** + * Takes links, magnet links and `.torrent` files dragged in from another app into the Add transfer + * sheet, through the same intake as a share. While such a drag is under way the whole signed-in + * screen shows where to drop it. A torrent's read grant is requested for this drop only and released + * once its read is over or cancelled. Drags of this app's own files are not taken; their end is + * reported to [onOwnDragEnded], which forgets a drag nobody received. + */ +@Composable +internal fun MobileTransferDropZone( + enabled: Boolean, + draft: MobileTransferDraft, + onOwnDragEnded: (MobileFileDrag, Boolean) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val activity = LocalActivity.current + var highlight by remember { mutableStateOf(MobileDropHighlight.None) } + val currentDraft by rememberUpdatedState(draft) + val currentOnOwnDragEnded by rememberUpdatedState(onOwnDragEnded) + val target = remember(activity) { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + highlight = MobileDropHighlight.None + val dragEvent = event.toAndroidDragEvent() + return dragEvent.localState == null && activity != null && currentDraft.receiveDrop(activity, dragEvent) + } + + override fun onStarted(event: DragAndDropEvent) { + if (event.toAndroidDragEvent().localState == null) highlight = MobileDropHighlight.Available + } + + override fun onEntered(event: DragAndDropEvent) { + if (highlight != MobileDropHighlight.None) highlight = MobileDropHighlight.Over + } + + override fun onExited(event: DragAndDropEvent) { + if (highlight != MobileDropHighlight.None) highlight = MobileDropHighlight.Available + } + + override fun onEnded(event: DragAndDropEvent) { + highlight = MobileDropHighlight.None + val dragEvent = event.toAndroidDragEvent() + (dragEvent.localState as? MobileFileDrag)?.let { currentOnOwnDragEnded(it, dragEvent.result) } + } + } + } + Box( + modifier.dragAndDropTarget( + shouldStartDragAndDrop = { event -> + val dragEvent = event.toAndroidDragEvent() + when { + dragEvent.localState is MobileFileDrag -> true + !enabled || dragEvent.localState != null -> false + else -> dragEvent.clipDescription?.isMobileTransferDrop() == true + } + }, + target = target, + ), + ) { + content() + if (highlight != MobileDropHighlight.None) MobileTransferDropOverlay(highlight == MobileDropHighlight.Over) + } +} + +/** Reads a drop into the draft; false when it carries nothing the intake takes. */ +internal fun MobileTransferDraft.receiveDrop(activity: Activity, event: DragEvent): Boolean { + val incoming = event.clipData?.toMobileIncomingTransfer() ?: return false + // Only a file needs the source's grant; text arrives inside the drop itself. + val permissions = + (incoming as? MobileIncomingTransfer.Torrent)?.let { activity.requestDragAndDropPermissions(event) } + receive(incoming, activity.contentResolver, activity.packageName, afterRead = { permissions?.release() }) + return true +} + +@Composable +internal fun MobileTransferDropOverlay(over: Boolean) { + val accent = MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = if (over) OVER_SCRIM else AVAILABLE_SCRIM)) + .padding(16.dp) + .border(if (over) 4.dp else 2.dp, accent, MaterialTheme.shapes.large) + .testTag(MOBILE_TRANSFER_DROP_TARGET_TAG) + .semantics { liveRegion = LiveRegionMode.Polite }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(24.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_ph_arrow_circle_down_fill), + contentDescription = null, + tint = accent, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(R.string.mobile_transfers_drop_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.mobile_transfers_drop_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +private const val AVAILABLE_SCRIM = 0.55f +private const val OVER_SCRIM = 0.75f diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index f9c4cab4..f6d27ec9 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -558,4 +558,17 @@ You have reached the daily public link creation limit. You have reached the weekly public link creation limit. + Drop to add a transfer + Links, magnet links and .torrent files open Add transfer. + + Files + Player + Open the focused item + Go up a folder or back + Search + Refresh + Move the focused item to trash, or delete it + Play or pause + Back 10 seconds + Forward 10 seconds diff --git a/mobile/src/test/kotlin/io/putdotio/android/HardwareKeys.kt b/mobile/src/test/kotlin/io/putdotio/android/HardwareKeys.kt new file mode 100644 index 00000000..33d4feb2 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/HardwareKeys.kt @@ -0,0 +1,61 @@ +package io.putdotio.android + +import android.app.Activity +import android.os.Looper +import android.os.SystemClock +import android.view.InputDevice +import android.view.InputEvent +import android.view.KeyEvent +import android.view.Window +import org.robolectric.Shadows.shadowOf +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** + * Presses a hardware key the way the input dispatcher delivers one: through the window's + * ViewRootImpl input stages, so touch mode, unhandled-key listeners, Ctrl shortcuts and focus + * navigation all run as on a device. Calling View.dispatchKeyEvent skips those stages. + */ +internal fun Activity.pressHardwareKey(keyCode: Int, metaState: Int = 0, repeats: Int = 0) { + val root = ReflectionHelpers.callInstanceMethod(window.decorView, "getViewRootImpl") + val downTime = SystemClock.uptimeMillis() + fun send(action: Int, repeat: Int) { + val event = KeyEvent( + downTime, SystemClock.uptimeMillis(), action, keyCode, repeat, metaState, + HARDWARE_KEYBOARD, 0, KeyEvent.FLAG_FROM_SYSTEM, InputDevice.SOURCE_KEYBOARD, + ) + ReflectionHelpers.callInstanceMethod( + root, + "dispatchInputEvent", + ClassParameter.from(InputEvent::class.java, event), + ) + shadowOf(Looper.getMainLooper()).idle() + } + send(KeyEvent.ACTION_DOWN, 0) + repeat(repeats) { send(KeyEvent.ACTION_DOWN, it + 1) } + send(KeyEvent.ACTION_UP, 0) +} + +// Any non-virtual device id: a key from a physical keyboard. +private const val HARDWARE_KEYBOARD = 1 + +/** + * Presses [keyCode] and returns whether the window consumed it. An Escape the app leaves unconsumed is what + * Android turns into Back, outside the app's process, so the result at the window is what keeps it in the app. + */ +internal fun Activity.pressHardwareKeyConsumed(keyCode: Int, metaState: Int = 0): Boolean { + val original = window.callback + var consumed: Boolean? = null + window.callback = object : Window.Callback by original { + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + original.dispatchKeyEvent(event).also { + if (event.keyCode == keyCode && event.action == KeyEvent.ACTION_DOWN) consumed = it + } + } + try { + pressHardwareKey(keyCode, metaState) + } finally { + window.callback = original + } + return requireNotNull(consumed) { "The window never saw the key" } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileKeyboardShortcutsTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileKeyboardShortcutsTest.kt new file mode 100644 index 00000000..945df79b --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileKeyboardShortcutsTest.kt @@ -0,0 +1,298 @@ +package io.putdotio.android + +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.unit.dp +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.files.FilesBrowserEffect +import io.putdotio.android.files.FilesBrowserEvent +import io.putdotio.android.files.FilesBrowserReducer +import io.putdotio.android.files.FilesBrowserState +import io.putdotio.android.files.FilesDeleteMode +import io.putdotio.android.files.FilesFolder +import io.putdotio.android.files.FilesItem +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.files.FilesPage +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.search.MOBILE_SEARCH_FIELD_TAG +import io.putdotio.android.settings.DefaultAccountSettingsPreferences +import io.putdotio.android.settings.readyAccountSettingsState +import io.putdotio.android.settings.readyAndroidAppConfigState +import io.putdotio.sdk.files.PlaybackConversionState +import io.putdotio.sdk.files.PutioFileType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +/** Hardware keys through the real input stages, on the production shell over a fixed Files listing. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileKeyboardShortcutsTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val events = mutableListOf() + private var files by mutableStateOf(rootState()) + + @Test + fun f5AndCtrlRRefreshFilesWhenNoControlTakesThem() { + setShell() + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_F5) + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_R, CTRL) + // Alt and Meta belong to Android's own shortcuts; R alone is just a letter. + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_R, KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON) + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_R) + + compose.runOnIdle { assertEquals(listOf(FilesBrowserEvent.Refresh, FilesBrowserEvent.Refresh), events) } + } + + @Test + fun escapeAndBackspaceGoUpAFolderAndNeverLeaveTheApp() { + setShell(filesState = nestedState()) + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_ESCAPE) + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_DEL) + + compose.runOnIdle { + assertEquals(listOf(FilesBrowserEvent.NavigateBack, FilesBrowserEvent.NavigateBack), events) + } + + // With nothing left to go back to, the app still takes Escape, so Android never turns it into a Back + // that leaves the app. + compose.runOnIdle { files = rootState() } + compose.waitForIdle() + assertTrue(compose.activity.pressHardwareKeyConsumed(KeyEvent.KEYCODE_ESCAPE)) + compose.runOnIdle { assertEquals(2, events.size) } + } + + /** Below API 28 the listener rides androidx's KeyEventDispatcher, which ComponentActivity routes keys through. */ + @Test + @Config(sdk = [26]) + fun onApi26F5RefreshesAndEscapeStaysInTheApp() { + setShell() + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_F5) + assertTrue(compose.activity.pressHardwareKeyConsumed(KeyEvent.KEYCODE_ESCAPE)) + + compose.runOnIdle { assertEquals(listOf(FilesBrowserEvent.Refresh), events) } + } + + @Test + fun deleteOnAFocusedRowMovesItToTrashWithoutTheSheetWhenTrashIsOn() { + setShell() + focusRow("notes.txt") + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_FORWARD_DEL) + + compose.runOnIdle { + assertEquals(listOf(FilesBrowserEvent.Delete(FilesFolder.Root.id, Notes.id, FilesDeleteMode.TRASH)), events) + } + compose.onNodeWithText("Rename").assertDoesNotExist() + } + + @Test + fun deleteOnAFocusedRowConfirmsFirstWhenTrashIsOff() { + setShell(trashEnabled = false) + focusRow("notes.txt") + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_FORWARD_DEL) + + compose.onNodeWithText("Rename").assertDoesNotExist() + compose.runOnIdle { assertEquals(emptyList(), events) } + compose.onNodeWithText("Confirm").performClick() + compose.runOnIdle { + assertEquals( + listOf(FilesBrowserEvent.Delete(FilesFolder.Root.id, Notes.id, FilesDeleteMode.PERMANENT)), + events, + ) + } + } + + @Test + fun deleteOnASharedRowDoesNothing() { + setShell() + focusRow("friend.mkv") + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_FORWARD_DEL) + + compose.runOnIdle { assertEquals(emptyList(), events) } + compose.onNodeWithText("Make a copy").assertDoesNotExist() + } + + @Test + fun enterOpensTheFocusedFolderAndArrowsMoveBetweenRows() { + setShell() + focusRow("Shows") + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_DPAD_DOWN) + compose.onNode(hasText("movie.mkv") and hasAnyAncestor(hasTestTag(FILES_LIST))).assertIsFocused() + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_DPAD_UP) + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_ENTER) + + compose.runOnIdle { assertEquals(listOf(FilesBrowserEvent.OpenFolder(Shows.id)), events) } + } + + @Test + fun ctrlFOpensSearchWithTheCursorInItsFieldWhichKeepsBackspace() { + setShell() + + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_F, CTRL) + + compose.onNodeWithTag(MOBILE_SEARCH_FIELD_TAG).assertIsFocused() + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_DEL) + compose.onNodeWithTag(MOBILE_SEARCH_FIELD_TAG).assertIsFocused() + compose.onNode(hasText("Search") and hasAnyAncestor(hasTestTag(MOBILE_NAV_RAIL_TAG))).assertIsSelected() + } + + @Test + fun theShortcutsHelperListsTheKeysTheAppTakes() { + Robolectric.buildActivity(MainActivity::class.java).setup().use { controller -> + val groups = mutableListOf() + controller.get().onProvideKeyboardShortcuts(groups, null, -1) + + val listed = groups.associate { group -> + group.label.toString() to group.items.map { "${it.label}:${it.keycode}:${it.modifiers}" } + } + assertEquals( + listOf( + "Open the focused item:${KeyEvent.KEYCODE_ENTER}:0", + "Go up a folder or back:${KeyEvent.KEYCODE_DEL}:0", + "Go up a folder or back:${KeyEvent.KEYCODE_ESCAPE}:0", + "Search:${KeyEvent.KEYCODE_F}:${KeyEvent.META_CTRL_ON}", + "Refresh:${KeyEvent.KEYCODE_R}:${KeyEvent.META_CTRL_ON}", + "Refresh:${KeyEvent.KEYCODE_F5}:0", + "Move the focused item to trash, or delete it:${KeyEvent.KEYCODE_FORWARD_DEL}:0", + ), + listed["Files"], + ) + assertEquals( + listOf( + "Play or pause:${KeyEvent.KEYCODE_SPACE}:0", + "Back 10 seconds:${KeyEvent.KEYCODE_DPAD_LEFT}:0", + "Forward 10 seconds:${KeyEvent.KEYCODE_DPAD_RIGHT}:0", + "Go up a folder or back:${KeyEvent.KEYCODE_ESCAPE}:0", + "Go up a folder or back:${KeyEvent.KEYCODE_DEL}:0", + ), + listed["Player"], + ) + } + } + + @Test + fun onlyBareKeysAndCtrlAreTaken() { + val files = MobileShortcutScope.Files + val ctrlShift = CTRL or KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON + assertEquals(MobileKeyCommand.Search, mobileKeyCommand(files, KeyEvent.KEYCODE_F, CTRL)) + assertNull(mobileKeyCommand(files, KeyEvent.KEYCODE_F, ctrlShift)) + assertNull(mobileKeyCommand(files, KeyEvent.KEYCODE_F, KeyEvent.META_META_ON)) + assertNull(mobileKeyCommand(files, KeyEvent.KEYCODE_DEL, KeyEvent.META_META_ON)) + assertNull(mobileKeyCommand(files, KeyEvent.KEYCODE_SPACE, 0)) + assertNull(mobileKeyCommand(files, KeyEvent.KEYCODE_ENTER, 0)) + assertNull(mobileKeyCommand(MobileShortcutScope.Player, KeyEvent.KEYCODE_SPACE, CTRL)) + val player = MobileShortcutScope.Player + assertEquals(MobileKeyCommand.PlayPause, mobileKeyCommand(player, KeyEvent.KEYCODE_SPACE, 0)) + } + + private fun setShell(filesState: FilesBrowserState = rootState(), trashEnabled: Boolean = true) { + files = filesState + compose.setContent { + PutioTheme { + Box(Modifier.requiredSize(width = 900.dp, height = 700.dp)) { + MobileShell( + playbackPlayerFactory = NoAudioSessionFactory, + filesState = files, + accountSettingsState = readyAccountSettingsState( + DefaultAccountSettingsPreferences.copy(trashEnabled = trashEnabled), + ), + appConfigState = readyAndroidAppConfigState(), + account = Account, + playbackRepository = UnusedPlayback, + sessionId = Session, + onFilesEvent = { + events += it + true + }, + onAccountSettingsEvent = {}, + onPlaybackAuthenticationRequired = {}, + onShareItem = {}, + onSignOut = {}, + ) + } + } + } + compose.waitForIdle() + } + + /** A navigation key leaves touch mode, as on a device; then the row takes focus as arrows would give it. */ + private fun focusRow(name: String) { + compose.activity.pressHardwareKey(KeyEvent.KEYCODE_TAB) + compose.onNode(hasText(name) and hasAnyAncestor(hasTestTag(FILES_LIST))).requestFocus().assertIsFocused() + } + + private companion object { + const val CTRL = KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON + const val FILES_LIST = "mobile-files-list" + val Account = MobileAccount(userId = 42L, username = "user", email = "user@example.com") + val Session = MobileAuthSessionId(1L) + val Shows = item(7L, "Shows", PutioFileType.FOLDER) + val Movie = item(9L, "movie.mkv", PutioFileType.VIDEO) + val Notes = item(11L, "notes.txt", PutioFileType.TEXT) + val Friend = item(12L, "friend.mkv", PutioFileType.VIDEO).copy(isShared = true) + + fun item(id: Long, name: String, type: PutioFileType) = FilesItem( + id = FilesItemId(id), + parentId = FilesFolder.Root.id, + name = name, + type = type, + sizeBytes = 42L, + createdAt = "2026-08-29T00:00:00Z", + ) + + fun rootState(): FilesBrowserState { + val initial = FilesBrowserReducer.start() + val requestId = (initial.effect as FilesBrowserEffect.LoadFolder).requestId + val page = FilesPage(listOf(Shows, Movie, Notes, Friend), nextCursor = null) + return FilesBrowserReducer.reduce(initial.state, FilesBrowserEvent.LoadSucceeded(requestId, page)).state + } + + fun nestedState(): FilesBrowserState = + FilesBrowserReducer.reduce(rootState(), FilesBrowserEvent.OpenFolder(Shows.id)).state + + val UnusedPlayback = object : PlaybackRepository { + override suspend fun resolve(target: PlaybackTarget): PlaybackRepositoryResult = + PlaybackRepositoryResult.Success(PlaybackResolution.Conversion(PlaybackConversionState.Queued)) + + override suspend fun findNextVideo(target: PlaybackTarget) = PlaybackNextResult.Ended + } + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileMultiWindowTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileMultiWindowTest.kt new file mode 100644 index 00000000..96f77247 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileMultiWindowTest.kt @@ -0,0 +1,144 @@ +package io.putdotio.android + +import android.content.res.Configuration +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.files.FilesBrowserEffect +import io.putdotio.android.files.FilesBrowserEvent +import io.putdotio.android.files.FilesBrowserReducer +import io.putdotio.android.files.FilesBrowserState +import io.putdotio.android.files.FilesPage +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.sdk.files.PlaybackConversionState +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +/** Split-screen, freeform and keyboard changes, with and without the Activity being recreated. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileMultiWindowTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun resizingRotatingAndAttachingAKeyboardKeepTheActivityAndWhatItHolds() { + Robolectric.buildActivity(MainActivity::class.java).setup().use { controller -> + val activity = controller.get() + val draft = activity.transferDraft + val resized = Configuration(activity.resources.configuration).apply { + screenWidthDp = 1280 + screenHeightDp = 800 + smallestScreenWidthDp = 800 + orientation = Configuration.ORIENTATION_LANDSCAPE + screenLayout = Configuration.SCREENLAYOUT_SIZE_XLARGE or Configuration.SCREENLAYOUT_LONG_NO + keyboard = Configuration.KEYBOARD_QWERTY + keyboardHidden = Configuration.KEYBOARDHIDDEN_NO + hardKeyboardHidden = Configuration.HARDKEYBOARDHIDDEN_NO + } + + controller.configurationChange(resized) + + assertSame(activity, controller.get()) + assertSame(draft, controller.get().transferDraft) + } + } + + @Test + fun aDensityChangeStillRecreatesTheActivity() { + Robolectric.buildActivity(MainActivity::class.java).setup().use { controller -> + val activity = controller.get() + val draft = activity.transferDraft + val denser = Configuration(activity.resources.configuration).apply { densityDpi *= 2 } + + controller.configurationChange(denser) + + assertNotSame(activity, controller.get()) + // Recreation keeps the Activity-scoped intake through its view model. + assertSame(draft, controller.get().transferDraft) + } + } + + @Test + fun theShellKeepsItsDestinationAsTheWindowResizesAndWhenTheActivityIsRecreated() { + var width by mutableStateOf(400.dp) + val restoration = StateRestorationTester(compose) + restoration.setContent { Shell(width) } + + compose.onNodeWithTag(MOBILE_NAV_BAR_TAG).assertExists() + compose.onNode(hasText("Search") and hasAnyAncestor(hasTestTag(MOBILE_NAV_BAR_TAG))).performClick() + + compose.runOnIdle { width = 900.dp } + compose.onNode(hasText("Search") and hasAnyAncestor(hasTestTag(MOBILE_NAV_RAIL_TAG))).assertIsSelected() + + restoration.emulateSavedInstanceStateRestore() + compose.onNode(hasText("Search") and hasAnyAncestor(hasTestTag(MOBILE_NAV_RAIL_TAG))).assertIsSelected() + + compose.runOnIdle { width = 360.dp } + compose.onNode(hasText("Search") and hasAnyAncestor(hasTestTag(MOBILE_NAV_BAR_TAG))).assertIsSelected() + } + + @androidx.compose.runtime.Composable + private fun Shell(width: Dp) { + PutioTheme { + Box(Modifier.requiredSize(width = width, height = 700.dp)) { + MobileShell( + playbackPlayerFactory = NoAudioSessionFactory, + filesState = emptyFiles(), + accountSettingsState = readyAccountSettingsState(), + appConfigState = readyAndroidAppConfigState(), + account = MobileAccount(userId = 42L, username = "user", email = "user@example.com"), + playbackRepository = unusedPlayback, + sessionId = MobileAuthSessionId(1L), + onFilesEvent = { true }, + onAccountSettingsEvent = {}, + onPlaybackAuthenticationRequired = {}, + onSignOut = {}, + ) + } + } + } + + private fun emptyFiles(): FilesBrowserState { + val initial = FilesBrowserReducer.start() + val requestId = (initial.effect as FilesBrowserEffect.LoadFolder).requestId + val page = FilesPage(emptyList(), nextCursor = null) + return FilesBrowserReducer.reduce(initial.state, FilesBrowserEvent.LoadSucceeded(requestId, page)).state + } + + private val unusedPlayback = object : PlaybackRepository { + override suspend fun resolve(target: PlaybackTarget): PlaybackRepositoryResult = + PlaybackRepositoryResult.Success(PlaybackResolution.Conversion(PlaybackConversionState.Queued)) + + override suspend fun findNextVideo(target: PlaybackTarget) = PlaybackNextResult.Ended + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobilePlayerScreenTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobilePlayerScreenTest.kt index d4c482f1..521625f3 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobilePlayerScreenTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobilePlayerScreenTest.kt @@ -2548,6 +2548,135 @@ class MobilePlayerAudioTest { } } +/** Hardware keys through the real input stages, and a split-screen window pausing around the player. */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "en-rUS") +class MobilePlayerKeyboardTest { + @get:Rule + val compose = androidx.compose.ui.test.junit4.createAndroidComposeRule() + + @Test + fun spaceTogglesPlaybackArrowsSeekAndEscapeLeavesBeforeAnyControlIsFocused() { + lateinit var player: RecordingPlayer + var backs = 0 + setPlayer(onBack = { backs += 1 }) { RecordingPlayer(durationMillis = 60_000L).also { player = it } } + compose.runOnIdle { assertTrue(player.playWhenReady) } + + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_SPACE) + compose.runOnIdle { assertFalse(player.playWhenReady) } + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_SPACE) + compose.runOnIdle { assertTrue(player.playWhenReady) } + + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_DPAD_LEFT) + compose.runOnIdle { assertEquals(listOf(30_000L, 40_000L, 30_000L), player.seekPositions) } + // Shift and Ctrl arrows are not seeks. + compose.activity.pressHardwareKey( + android.view.KeyEvent.KEYCODE_DPAD_RIGHT, + android.view.KeyEvent.META_SHIFT_ON or android.view.KeyEvent.META_SHIFT_LEFT_ON, + ) + compose.runOnIdle { assertEquals(3, player.seekPositions.size) } + + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_ESCAPE) + compose.runOnIdle { assertEquals(1, backs) } + } + + @Test + fun aHeldArrowKeepsSeekingWhileAHeldSpaceTogglesOnce() { + lateinit var player: RecordingPlayer + setPlayer { RecordingPlayer(durationMillis = 60_000L).also { player = it } } + + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_DPAD_RIGHT, repeats = 2) + compose.activity.pressHardwareKey(android.view.KeyEvent.KEYCODE_SPACE, repeats = 3) + + compose.runOnIdle { + assertEquals(listOf(30_000L, 40_000L, 50_000L), player.seekPositions) + assertFalse(player.playWhenReady) + } + } + + @Test + fun videoKeepsPlayingWhileItsSplitScreenWindowIsPausedAndStopsWithIt() { + val lifecycleOwner = PlayerLifecycleOwner().apply { moveTo(Lifecycle.State.RESUMED) } + val players = mutableListOf() + shadowOf(compose.activity).setInMultiWindowMode(true) + compose.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + PutioTheme { + MobilePlayerScreen( + state = state(PlaybackContent.Ready(videoSource())), + onRetry = {}, + onPlayerFailure = { _, _ -> }, + onBack = {}, + playerFactory = MobilePlayerFactory { _, _ -> RecordingPlayer().also(players::add) }, + ) + } + } + } + compose.runOnIdle { assertTrue(players.single().playWhenReady) } + + compose.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.STARTED) } + compose.runOnIdle { assertTrue(players.single().playWhenReady) } + + compose.runOnIdle { + lifecycleOwner.moveTo(Lifecycle.State.CREATED) + assertTrue(players.single().released) + } + compose.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + compose.runOnIdle { assertTrue(players.last().playWhenReady) } + + // A full-screen window still pauses the video when it pauses. + shadowOf(compose.activity).setInMultiWindowMode(false) + compose.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.STARTED) } + compose.runOnIdle { assertFalse(players.last().playWhenReady) } + } + + @Test + fun aPauseMadeWhileTheSplitScreenPaneIsStartedSurvivesItsResume() { + val lifecycleOwner = PlayerLifecycleOwner().apply { moveTo(Lifecycle.State.RESUMED) } + val players = mutableListOf() + shadowOf(compose.activity).setInMultiWindowMode(true) + compose.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + PutioTheme { + MobilePlayerScreen( + state = state(PlaybackContent.Ready(videoSource())), + onRetry = {}, + onPlayerFailure = { _, _ -> }, + onBack = {}, + playerFactory = MobilePlayerFactory { _, _ -> RecordingPlayer().also(players::add) }, + ) + } + } + } + compose.runOnIdle { assertTrue(players.single().playWhenReady) } + + // The other pane takes focus, then a headset or the notification pauses the video. + compose.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.STARTED) } + compose.runOnIdle { players.single().pause() } + compose.runOnIdle { lifecycleOwner.moveTo(Lifecycle.State.RESUMED) } + + compose.runOnIdle { assertFalse(players.single().playWhenReady) } + } + + private fun setPlayer(onBack: () -> Unit = {}, create: () -> RecordingPlayer) { + compose.setContent { + PutioTheme { + MobilePlayerScreen( + state = readyState(startFromSeconds = 20.0), + onRetry = {}, + onPlayerFailure = { _, _ -> }, + onBack = onBack, + playerFactory = MobilePlayerFactory { _, _ -> create() }, + ) + } + } + compose.waitForIdle() + } +} + private fun state( content: PlaybackContent, target: PlaybackTarget = Target, diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDraftTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDraftTest.kt index 645ab2c2..513bcb52 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDraftTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDraftTest.kt @@ -244,9 +244,9 @@ class MobileTransferDraftTest { MobileSharedTransfer(torrent = torrent) } try { - draft.receiveLater(read(TORRENT, stuck = true)) + draft.receiveLater(read = read(TORRENT, stuck = true)) while (running.get() == 0) Thread.sleep(5) - draft.receiveLater(read(other, stuck = false)) + draft.receiveLater(read = read(other, stuck = false)) repeat(10) { shadowOf(Looper.getMainLooper()).idle(); Thread.sleep(10) } assertEquals(1, mostRunning.get()) release.countDown() @@ -263,6 +263,47 @@ class MobileTransferDraftTest { } } + @Test + fun aReadSupersededBeforeItBeganStillReportsItIsDone() { + val pool = java.util.concurrent.Executors.newFixedThreadPool(2) + val draft = MobileTransferDraft(pool.asCoroutineDispatcher()) + val release = java.util.concurrent.CountDownLatch(1) + val started = java.util.concurrent.CountDownLatch(1) + val done = java.util.concurrent.CopyOnWriteArrayList() + val other = TorrentUpload("Other.torrent", TORRENT.content) + try { + draft.receiveLater( + read = { + started.countDown() + while (!awaitIgnoringInterrupts(release)) Unit + MobileSharedTransfer(torrent = TORRENT) + }, + onDone = { done += "first" }, + ) + check(started.await(5, java.util.concurrent.TimeUnit.SECONDS)) + // Waits behind the first read, which ignores interrupts, then a third intake supersedes it. + draft.receiveLater(read = { MobileSharedTransfer(torrent = TORRENT) }, onDone = { done += "second" }) + shadowOf(Looper.getMainLooper()).idle() + draft.receiveLater(read = { MobileSharedTransfer(torrent = other) }, onDone = { done += "third" }) + val deadline = System.nanoTime() + 5_000_000_000L + while ("second" !in done && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertTrue("A drop's grant goes even when its read never ran", "second" in done) + release.countDown() + while (draft.state.value.torrent == null && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(10) + } + assertSame(other, draft.state.value.torrent) + assertEquals(setOf("first", "second", "third"), done.toSet()) + } finally { + release.countDown() + pool.shutdownNow() + } + } + private fun awaitIgnoringInterrupts(latch: java.util.concurrent.CountDownLatch): Boolean = try { latch.await(10, java.util.concurrent.TimeUnit.MILLISECONDS) diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDropTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDropTest.kt new file mode 100644 index 00000000..43fde0f3 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileTransferDropTest.kt @@ -0,0 +1,175 @@ +package io.putdotio.android + +import android.content.ClipData +import android.content.ClipDescription +import android.net.Uri +import android.os.Looper +import android.view.DragEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.design.PutioTheme +import io.putdotio.android.files.FilesItemId +import io.putdotio.android.share.MobileFileDrag +import io.putdotio.android.transfers.MOBILE_TRANSFER_DROP_TARGET_TAG +import io.putdotio.android.transfers.MobileIncomingTransfer +import io.putdotio.android.transfers.MobileShareValidation +import io.putdotio.android.transfers.MobileTransferDraft +import io.putdotio.android.transfers.MobileTransferDropZone +import io.putdotio.android.transfers.isMobileTransferDrop +import io.putdotio.android.transfers.toMobileIncomingTransfer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.DragEventBuilder + +/** Drops from other apps through the production drop zone, delivered as the window's drag events. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileTransferDropTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val draft = MobileTransferDraft() + private val ownDragEnds = mutableListOf>() + + @Test + fun aMagnetDraggedInShowsTheTargetThenOpensAnUnsubmittedDraft() { + setZone() + + drag(DragEvent.ACTION_DRAG_STARTED, description = ClipDescription("link", arrayOf("text/plain"))) + compose.onNodeWithText("Drop to add a transfer").assertExists() + drag(DragEvent.ACTION_DRAG_LOCATION) + drag(DragEvent.ACTION_DROP, clip = ClipData.newPlainText("link", MAGNET)) + drag(DragEvent.ACTION_DRAG_ENDED, result = true) + + compose.onNodeWithTag(MOBILE_TRANSFER_DROP_TARGET_TAG).assertDoesNotExist() + compose.runOnIdle { + val state = draft.state.value + assertEquals(MAGNET, state.input) + assertTrue(state.open) + assertFalse(state.submitting) + assertNull(state.validation) + } + } + + @Test + fun aDroppedTorrentIsReadThroughTheShareIntake() { + val uri = Uri.parse("content://downloads.example/torrents/Harbor%20film.torrent") + val metainfo = "d8:announce3:url4:infod4:name6:Harboree".toByteArray() + shadowOf(compose.activity.contentResolver).registerInputStream(uri, metainfo.inputStream()) + setZone() + + val description = ClipDescription("Harbor film.torrent", arrayOf("application/x-bittorrent")) + drag(DragEvent.ACTION_DRAG_STARTED, description = description) + drag(DragEvent.ACTION_DRAG_LOCATION) + drag(DragEvent.ACTION_DROP, clip = ClipData(description, ClipData.Item(uri))) + drag(DragEvent.ACTION_DRAG_ENDED, result = true) + + val torrent = awaitTorrent() + assertEquals("Harbor film.torrent", torrent.fileName) + assertTrue(metainfo.contentEquals(torrent.content)) + } + + @Test + fun otherFilesAndThisAppsOwnDragsDoNotShowTheTarget() { + setZone() + + drag(DragEvent.ACTION_DRAG_STARTED, description = ClipDescription("photo", arrayOf("image/png"))) + compose.onNodeWithTag(MOBILE_TRANSFER_DROP_TARGET_TAG).assertDoesNotExist() + drag(DragEvent.ACTION_DRAG_ENDED) + + val own = MobileFileDrag("key", FilesItemId(9L)) + val description = ClipDescription("poster.jpg", arrayOf("image/jpeg")) + drag(DragEvent.ACTION_DRAG_STARTED, description = description, localState = own) + compose.onNodeWithTag(MOBILE_TRANSFER_DROP_TARGET_TAG).assertDoesNotExist() + drag(DragEvent.ACTION_DRAG_LOCATION, localState = own) + drag(DragEvent.ACTION_DROP, clip = ClipData.newPlainText("poster", MAGNET), localState = own) + drag(DragEvent.ACTION_DRAG_ENDED, localState = own, result = false) + + compose.runOnIdle { + assertEquals("", draft.state.value.input) + assertEquals(listOf(own to false), ownDragEnds) + } + } + + @Test + fun dropsAreReadAsUntrustedShareInput() { + assertTrue(ClipDescription("links", arrayOf("text/uri-list")).isMobileTransferDrop()) + assertTrue(ClipDescription("file", arrayOf("application/octet-stream")).isMobileTransferDrop()) + assertFalse(ClipDescription("photo", arrayOf("image/png")).isMobileTransferDrop()) + + val links = ClipData.newPlainText("links", "https://example.invalid/a.iso") + .apply { addItem(ClipData.Item(Uri.parse("https://example.invalid/b.iso"))) } + val received = links.toMobileIncomingTransfer() as MobileIncomingTransfer.Ready + assertEquals("https://example.invalid/a.iso\nhttps://example.invalid/b.iso", received.transfer.input) + + // A text file is not a torrent, and its bytes are never read for links. + val textFile = ClipData(ClipDescription("notes", arrayOf("text/plain")), ClipData.Item(Uri.parse(TEXT_FILE))) + assertNull(textFile.toMobileIncomingTransfer()) + + val tooLong = ClipData.newPlainText("prose", "x".repeat(17 * 1024)).toMobileIncomingTransfer() + assertEquals(MobileShareValidation.TooLong, (tooLong as MobileIncomingTransfer.Ready).transfer.validation) + } + + private fun setZone() { + compose.setContent { + PutioTheme { + MobileTransferDropZone( + enabled = true, + draft = draft, + onOwnDragEnded = { drag, dropped -> ownDragEnds += drag to dropped }, + ) { + Box(Modifier.fillMaxSize()) + } + } + } + compose.waitForIdle() + } + + private fun drag( + action: Int, + description: ClipDescription? = null, + clip: ClipData? = null, + localState: Any? = null, + result: Boolean = false, + ) { + val window = compose.activity.window.decorView + val event = DragEventBuilder.newBuilder() + .setAction(action) + .setX(window.width / 2f) + .setY(window.height / 2f) + .setClipDescription(description ?: clip?.description) + .setClipData(clip) + .setLocalState(localState) + .setResult(result) + .build() + compose.runOnUiThread { window.dispatchDragEvent(event) } + compose.waitForIdle() + } + + private fun awaitTorrent(): io.putdotio.android.transfers.TorrentUpload { + val deadline = System.currentTimeMillis() + 5_000 + while (draft.state.value.torrent == null && System.currentTimeMillis() < deadline) { + Thread.sleep(10) + shadowOf(Looper.getMainLooper()).idle() + } + return requireNotNull(draft.state.value.torrent) + } + + private companion object { + const val MAGNET = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Harbor%20film" + const val TEXT_FILE = "content://files.example/notes.txt" + } +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/files/FilesKeyboardFocusTest.kt b/mobile/src/test/kotlin/io/putdotio/android/files/FilesKeyboardFocusTest.kt new file mode 100644 index 00000000..b92cf5d8 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/files/FilesKeyboardFocusTest.kt @@ -0,0 +1,74 @@ +package io.putdotio.android.files + +import androidx.compose.runtime.mutableStateOf +import io.putdotio.sdk.files.PutioFileType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilesKeyboardFocusTest { + private val rows = (1L..4L).map { id -> + FilesItem(FilesItemId(id), FilesFolder.Root.id, "row $id", PutioFileType.TEXT, 1L, "2026-10-04") + } + private val lastFocused = mutableStateOf(null) + private val focus = FilesKeyboardFocus(entryIndex = 0, entryPending = false, lastFocusedId = lastFocused) + + @Test + fun aFocusedRowThatLeavesHandsFocusToTheRowNowInItsPlace() { + focus.onRowFocus(rows[1].id, index = 1, focused = true) + + focus.afterListingChange(keyboardInput = true, items = rows - rows[1]) + + assertEquals(listOf(1), entries(rows.size - 1)) + } + + @Test + fun theLastRowLeavingHandsFocusToTheNewLastRow() { + focus.onRowFocus(rows[3].id, index = 3, focused = true) + + focus.afterListingChange(keyboardInput = true, items = rows - rows[3]) + + assertEquals(listOf(2), entries(rows.size - 1)) + } + + @Test + fun aFocusedRowThatStaysKeepsItsFocus() { + focus.onRowFocus(rows[1].id, index = 1, focused = true) + + focus.afterListingChange(keyboardInput = true, items = rows - rows[3]) + + assertTrue(entries(rows.size - 1).isEmpty()) + } + + @Test + fun touchInputNeverMovesFocus() { + focus.onRowFocus(rows[1].id, index = 1, focused = true) + + focus.afterListingChange(keyboardInput = false, items = rows - rows[1]) + + assertTrue(entries(rows.size - 1).isEmpty()) + } + + @Test + fun focusMovedOffTheListingStaysWhereTheViewerPutIt() { + focus.onRowFocus(rows[1].id, index = 1, focused = true) + // Tab to the navigation rail: the row blurs and is still listed. + focus.onRowFocus(rows[1].id, index = 1, focused = false) + + focus.afterListingChange(keyboardInput = true, items = rows - rows[3]) + + assertTrue(entries(rows.size - 1).isEmpty()) + } + + @Test + fun theLastFocusedRowIsRememberedForTheNextEntry() { + focus.onRowFocus(rows[2].id, index = 2, focused = true) + focus.entered() + + assertEquals(rows[2].id.value, lastFocused.value) + assertFalse(focus.takesEntry(0)) + } + + private fun entries(count: Int): List = (0 until count).filter(focus::takesEntry) +} diff --git a/mobile/src/test/kotlin/io/putdotio/android/share/MobileDragProviderTest.kt b/mobile/src/test/kotlin/io/putdotio/android/share/MobileDragProviderTest.kt new file mode 100644 index 00000000..e3675123 --- /dev/null +++ b/mobile/src/test/kotlin/io/putdotio/android/share/MobileDragProviderTest.kt @@ -0,0 +1,178 @@ +package io.putdotio.android.share + +import android.content.Context +import android.provider.OpenableColumns +import android.system.ErrnoException +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.files.FilesItem +import io.putdotio.android.files.FilesItemId +import io.putdotio.sdk.files.PutioFileType +import java.io.FileNotFoundException +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileDragProviderTest { + private val context = ApplicationProvider.getApplicationContext() + private val auth = MutableStateFlow(signedIn(SESSION)) + private val authority = mobileDragAuthority(context) + private val original = ByteArray(ORIGINAL_SIZE) { (it % 251).toByte() } + private val requests = CopyOnWriteArrayList() + private val dependencies = MobileShareDependencies( + http = OkHttpClient.Builder().addInterceptor { chain -> ranged(chain.request()) }.build(), + authState = auth, + accessToken = { "session-token" }, + ) + + @Before + fun setUp() { + MobileFileShareService.dependenciesForTest = dependencies + Robolectric.setupContentProvider(MobileDragProvider::class.java, authority) + } + + @After + fun tearDown() { + MobileFileDrags.clearForTest() + MobileFileShareService.dependenciesForTest = null + } + + @Test + fun theDragCarriesOnlyATokenFreeProviderUriWithAReadOnlyGrant() { + val clip = mobileFileDragClip(authority, "drag-key", "Harbor/film.mkv", "video/x-matroska") + + assertEquals(1, clip.itemCount) + val uri = clip.getItemAt(0).uri + assertEquals("content", uri.scheme) + assertEquals("${context.packageName}.drag", uri.authority) + assertEquals(listOf("drag-key", "Harbor_film.mkv"), uri.pathSegments) + assertEquals("video/x-matroska", clip.description.getMimeType(0)) + val everything = "$uri ${clip.description} ${clip.getItemAt(0).text} ${clip.getItemAt(0).intent}" + listOf("token", "oauth", "api.put.io", "http").forEach { assertFalse(everything.contains(it)) } + + assertEquals(View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ, MOBILE_FILE_DRAG_FLAGS) + val wider = View.DRAG_FLAG_GLOBAL_URI_WRITE or View.DRAG_FLAG_GLOBAL_PERSISTABLE_URI_PERMISSION or + View.DRAG_FLAG_GLOBAL_PREFIX_URI_PERMISSION + assertEquals(0, MOBILE_FILE_DRAG_FLAGS and wider) + } + + @Test + fun openingReturnsAtOnceAndReadsStreamTheOriginalInRanges() { + val key = MobileFileDrags.begin(SESSION, POSTER) + + val reader = MobileFileDrags.open(key, dependencies) {} + assertTrue("Nothing is fetched until the drop target reads", requests.isEmpty()) + assertEquals(ORIGINAL_SIZE.toLong(), reader.onGetSize()) + + val buffer = ByteArray(16) + assertEquals(16, reader.onRead(0L, 16, buffer)) + assertArrayEquals(original.copyOfRange(0, 16), buffer) + assertEquals(16, reader.onRead(FAR_OFFSET, 16, buffer)) + assertArrayEquals(original.copyOfRange(FAR_OFFSET.toInt(), FAR_OFFSET.toInt() + 16), buffer) + + assertEquals(listOf("bytes=0-", "bytes=$FAR_OFFSET-"), requests.map { it.header("Range") }) + requests.forEach { request -> + assertEquals("Token session-token", request.header("Authorization")) + assertFalse(request.url.toString().contains("session-token")) + assertEquals("/v2/files/${POSTER.id.value}/download", request.url.encodedPath) + } + } + + @Test + fun theProviderNamesTypesAndRefusesWritingADrag() { + val key = MobileFileDrags.begin(SESSION, POSTER) + val uri = mobileFileDragClip(authority, key, POSTER.name, "image/jpeg").getItemAt(0).uri + + requireNotNull(context.contentResolver.query(uri, null, null, null, null)).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("poster.jpg", cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))) + assertEquals(ORIGINAL_SIZE.toLong(), cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))) + } + assertEquals("image/jpeg", context.contentResolver.getType(uri)) + assertThrows(SecurityException::class.java) { context.contentResolver.openFileDescriptor(uri, "w") } + } + + @Test + fun endingTheSessionStopsReadsAndOnlyItsSessionOpensADrag() { + val key = MobileFileDrags.begin(SESSION, POSTER) + val reader = MobileFileDrags.open(key, dependencies) {} + assertEquals(4, reader.onRead(0L, 4, ByteArray(4))) + + auth.value = signedIn(OTHER) + assertThrows(ErrnoException::class.java) { reader.onRead(4L, 4, ByteArray(4)) } + assertThrows(FileNotFoundException::class.java) { MobileFileDrags.open(key, dependencies) {} } + + auth.value = signedIn(SESSION) + val again = MobileFileDrags.open(key, dependencies) {} + MobileFileDrags.endSession(SESSION.sessionId) + assertThrows(ErrnoException::class.java) { again.onRead(0L, 4, ByteArray(4)) } + assertThrows(FileNotFoundException::class.java) { MobileFileDrags.open(key, dependencies) {} } + } + + @Test + fun aDragNobodyTookIsForgottenAndAnotherSessionsDragsStay() { + val untaken = MobileFileDrags.begin(SESSION, POSTER) + val later = MobileFileDrags.begin(OTHER, POSTER) + + MobileFileDrags.forget(untaken) + MobileFileDrags.endSession(SESSION.sessionId) + + assertNull(MobileFileDrags[untaken]) + assertTrue(MobileFileDrags[later] != null) + assertTrue(requests.isEmpty()) + } + + /** put.io's download endpoint answering a range from the request's offset. */ + private fun ranged(request: Request): Response { + requests += request + val start = request.header("Range")?.removePrefix("bytes=")?.substringBefore('-')?.toInt() ?: 0 + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(PARTIAL_CONTENT) + .message("Partial Content") + .header("Content-Range", "bytes $start-${original.size - 1}/${original.size}") + .body(original.copyOfRange(start, original.size).toResponseBody("image/jpeg".toMediaType())) + .build() + } + + private companion object { + const val ORIGINAL_SIZE = 600_000 + const val FAR_OFFSET = 500_000L + const val PARTIAL_CONTENT = 206 + val SESSION = MobileSessionKey(1L, MobileAuthSessionId(1L)) + val OTHER = MobileSessionKey(1L, MobileAuthSessionId(2L)) + val POSTER = FilesItem( + FilesItemId(9L), FilesItemId(0L), "poster.jpg", PutioFileType.IMAGE, ORIGINAL_SIZE.toLong(), "2026-10-04", + ) + + fun signedIn(session: MobileSessionKey) = MobileAuthState.SignedIn( + account = MobileAccount(userId = session.userId, username = "someone", email = "someone@example.com"), + sessionId = session.sessionId, + ) + } +}