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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ dependencies {
// Drive: "Scannen" (Google's document scanner and text recognition, both through Play services: no models in the APK)
implementation(libs.mlkit.document.scanner)
implementation(libs.mlkit.text.recognition)
// Drive: work photos apart — image labeling on the phone (Play services, no model in the APK, no picture leaves the phone)
implementation(libs.mlkit.image.labeling)

// Vault: end-to-end encryption (README "Kryptoformat" of gatecontrol-vault) and the recovery key's QR
implementation(libs.bouncycastle.prov)
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@
android:theme="@style/Theme.GateControl"
tools:replace="android:theme">

<!-- Drive "Scannen": Play services fetch the text recognition model with the app, not on the first scan. -->
<!-- Drive "Scannen" and work photos: Play services fetch the text recognition and the image labeling model
with the app, not on the first use. -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="ocr" />
android:value="ocr,ica" />

<activity
android:name=".MainActivity"
Expand Down Expand Up @@ -397,6 +398,13 @@
android:theme="@style/Theme.GateControl"
android:windowSoftInputMode="adjustResize" />

<!-- Drive work photos: Android's wake for a new WLAN (the app's own network
request) and the buttons of the "Neues WLAN" notification. NOT exported:
only the app's own PendingIntents reach it. -->
<receiver
android:name=".drive.work.DriveWorkWifiReceiver"
android:exported="false" />

<!-- GateDrop: buttons of its notifications (Kopieren, Speichern).
NOT exported: only the app's own PendingIntents reach it. -->
<receiver
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/com/gatecontrol/android/GateControlApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ class GateControlApp : Application() {
Timber.e(e, "Failed to start the trusted-network monitor")
}

// Drive work photos: the WLAN timeline (needs the monitor above; Android drops the WLAN wake on reboot).
try {
EntryPointAccessors.fromApplication(this, com.gatecontrol.android.drive.work.DriveWorkEntryPoint::class.java).driveWorkWifi().start()
} catch (e: Throwable) {
Timber.w(e, "Drive work: WLAN timeline not started")
}

// Drive photo backup: its work is scheduled while it is on (it may have been lost).
try {
com.gatecontrol.android.drive.backup.DriveBackupWork.ensure(this)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/gatecontrol/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class MainActivity : FragmentActivity() {
pendingSetupLink = pendingSetupLink,
onSetupLinkConsumed = { pendingSetupLink = null },
)
// Drive work photos: "Wo bist du gerade?" for a new WLAN, over whatever screen is open.
if (isSetupComplete) com.gatecontrol.android.ui.drive.DriveWorkAskHost()
// Google Play build: the update sheet, over whatever screen is open.
if (isSetupComplete && com.gatecontrol.android.update.PlayUpdater.enabled) {
com.gatecontrol.android.ui.update.PlayUpdateSheetHost()
Expand Down
39 changes: 35 additions & 4 deletions app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class DriveApi @Inject constructor(
const val CODE_INVALID = "invalid_answer"
/** At most this many ids per album call (plugin README). */
private const val ALBUM_IDS_MAX = 500
/** At most this many ids per `photos/review` (Drive 1.13). */
private const val REVIEW_IDS_MAX = 500
private const val TIMEOUT_MS = 25_000L
/** Small files go in one piece. */
private const val RESUMABLE_FROM = 8L * 1024 * 1024
Expand Down Expand Up @@ -114,6 +116,9 @@ class DriveApi @Inject constructor(
private suspend fun <T> post(path: String, body: JsonObject, parse: (JsonObject) -> T?) =
call(request = { pluginPost(DRIVE_PLUGIN, path, body, lang()) }, parse = parse)

private suspend fun <T> put(path: String, body: JsonObject, parse: (JsonObject) -> T?) =
call(request = { pluginPut(DRIVE_PLUGIN, path, body, lang()) }, parse = parse)

private suspend fun <T> patch(path: String, body: JsonObject, parse: (JsonObject) -> T?) =
call(request = { pluginPatch(DRIVE_PLUGIN, path, body, lang()) }, parse = parse)

Expand Down Expand Up @@ -247,11 +252,37 @@ class DriveApi @Inject constructor(
* Photos and videos, newest first; [before] is the cursor of the page before. Without [area]: all I can read.
* [camera]: only the camera uploads (Drive 1.10, feature `photosFolder`).
*/
suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false): DResult<DPhotos> =
get("photos", DriveJson.photosQuery(area, before, limit, camera) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } }
suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false, cls: String? = null): DResult<DPhotos> =
get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } }

suspend fun photoMonths(area: String? = null, camera: Boolean = false, cls: String? = null): DResult<List<DMonth>> =
get("photos/months", DriveJson.photosQuery(area, camera = camera, cls = cls) ?: return bad(), DriveJson::months)

