Android's only export destination was the library on the server
(ExportTarget::available), because writing to the device goes through
the Storage Access Framework and nothing did. An album's folder on the
tablet is now chosen in the system's tree picker — which has its own
"Create new folder" — and exports are written into it with
DocumentsContract.
The picker answers through onActivityResult, and the main activity is
NativeActivity, whose result is not ours. FolderPicker is a translucent
activity that only asks: it starts ACTION_OPEN_DOCUMENT_TREE, takes a
persistable grant (a folder is chosen once and exported to for months),
leaves the URI in a static, and finishes. Rust polls it from a Slint
timer — one static call, rather than a registered native method and a
thread to deliver on.
Two things the first build on the tablet got wrong, recorded where they
are fixed:
- Our classes must be loaded through Context.getClassLoader(). The
class of what ndk_context holds is a framework class from the boot
loader, which reports every class in the APK as not found.
- What ndk_context holds is the application context, not the activity,
and starting an activity from it throws without FLAG_ACTIVITY_NEW_TASK.
Saf.write creates the document (or, under Overwrite, reopens the one of
that name with "wt" so a shorter file does not keep the old tail) and
returns the name the provider actually gave it, since SAF renames on a
collision by itself; the album records that name. A tree URI reads in
the sidebar as its folder ("Pictures/Web"), not as a content:// string.
201 lines
11 KiB
XML
201 lines
11 KiB
XML
<?xml version="1.0" encoding="utf-8"?>
|
|
<!--
|
|
DarkRoom Android manifest.
|
|
|
|
Deliberately minimal: this packages the viewer for on-device testing (spike
|
|
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
|
here is a distribution manifest yet. Only network access is declared: file
|
|
access needs no manifest permission because the library grid reads through
|
|
SAF, which grants per-tree at runtime (ARCH §6.9).
|
|
|
|
Minimal is not the same as empty, and the entries below that are not the
|
|
activity are the difference. A manifest is the only place a component can be
|
|
declared: an intent filter is how the system learns this app is worth
|
|
offering for a photograph, and a provider is how it learns the class exists
|
|
at all. Neither can be moved into code (FR-PLAT-AND-6).
|
|
-->
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
package="paris.tourolle.darkroom">
|
|
|
|
<!-- Everything the app does with a server needs this: Login Flow v2, the
|
|
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
|
socket creation outright, and the failure is invisible — no panic to
|
|
catch, no log line, just a worker thread that stops. Storage is the
|
|
separate case that genuinely needs no permission here, because SAF
|
|
grants per-tree at runtime (ARCH §6.9). -->
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
|
work on unmetered-and-charging, which means knowing the network type. -->
|
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
|
|
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
|
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
|
fails at install rather than at first frame. -->
|
|
<uses-feature
|
|
android:name="android.hardware.vulkan.version"
|
|
android:version="0x00401000"
|
|
android:required="true" />
|
|
|
|
<!-- One name covers both icon generations, which is the point of the
|
|
`anydpi-v26` qualifier: @mipmap/ic_launcher resolves to the adaptive
|
|
icon at res/mipmap-anydpi-v26/ic_launcher.xml on API 26 and up, and to
|
|
the density-matched ic_launcher.png below that. Since minSdk is 28 the
|
|
PNGs are only ever reached by tooling, but they cost little and aapt2
|
|
wants a real drawable behind the name. `roundIcon` is deliberately
|
|
absent: it predates adaptive icons and a launcher that reads it would
|
|
also be one that ignores the XML, which no device here is.
|
|
|
|
The adaptive icon has three layers rather than two. The third,
|
|
monochrome, is what lets Android 13's themed-icon setting recolour it
|
|
instead of dropping the app out of the themed set. -->
|
|
<application
|
|
android:label="DarkRoom"
|
|
android:icon="@mipmap/ic_launcher"
|
|
android:hasCode="true"
|
|
android:allowBackup="false"
|
|
android:supportsRtl="true">
|
|
|
|
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
|
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
|
|
is how it learns which library to load, and must match [lib].name.
|
|
|
|
`singleTask` because a second instance of this activity is not
|
|
survivable. The intent filters below mean another app can now
|
|
launch it while it is already running, and under the default
|
|
launch mode that starts a *second* NativeActivity — in the
|
|
caller's task, in this same process, calling android_main again.
|
|
Two Slint backends and two wgpu devices in one process is not a
|
|
degraded experience, it is a failed second launch on top of a
|
|
working first one.
|
|
|
|
What it costs, stated plainly: a share that arrives while DarkRoom
|
|
is already running brings it forward without opening the image.
|
|
The Intent goes to `onNewIntent`, and android-activity's event
|
|
stream has no variant for it (MainEvent in 0.6 stops at Destroy),
|
|
so nothing native ever sees it. Reading it would mean a Java
|
|
Activity subclass forwarding it across JNI — the same shape of
|
|
change FR-PLAT-AND-5 declined for onTrimMemory, and for the same
|
|
reason. Launched from cold, which is the ordinary case for "open
|
|
this photograph", the Intent is on getIntent() and is read. -->
|
|
<activity
|
|
android:name="android.app.NativeActivity"
|
|
android:exported="true"
|
|
android:launchMode="singleTask"
|
|
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
|
|
android:windowSoftInputMode="adjustResize">
|
|
|
|
<meta-data
|
|
android:name="android.app.lib_name"
|
|
android:value="darkroom" />
|
|
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN" />
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
</intent-filter>
|
|
|
|
<!-- FR-PLAT-AND-6, inbound. The traceability tool reads .rs,
|
|
.slint, .wgsl and .yaml, so this is a reference and not a
|
|
tag; the tag that counts is on the test in lib.rs that
|
|
asserts these declarations are still here.
|
|
|
|
Opening a photograph from a gallery, a file manager or a
|
|
download. `android_main` reads the launch Intent through
|
|
`Intents.receive` and the named images become the browsing
|
|
list, exactly as paths on the desktop command line do.
|
|
|
|
`image/*` and not a wider match, even though it misses raws:
|
|
a provider that does not recognise `.CR3` reports it as
|
|
`application/octet-stream`, and claiming that type would put
|
|
DarkRoom in the chooser for every unidentified binary on the
|
|
device — an APK, a database, a partial download. Being absent
|
|
from one gallery's menu is a smaller failure than being
|
|
present in all of them. DNG, which providers do know as
|
|
`image/x-adobe-dng`, matches here already.
|
|
|
|
BROWSABLE is what lets a browser's finished download and a
|
|
link hand the file over; without it those routes silently do
|
|
not list the app. -->
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data android:mimeType="image/*" />
|
|
</intent-filter>
|
|
|
|
<!-- The share sheet, one photograph or a selection of them.
|
|
SEND_MULTIPLE is declared because the sheet offers this app
|
|
for a multi-selection only if it says it accepts one, and a
|
|
culling tool that can be sent a single frame and not a burst
|
|
is the wrong way round.
|
|
|
|
ACTION_EDIT is deliberately not here. It is a promise to write
|
|
the result back to the URI it was handed, and nothing in this
|
|
app does: an edit lands in a sidecar beside the original
|
|
(FR-CAT-8). Registering for it would put DarkRoom in the "edit
|
|
with" menu and lose the user's work every time. -->
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.SEND" />
|
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<data android:mimeType="image/*" />
|
|
</intent-filter>
|
|
</activity>
|
|
|
|
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
|
|
carries in assets/manual. See ManualActivity.java for why it is
|
|
not the browser.
|
|
|
|
Not exported: nothing outside this app has a reason to start it,
|
|
and dr_ui starts it by class name, which needs no intent filter.
|
|
Its own task entry is not wanted either — it is a page over the
|
|
app, and Back returns to the photograph it was opened from.
|
|
configChanges so a rotation reflows the page rather than
|
|
reloading it at the top. -->
|
|
<activity
|
|
android:name="paris.tourolle.darkroom.ManualActivity"
|
|
android:exported="false"
|
|
android:label="DarkRoom manual"
|
|
android:theme="@style/ManualTheme"
|
|
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
|
|
|
<!-- FR-EXP-10: the system's folder picker, for an album's folder on
|
|
this device. NativeActivity's onActivityResult is not ours, so
|
|
this activity exists only to ask and hand the answer back (see
|
|
FolderPicker.java). Translucent and without a title so nothing
|
|
of it shows but the system chooser; not exported, and started by
|
|
class name from dr_ui::saf. -->
|
|
<activity
|
|
android:name="paris.tourolle.darkroom.FolderPicker"
|
|
android:exported="false"
|
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
|
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
|
|
|
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
|
between apps since API 24 — handing one out raises
|
|
FileUriExposedException in *this* process — so an exported JPEG
|
|
reaches the share sheet as a content:// URI or not at all.
|
|
|
|
Not AndroidX's FileProvider: that is a Maven artefact, and this
|
|
build has no Gradle and no dependency resolver (docker/android/
|
|
README.md). ExportProvider does the same hundred lines against one
|
|
fixed root.
|
|
|
|
`exported="false"` with `grantUriPermissions="true"` is the whole
|
|
security model, and the two halves are not redundant. Exported
|
|
false means no app may address the provider on its own account;
|
|
the grant flag means a URI this app puts in an Intent carries a
|
|
read permission for that one file, for the lifetime of the
|
|
receiving task. Without the grant flag the share sheet opens and
|
|
every target fails with SecurityException; with `exported="true"`
|
|
instead, every app on the device could read the app's private
|
|
directory. The authority must equal ExportProvider.AUTHORITY — a
|
|
mismatch is a SecurityException in somebody else's app, so a test
|
|
in lib.rs compares the two strings. -->
|
|
<provider
|
|
android:name="paris.tourolle.darkroom.ExportProvider"
|
|
android:authorities="paris.tourolle.darkroom.exports"
|
|
android:exported="false"
|
|
android:grantUriPermissions="true" />
|
|
</application>
|
|
</manifest>
|