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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 66 additions & 12 deletions build-logic/src/main/kotlin/RobolectricOffline.kt
Original file line number Diff line number Diff line change
@@ -1,41 +1,95 @@
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<VersionCatalogsExtension>().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>("linkRobolectricAndroidAll") {
jars.from(androidAll)
directory.set(layout.buildDirectory.dir("robolectric-android-all"))
}
tasks.withType<Test>().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)
}
}
}
}

class RobolectricOfflineArguments(
@get:InputFiles
@get:PathSensitive(PathSensitivity.NAME_ONLY)
val androidAll: FileCollection,
@get:Internal
val directory: Provider<Directory>,
) : CommandLineArgumentProvider {
override fun asArguments(): Iterable<String> = listOf(
"-Drobolectric.offline=true",
"-Drobolectric.dependency.dir=${androidAll.singleFile.parent}",
"-Drobolectric.dependency.dir=${directory.get().asFile}",
)
}
109 changes: 109 additions & 0 deletions docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<application id>.drag/<random key>/<safe name>` 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
Expand Down
48 changes: 48 additions & 0 deletions docs/harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`, then `boot`), with a
hardware keyboard and a tablet-size display:

```bash
sed -i '' 's/^hw.keyboard=no$/hw.keyboard=yes/' ~/.android/avd/<name>.avd/config.ini # before booting
adb -s <serial> shell wm size 2560x1600 && adb -s <serial> 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=<UUID>` and
`putio.tablet.video=<absolute device path>`; 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-<UUID>/`. 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
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
16 changes: 16 additions & 0 deletions mobile/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The tablet proof's other app: it runs in the test package's own process, beside put.io. -->
<application>
<activity
android:name="io.putdotio.android.probe.DragProbeActivity"
android:exported="true"
android:label="Drag probe"
android:resizeableActivity="true"
android:theme="@android:style/Theme.Material.NoActionBar" />
<provider
android:name="io.putdotio.android.probe.DragProbeProvider"
android:authorities="io.putdotio.probe.files"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>
Loading