// ─── work photos (Drive 1.13, feature `photoClasses`) ───────────

/** Where my work photos go, for every device and the portal. */
suspend fun photoSettings(): DResult<DPhotoSettings> = get("photos/settings", parse = DriveJson::photoSettings)

/** Changes only what is given; [clearFolder]: no work folder any more. */
suspend fun putPhotoSettings(workFolder: Long? = null, clearFolder: Boolean = false, workSubfolders: Boolean? = null, workInMemories: Boolean? = null): DResult<DPhotoSettings> =
put("photos/settings", DriveJson.photoSettingsBody(workFolder, clearFolder, workSubfolders, workInMemories), DriveJson::photoSettings)

/** Sets or clears the review mark of [ids] (at most 500 a call, so in parts); [hint] the AI's "work" in percent. */
suspend fun photoReview(ids: List<Long>, review: Boolean, hint: Int? = null): DResult<Map<Long, DReview?>> {
if (ids.isEmpty()) return bad()
val out = HashMap<Long, DReview?>()
for (part in ids.distinct().chunked(REVIEW_IDS_MAX)) {
when (val r = post("photos/review", DriveJson.reviewBody(part, review, hint), DriveJson::reviewAnswer)) {
is DResult.Ok -> out.putAll(r.value)
else -> return r.notOk()
}
}
return DResult.Ok(out)
}

suspend fun photoMonths(area: String? = null, camera: Boolean = false): DResult<List<DMonth>> =
get("photos/months", DriveJson.photosQuery(area, camera = camera) ?: return bad(), DriveJson::months)
/** How many photos are work, private, to be reviewed (the chips' counts). */
suspend fun photoClasses(camera: Boolean = false): DResult<DPhotoClasses> =
get("photos/classes", DriveJson.photosQuery(camera = camera) ?: return bad(), DriveJson::photoClasses)

/** Photos of this calendar day ([date] `YYYY-MM-DD`, else the server's today) in earlier years. */
suspend fun memories(date: String? = null, camera: Boolean = false): DResult<List<DMemories>> =
Expand Down
58 changes: 57 additions & 1 deletion app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ internal object DriveJson {
takenAt = o.str("takenAt")?.take(40),
deleteAt = o.str("deleteAt")?.take(40),
insight = o.obj("insight")?.let(::insightShort),
review = o.obj("review")?.let { DReview(it.int("hint")?.coerceIn(0, 100)) },
work = o.bool("work") == true,
).let { n ->
// a node of a vault (Drive 1.9): its name is opaque, the encrypted one comes as nameCipher
val vault = vaultId(o.str("vaultId"))
Expand Down Expand Up @@ -446,15 +448,69 @@ internal object DriveJson {
* [limit] (1–200), the day of the memories ([date]) — and `camera=1` for only the camera uploads (Drive 1.10,
* feature `photosFolder`). Null for an invalid area.
*/
fun photosQuery(area: String? = null, before: String? = null, limit: Int? = null, camera: Boolean = false, date: String? = null): Map<String, String>? =
fun photosQuery(
area: String? = null,
before: String? = null,
limit: Int? = null,
camera: Boolean = false,
date: String? = null,
cls: String? = null,
): Map<String, String>? =
buildMap {
if (area != null) put("area", areaId(area) ?: return null)
if (before != null) put("before", before)
if (limit != null) put("limit", limit.coerceIn(1, 200).toString())
if (date != null) put("date", date)
if (camera) put("camera", "1")
if (cls != null) put("class", cls.takeIf { it in PHOTO_CLASSES } ?: return null)
}

/** The values of `class` (Drive 1.13). */
val PHOTO_CLASSES = setOf("work", "private", "review")

/** `GET|PUT photos/settings` → the work folder and its switches (Drive 1.13); null without the switches. */
fun photoSettings(o: JsonObject): DPhotoSettings? {
if (!o.has("workFolder") && !o.has("workSubfolders")) return null
val folder = o.obj("folder")
val id = o.long("workFolder")?.takeIf { it > 0 }
return DPhotoSettings(
workFolder = id,
workSubfolders = o.bool("workSubfolders") ?: true,
workInMemories = o.bool("workInMemories") ?: false,
folderPath = folder?.takeIf { id != null }?.str("path")?.take(2000),
folderAreaId = folder?.takeIf { id != null }?.let { areaId(it.str("areaId")) },
)
}

/** `PUT photos/settings`: only the fields given ([clearFolder] sends `workFolder: null`). */
fun photoSettingsBody(workFolder: Long? = null, clearFolder: Boolean = false, workSubfolders: Boolean? = null, workInMemories: Boolean? = null) =
JsonObject().apply {
if (clearFolder) add("workFolder", com.google.gson.JsonNull.INSTANCE) else if (workFolder != null) addProperty("workFolder", workFolder)
if (workSubfolders != null) addProperty("workSubfolders", workSubfolders)
if (workInMemories != null) addProperty("workInMemories", workInMemories)
}

/** `POST photos/review { ids, review, hint? }` (1 … 500 ids; [hint] 0 … 100). */
fun reviewBody(ids: List<Long>, review: Boolean, hint: Int?) = JsonObject().apply {
add("ids", idsArray(ids))
addProperty("review", review)
if (review && hint != null) addProperty("hint", hint.coerceIn(0, 100))
}

/** `POST photos/review` → each node's mark now (null: none); null without `items`. */
fun reviewAnswer(o: JsonObject): Map<Long, DReview?>? = o.arr("items")?.mapNotNull { e ->
val i = e as? JsonObject ?: return@mapNotNull null
val id = i.long("id") ?: return@mapNotNull null
id to i.obj("review")?.let { DReview(it.int("hint")?.coerceIn(0, 100)) }
}?.toMap()

/** `GET photos/classes` → `{ work, private, review }`; null when one is missing. */
fun photoClasses(o: JsonObject): DPhotoClasses? = DPhotoClasses(
work = o.int("work")?.coerceAtLeast(0) ?: return null,
private = o.int("private")?.coerceAtLeast(0) ?: return null,
review = o.int("review")?.coerceAtLeast(0) ?: return null,
)

/** `GET photos/memories` → `{ groups: [{ yearsAgo, date, items }] }`; groups without photos are left out. */
fun memories(o: JsonObject): List<DMemories>? = o.arr("groups")?.mapNotNull { e ->
val g = e as? JsonObject ?: return@mapNotNull null
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ data class DMe(
const val FEATURE_COPY_SERVER = "copyServer"
/** Drive 1.12: a ZIP and one link of any selection (folders and files mixed, more than 20). */
const val FEATURE_ZIP_MIXED = "zipMixed"
/**
* Drive 1.13: work photos apart (`photos/settings` with the work folder, the review mark `photos/review`, the
* filter `class=work|private|review` and the counts `photos/classes`).
*/
const val FEATURE_PHOTO_CLASSES = "photoClasses"
}
}

Expand Down Expand Up @@ -191,11 +196,35 @@ data class DNode(
val vaultId: String? = null,
val nameCipher: String? = null,
val keyVersion: Int? = null,
/** Drive 1.13 (`photoClasses`): marked "zu prüfen" (work or private?), with the AI's hint; null when not marked. */
val review: DReview? = null,
/** Drive 1.13: a photo item below my work folder (the small blue bag). */
val work: Boolean = false,
) {
/** In a vault: nothing of it may go to a preview, a link, the office, the AI or a backup. */
val encrypted: Boolean get() = vaultId != null
}

/** The review mark of a photo (Drive 1.13): [hint] the AI's probability "work" in percent, null when it had none. */
data class DReview(val hint: Int?)

/**
* `GET|PUT photos/settings` (Drive 1.13): where work photos go ([workFolder], its path [folderPath] in [folderAreaId]),
* whether below it by year and month, whether they may show in the memories and the photo frame.
*/
data class DPhotoSettings(
val workFolder: Long? = null,
val workSubfolders: Boolean = true,
val workInMemories: Boolean = false,
val folderPath: String? = null,
val folderAreaId: String? = null,
)

/** `GET photos/classes`: how many photos are work, private and to be reviewed. */
data class DPhotoClasses(val work: Int, val private: Int, val review: Int) {
val all: Int get() = work + private + review
}

/** `DELETE nodes/<id>?after=`: the file goes into the trash at [deleteAt] (ISO 8601; null when the server did not say). */
data class DMark(val deleteAt: String?)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ class DriveBackup @Inject constructor(
private val live: DriveLive,
private val dates: DriveTakenAt,
private val camera: DriveCameraUploads,
private val work: com.gatecontrol.android.drive.work.DriveWorkPhotos,
private val wifi: com.gatecontrol.android.drive.work.DriveWorkWifi,
) {
/** The run in progress: [done] of [total] photos, [current] the one going up. */
data class Status(val running: Boolean = false, val done: Int = 0, val total: Int = 0, val current: String? = null)
Expand Down Expand Up @@ -213,6 +215,11 @@ class DriveBackup @Inject constructor(
if (!canRead(cfg.videos)) return stop("no_permission")
// Stopped by Drive's ransomware protection: no photo is tried until it is lifted (that starts a pass).
if (live.locked.value) return stop("device_locked")
// work photos apart (Drive 1.13): the settings as the server has them, the WLAN now on the timeline
if (work.settings.value.active || live.supports(DMe.FEATURE_PHOTO_CLASSES)) {
work.refreshSettings()
runCatching { wifi.sample() }
}
var done = 0
while (true) {
val mark = mark() ?: DriveBackupMark.ALL
Expand Down Expand Up @@ -300,15 +307,38 @@ class DriveBackup @Inject constructor(
// when it was taken — EXIF (with its time zone), else the media store's date: its year and month are its folder
val own = dates.of(item.uri, item.mime, item.name)
val path = DriveBackupLogic.cameraPath(own, item.taken, item.modified, item.added * 1000, ZoneId.systemDefault())
var folder = when (val f = camera.folder(path)) {
// work photos apart: where this one goes (the WLAN it was taken in, else the AI on the phone); null: Kamera-Uploads
var placed = try {
// the plain URI: the thumbnail does not need the original's location data
val plain = ContentUris.withAppendedId(if (item.video) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI, item.id)
work.placeNew(plain, DriveBackupLogic.takenMs(own, item.taken, item.modified), item.video)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.d("Drive backup: not placed (%s)", e.javaClass.simpleName)
null
}
suspend fun target(): DResult<Long> {
val p = placed
if (p != null && p.placement.dest == com.gatecontrol.android.drive.work.DriveWorkLogic.Dest.WORK) {
when (val w = work.workFolderFor(path)) {
is DResult.Ok -> return w
// the work folder is gone or read-only: the photo goes the private way, to be reviewed
is DResult.Failed -> if (w.code in WORK_GONE) placed = p.copy(placement = DriveBackupLogic.reviewInstead(p.placement)) else return w
else -> return w
}
}
return camera.folder(path)
}
var folder = when (val f = target()) {
is DResult.Ok -> f.value
else -> return code(f)
}
var check = api.uploadCheck(folder, item.name, sha, length)
if (check is DResult.Failed && check.code == "not_found") {
// the month's folder (or Kamera-Uploads) went away since: made again
camera.forget()
folder = when (val f = camera.folder(path)) {
folder = when (val f = target()) {
is DResult.Ok -> f.value
else -> return code(f)
}
Expand All @@ -325,7 +355,10 @@ class DriveBackup @Inject constructor(
// Drive 1.7: the commit takes when it was taken
val takenAt = if (live.supports(DMe.FEATURE_TAKEN_AT)) own ?: DriveLogic.msTakenAt(item.taken) else null
return when (val r = api.commit(media.value, folder, item.name, item.mtime, DriveLogic.ON_CONFLICT_RENAME, takenAt = takenAt)) {
is DResult.Ok -> null
is DResult.Ok -> {
placed?.let { p -> runCatching { work.afterBackup(r.value.first, p, item.id, item.video) }.onFailure { if (it is CancellationException) throw it } }
null
}
else -> {
if (r is DResult.Failed && r.code == "not_found") camera.forget()
code(r)
Expand Down Expand Up @@ -397,5 +430,7 @@ class DriveBackup @Inject constructor(
private const val KEY_SAVED = "saved"
private const val KEY_LAST = "last"
private const val KEY_ERROR = "error"
/** The work folder cannot take the photo (gone, read-only, a file in its way). */
private val WORK_GONE = setOf("not_found", "forbidden", "no_folder", "no_work_folder")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ internal object DriveBackupLogic {

private val YEARS = 1900..9999

/**
* When a photo was taken (ms) for the WLAN timeline: the file's own date ([takenAt], ISO 8601 with its offset),
* else the media store's date taken, else when it was changed; null when unknown.
*/
fun takenMs(takenAt: String?, takenMs: Long?, modifiedMs: Long?): Long? =
takenAt?.let { s -> runCatching { OffsetDateTime.parse(s).toInstant().toEpochMilli() }.getOrNull() }
?: takenMs?.takeIf { it > 0 }
?: modifiedMs?.takeIf { it > 0 }

/** The work folder cannot take a photo: it goes the private way, marked to be reviewed (its hint kept). */
fun reviewInstead(p: com.gatecontrol.android.drive.work.DriveWorkLogic.Placement) =
p.copy(dest = com.gatecontrol.android.drive.work.DriveWorkLogic.Dest.PRIVATE, review = true, learn = null, why = com.gatecontrol.android.drive.work.DriveWorkLogic.Why.REVIEW)

/**
* Where a backup that is switched on starts: where it got to ([have]) — the photos before are in Drive already
* (wherever they went then), so they are never sent again, also not into a new folder — else all photos or only
Expand Down
Loading
Loading