From 8b4b86f9b69b68b33c486757990ede19348cc00c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:00:28 +0000 Subject: [PATCH 1/2] Drive: Arbeitsfotos getrennt sichern (Drive 1.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Foto-Sicherung ordnet neue Fotos ein: Firmen-WLAN → Arbeitsordner, Zuhause-WLAN → Kamera-Uploads, sonst KI auf dem Gerät; Lernphase oder unsicher → privat mit Prüfen-Marker - WLAN-Zeitleiste (Netzwerk-Callback der vertrauenswürdigen WLANs, Wecker per PendingIntent, Proben bei neuen Fotos und jedem Lauf), nur auf dem Gerät - Fotos-Tab: Alle | Privat | Arbeit | Prüfen (N), Prüfen-Marker und Tasche, Hinweisband, Arbeit/Privat vorne in der Leiste, Zuordnung im Mehr-Sheet, Snackbar mit Rückgängig - Lernen auf dem Gerät (ML Kit Image Labeling über Play-Dienste + logistische Regression), Lernstand, Neu lernen, Aufräumen-Bildschirm - Neues WLAN: Benachrichtigung und Dialog „Wo bist du gerade?“ - Einstellungen: Ziele, WLANs, KI für Arbeitsfotos, Auf dem Handy - Ohne photoClasses: Einstellung und Marker lokal, Server-Filter ausgegraut Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 12 +- .../com/gatecontrol/android/GateControlApp.kt | 7 + .../com/gatecontrol/android/MainActivity.kt | 2 + .../com/gatecontrol/android/drive/DriveApi.kt | 39 +- .../gatecontrol/android/drive/DriveJson.kt | 58 +- .../gatecontrol/android/drive/DriveModels.kt | 29 + .../android/drive/backup/DriveBackup.kt | 39 +- .../android/drive/backup/DriveBackupLogic.kt | 13 + .../android/drive/backup/DriveBackupWork.kt | 3 + .../drive/backup/DriveCameraUploads.kt | 20 + .../android/drive/work/DriveWifiTimeline.kt | 110 +++ .../android/drive/work/DriveWorkClassifier.kt | 169 ++++ .../android/drive/work/DriveWorkLabeler.kt | 90 ++ .../android/drive/work/DriveWorkLogic.kt | 180 ++++ .../android/drive/work/DriveWorkPhotos.kt | 440 +++++++++ .../android/drive/work/DriveWorkStore.kt | 292 ++++++ .../android/drive/work/DriveWorkWifi.kt | 251 ++++++ .../android/navigation/AppNavigation.kt | 6 +- .../gatecontrol/android/navigation/Screen.kt | 2 + .../android/ui/drive/DriveBackupScreen.kt | 14 +- .../android/ui/drive/DriveBulkActions.kt | 4 + .../android/ui/drive/DriveBulkUi.kt | 8 + .../android/ui/drive/DriveComponents.kt | 4 +- .../android/ui/drive/DriveFolderChooser.kt | 8 + .../android/ui/drive/DriveFolderScreen.kt | 9 +- .../android/ui/drive/DriveHomeScreen.kt | 4 +- .../android/ui/drive/DrivePhotosUi.kt | 253 +++++- .../android/ui/drive/DrivePhotosViewModels.kt | 86 +- .../android/ui/drive/DriveViewModels.kt | 3 + .../android/ui/drive/DriveWorkCleanup.kt | 275 ++++++ .../android/ui/drive/DriveWorkUi.kt | 837 ++++++++++++++++++ app/src/main/res/values-de/strings_drive8.xml | 170 ++++ app/src/main/res/values/strings_drive8.xml | 170 ++++ .../android/drive/DriveJsonWorkTest.kt | 71 ++ .../drive/backup/DriveBackupLogicTest.kt | 17 + .../drive/work/DriveWifiTimelineTest.kt | 83 ++ .../drive/work/DriveWorkClassifierTest.kt | 91 ++ .../android/drive/work/DriveWorkLogicTest.kt | 188 ++++ gradle/libs.versions.toml | 2 + 40 files changed, 4019 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWifiTimeline.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkClassifier.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLogic.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt create mode 100644 app/src/main/res/values-de/strings_drive8.xml create mode 100644 app/src/main/res/values/strings_drive8.xml create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveJsonWorkTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/work/DriveWifiTimelineTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkClassifierTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkLogicTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c7050ba..9d017fb8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2a66e4ae..08238547 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,10 +70,11 @@ android:theme="@style/Theme.GateControl" tools:replace="android:theme"> - + + android:value="ocr,ica" /> + + + post(path: String, body: JsonObject, parse: (JsonObject) -> T?) = call(request = { pluginPost(DRIVE_PLUGIN, path, body, lang()) }, parse = parse) + private suspend fun put(path: String, body: JsonObject, parse: (JsonObject) -> T?) = + call(request = { pluginPut(DRIVE_PLUGIN, path, body, lang()) }, parse = parse) + private suspend fun patch(path: String, body: JsonObject, parse: (JsonObject) -> T?) = call(request = { pluginPatch(DRIVE_PLUGIN, path, body, lang()) }, parse = parse) @@ -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 = - 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 = + 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> = + 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 = 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 = + 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, review: Boolean, hint: Int? = null): DResult> { + if (ids.isEmpty()) return bad() + val out = HashMap() + 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> = - 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 = + 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> = diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt index e48bbe26..5c155faf 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -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")) @@ -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? = + fun photosQuery( + area: String? = null, + before: String? = null, + limit: Int? = null, + camera: Boolean = false, + date: String? = null, + cls: String? = null, + ): Map? = 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, 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? = 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? = o.arr("groups")?.mapNotNull { e -> val g = e as? JsonObject ?: return@mapNotNull null diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt index d540fb63..837fca30 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -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" } } @@ -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/?after=`: the file goes into the trash at [deleteAt] (ISO 8601; null when the server did not say). */ data class DMark(val deleteAt: String?) diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt index e42c80c3..b6c6aa21 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt @@ -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) @@ -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 @@ -300,7 +307,28 @@ 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 { + work.placeNew(item.uri, 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 { + 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) } @@ -308,7 +336,7 @@ class DriveBackup @Inject constructor( 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) } @@ -325,7 +353,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) @@ -397,5 +428,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") } } diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupLogic.kt index ffc7e179..35a3ca84 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupLogic.kt @@ -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 diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt index b771d9e9..ca2dcc7f 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt @@ -31,6 +31,7 @@ import java.util.concurrent.TimeUnit @InstallIn(SingletonComponent::class) interface DriveBackupEntryPoint { fun driveBackup(): DriveBackup + fun driveWorkWifi(): com.gatecontrol.android.drive.work.DriveWorkWifi } private fun backup(context: Context) = EntryPointAccessors.fromApplication(context, DriveBackupEntryPoint::class.java).driveBackup() @@ -67,6 +68,8 @@ class DriveBackupTrigger(appContext: Context, params: WorkerParameters) : Corout override suspend fun doWork(): Result { val b = backup(applicationContext) if (b.config.value.enabled) { + // new photos came a moment ago: the WLAN now is where they were taken (the work photos' timeline) + runCatching { EntryPointAccessors.fromApplication(applicationContext, DriveBackupEntryPoint::class.java).driveWorkWifi().sample() } DriveBackupWork.runNow(applicationContext) DriveBackupWork.watch(applicationContext, again = true) } diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveCameraUploads.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveCameraUploads.kt index 509fa040..49b1786e 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveCameraUploads.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveCameraUploads.kt @@ -24,10 +24,29 @@ class DriveCameraUploads @Inject constructor( private val lock = Mutex() private var base: Long? = null private val below = HashMap() + private val other = HashMap() /** The folder for [path] (year, month — [DriveBackupLogic.cameraPath]) below Kamera-Uploads. */ suspend fun folder(path: List): DResult = lock.withLock { blocked(resolve(path)) } + /** + * The folder for [path] below the folder [base] (the work folder, Drive 1.13: `//`), made + * when missing; [base] itself for an empty path. + */ + suspend fun under(base: Long, path: List): DResult = lock.withLock { + val key = "$base:" + path.joinToString("/") + other[key]?.let { return@withLock DResult.Ok(it) } + var parent = base + for (name in path) { + parent = when (val c = child(parent, name)) { + is DResult.Ok -> c.value + else -> return@withLock blocked(c) + } + } + other[key] = parent + DResult.Ok(parent) + } + private suspend fun resolve(path: List): DResult { val key = path.joinToString("/") below[key]?.let { return DResult.Ok(it) } @@ -52,6 +71,7 @@ class DriveCameraUploads @Inject constructor( suspend fun forget() = lock.withLock { base = null below.clear() + other.clear() } /** Kamera-Uploads: the server's (`photosFolder`), else `Bilder/Kamera-Uploads` in the person's own area. */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWifiTimeline.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWifiTimeline.kt new file mode 100644 index 00000000..3bd16edc --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWifiTimeline.kt @@ -0,0 +1,110 @@ +package com.gatecontrol.android.drive.work + +import com.google.gson.JsonArray +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** + * One look at the WLAN: from [at] (ms) on the phone was in [ssid] (null: no WLAN, or Android did not tell its name). + * [session] is the app process that saw it: within one process the network callback runs all the time, so two looks + * of the same session have nothing unseen between them; between sessions (the app was not running) only the looks + * themselves are known. + */ +data class WifiSeen(val at: Long, val ssid: String?, val session: String) + +/** + * The WLAN timeline (pure, unit-tested): when the phone was in which WLAN, so a photo's time ([wifiAt]) tells where it + * was taken. Kept on the phone only ([encode]), for [KEEP_MS]. + */ +object DriveWifiTimeline { + + /** Kept this long (photos older than that are backed up long ago or not at all). */ + const val KEEP_MS = 30L * 24 * 60 * 60 * 1000 + + /** At most this many looks. */ + const val MAX = 4000 + + /** A look this close to a photo counts for it, whatever came before or after. */ + const val GRACE_MS = 2L * 60 * 1000 + + /** + * [seen] added to [list] (sorted by time). A look of the same session and WLAN as the last one adds nothing (the + * callback saw no change); one from another session does (it confirms the WLAN after a gap). Old looks go, the + * last one before the cut stays (it says what was then). + */ + fun add(list: List, seen: WifiSeen, now: Long = seen.at): List { + val last = list.lastOrNull() + val out = when { + last != null && last.ssid == seen.ssid && last.session == seen.session && seen.at >= last.at -> list + last == null || seen.at >= last.at -> list + seen + else -> (list + seen).sortedBy { it.at } + } + return prune(out, now) + } + + fun prune(list: List, now: Long): List { + val cut = now - KEEP_MS + val firstKept = list.indexOfFirst { it.at >= cut } + val trimmed = when { + firstKept < 0 -> list.takeLast(1) + firstKept > 0 -> list.subList(firstKept - 1, list.size) + else -> list + } + return if (trimmed.size > MAX) trimmed.takeLast(MAX) else trimmed + } + + /** + * The WLAN the phone was in at [t]; null when it was in none or it cannot tell. [current] is this process' + * session: its last look holds until now (the callback would have seen a change). Between two looks the earlier + * holds when both are of one session (seen without a gap) or name the same WLAN; else only a look within + * [GRACE_MS] of [t] counts. + */ + fun wifiAt(list: List, t: Long, current: String?): String? { + var prev: WifiSeen? = null + var next: WifiSeen? = null + for (s in list) { + if (s.at <= t) prev = s else { next = s; break } + } + if (prev == null) return next?.takeIf { it.at - t <= GRACE_MS }?.ssid + if (next == null) { + return if (prev.session == current || t - prev.at <= GRACE_MS) prev.ssid else null + } + if (prev.ssid == next.ssid || prev.session == next.session) return prev.ssid + if (t - prev.at <= GRACE_MS) return prev.ssid + if (next.at - t <= GRACE_MS) return next.ssid + return null + } + + // ─── on the phone ─────────────────────────────────────────────── + + /** `{ v:1, s:[[at, ssid|null, session], …] }`. */ + fun encode(list: List): String = JsonObject().apply { + addProperty("v", 1) + add("s", JsonArray().apply { + list.forEach { w -> + add(JsonArray().apply { + add(w.at) + if (w.ssid != null) add(w.ssid) else add(JsonNull.INSTANCE) + add(w.session) + }) + } + }) + }.toString() + + /** Anything broken reads as an empty timeline. */ + fun decode(text: String?): List { + if (text.isNullOrBlank()) return emptyList() + return runCatching { + val o = JsonParser.parseString(text).asJsonObject + o.getAsJsonArray("s").mapNotNull { e -> + val a = e as? JsonArray ?: return@mapNotNull null + if (a.size() < 3) return@mapNotNull null + val at = runCatching { a[0].asLong }.getOrNull() ?: return@mapNotNull null + val ssid = a[1].takeIf { it.isJsonPrimitive }?.asString?.take(64) + val session = a[2].takeIf { it.isJsonPrimitive }?.asString?.take(40) ?: return@mapNotNull null + WifiSeen(at, ssid, session) + }.sortedBy { it.at } + }.getOrDefault(emptyList()) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkClassifier.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkClassifier.kt new file mode 100644 index 00000000..ecaf5aea --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkClassifier.kt @@ -0,0 +1,169 @@ +package com.gatecontrol.android.drive.work + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.time.DayOfWeek +import java.time.Instant +import java.time.ZoneId +import kotlin.math.exp + +/** + * What the AI learns from (pure, unit-tested): a photo's features — the labels the phone's image labeling found + * (their index → confidence) and when it was taken (a working day's working hours, the weekend) — and whether the + * person said work or private, from where ([Source]). + */ +data class WorkExample( + /** One per photo (a later answer replaces an earlier one): `n:` or `m:`. */ + val key: String, + val work: Boolean, + val source: WorkExample.Source, + val features: Map, + val at: Long, +) { + enum class Source(val code: String) { + /** Taken in the WLAN "Firma" or "Zuhause". */ + WIFI("wifi"), + /** "Arbeit" / "Privat" of the bar, "Mehr › Zuordnung". */ + ASSIGN("assign"), + /** Moved into or out of the work folder. */ + MOVE("move"), + /** "Aufräumen": ticked = work, unticked = private. */ + CLEANUP("cleanup"), + ; + + companion object { + fun of(code: String?): Source? = entries.firstOrNull { it.code == code } + } + } +} + +/** + * A small model on the phone: logistic regression over the label features, trained on the examples ([train]), its + * answer the probability "work" ([predict]). Nothing of it leaves the phone. + */ +class DriveWorkModel internal constructor(private val bias: Double, private val weights: Map) { + + fun predict(features: Map): Double { + var z = bias + for ((i, v) in features) z += (weights[i] ?: 0.0) * v + return sigmoid(z) + } + + companion object { + private const val EPOCHS = 300 + private const val RATE = 0.5 + private const val L2 = 0.02 + + /** Trained on [examples] (both kinds weighed equally); null without examples of both kinds. */ + fun train(examples: List): DriveWorkModel? { + val pos = examples.count { it.work } + val neg = examples.size - pos + if (pos == 0 || neg == 0) return null + // equal weight for both kinds, whatever their numbers + val wPos = examples.size / (2.0 * pos) + val wNeg = examples.size / (2.0 * neg) + val keys = examples.flatMap { it.features.keys }.toSortedSet().toList() + val index = keys.withIndex().associate { it.value to it.index } + val w = DoubleArray(keys.size) + var b = 0.0 + val n = examples.size.toDouble() + val rows = examples.map { e -> e.features.map { (k, v) -> index.getValue(k) to v.toDouble() } } + repeat(EPOCHS) { + val gw = DoubleArray(w.size) + var gb = 0.0 + for ((r, e) in examples.withIndex()) { + var z = b + for ((i, v) in rows[r]) z += w[i] * v + val y = if (e.work) 1.0 else 0.0 + val err = (sigmoid(z) - y) * (if (e.work) wPos else wNeg) + for ((i, v) in rows[r]) gw[i] += err * v + gb += err + } + for (i in w.indices) w[i] -= RATE * (gw[i] / n + L2 * w[i]) + b -= RATE * gb / n + } + return DriveWorkModel(b, keys.withIndex().associate { it.value to w[it.index] }.filterValues { it != 0.0 }) + } + + private fun sigmoid(z: Double): Double = 1.0 / (1.0 + exp(-z.coerceIn(-30.0, 30.0))) + } +} + +/** Features and examples on the phone. */ +object DriveWorkFeatures { + + /** Index of "taken on a working day between 7 and 19 o'clock" (the labels' indices are far below). */ + const val WORK_HOURS = 100_000 + + /** Index of "taken on a Saturday or Sunday". */ + const val WEEKEND = 100_001 + + /** Labels below this confidence are left out (noise). */ + const val MIN_CONFIDENCE = 0.5f + + /** At most this many examples are kept (the oldest go). */ + const val MAX_EXAMPLES = 2000 + + /** The features of a photo: its labels ([labels], index → confidence) and when it was taken ([takenMs]). */ + fun of(labels: Map, takenMs: Long?, zone: ZoneId = ZoneId.systemDefault()): Map { + val out = HashMap() + labels.forEach { (i, c) -> if (i in 0 until WORK_HOURS && c >= MIN_CONFIDENCE) out[i] = c.coerceIn(0f, 1f) } + if (takenMs != null && takenMs > 0) { + val t = Instant.ofEpochMilli(takenMs).atZone(zone) + val weekend = t.dayOfWeek == DayOfWeek.SATURDAY || t.dayOfWeek == DayOfWeek.SUNDAY + if (weekend) out[WEEKEND] = 1f else if (t.hour in 7 until 19) out[WORK_HOURS] = 1f + } + return out + } + + /** [e] added: an earlier example of the same photo is replaced; the oldest go beyond [MAX_EXAMPLES]. */ + fun add(list: List, e: WorkExample): List { + val out = list.filter { it.key != e.key } + e + return if (out.size > MAX_EXAMPLES) out.sortedBy { it.at }.takeLast(MAX_EXAMPLES) else out + } + + /** What was learned so far, for the settings ("Gelernt aus …"). */ + fun learned(list: List): DriveWorkLogic.Learned = DriveWorkLogic.Learned( + total = list.size, + work = list.count { it.work }, + private = list.count { !it.work }, + workFromWifi = list.count { it.work && it.source == WorkExample.Source.WIFI }, + privateFromWifi = list.count { !it.work && it.source == WorkExample.Source.WIFI }, + ) + + /** `{ v:1, e:[{ k, w, s, f:{ "12": 0.83, … }, at }, …] }`. */ + fun encode(list: List): String = JsonObject().apply { + addProperty("v", 1) + add("e", JsonArray().apply { + list.forEach { e -> + add(JsonObject().apply { + addProperty("k", e.key) + addProperty("w", e.work) + addProperty("s", e.source.code) + add("f", JsonObject().apply { e.features.forEach { (i, v) -> addProperty(i.toString(), v) } }) + addProperty("at", e.at) + }) + } + }) + }.toString() + + /** Anything broken reads as no examples (the AI starts again). */ + fun decode(text: String?): List { + if (text.isNullOrBlank()) return emptyList() + return runCatching { + JsonParser.parseString(text).asJsonObject.getAsJsonArray("e").mapNotNull { el -> + val o = el as? JsonObject ?: return@mapNotNull null + val key = o.get("k")?.takeIf { it.isJsonPrimitive }?.asString?.take(80) ?: return@mapNotNull null + val work = o.get("w")?.takeIf { it.isJsonPrimitive }?.asBoolean ?: return@mapNotNull null + val source = WorkExample.Source.of(o.get("s")?.takeIf { it.isJsonPrimitive }?.asString) ?: return@mapNotNull null + val f = o.getAsJsonObject("f")?.entrySet()?.mapNotNull { (k, v) -> + val i = k.toIntOrNull() ?: return@mapNotNull null + val x = runCatching { v.asFloat }.getOrNull()?.takeIf { it.isFinite() } ?: return@mapNotNull null + i to x + }?.toMap().orEmpty() + WorkExample(key, work, source, f, o.get("at")?.takeIf { it.isJsonPrimitive }?.asLong ?: 0L) + } + }.getOrDefault(emptyList()) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt new file mode 100644 index 00000000..d036d351 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt @@ -0,0 +1,90 @@ +package com.gatecontrol.android.drive.work + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DriveNodeResolver +import com.gatecontrol.android.drive.DrivePreviews +import com.google.android.gms.tasks.Task +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.label.ImageLabeler +import com.google.mlkit.vision.label.ImageLabeling +import com.google.mlkit.vision.label.defaults.ImageLabelerOptions +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * What is in a photo, found on the phone: ML Kit's image labeling (through Google Play services, the model on the + * phone; no picture goes to a service for it) on a small picture — the phone's own thumbnail of a photo still on it, or + * the preview Drive made of a photo already backed up (fetched from the person's own server). The labels (index → + * confidence) are the AI's features ([DriveWorkFeatures]). + */ +@Singleton +class DriveWorkLabeler @Inject constructor( + @ApplicationContext private val context: Context, + private val previews: DrivePreviews, + private val resolver: DriveNodeResolver, +) { + private val lock = Mutex() + private var labeler: ImageLabeler? = null + + private fun client(): ImageLabeler = labeler ?: ImageLabeling.getClient( + ImageLabelerOptions.Builder().setConfidenceThreshold(DriveWorkFeatures.MIN_CONFIDENCE).build(), + ).also { labeler = it } + + /** The labels of a photo on the phone; null when it cannot be read or labeled (Play services missing, …). */ + suspend fun ofUri(uri: Uri): Map? = withContext(Dispatchers.IO) { + val bmp = try { + context.contentResolver.loadThumbnail(uri, Size(THUMB, THUMB), null) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d("Drive work: no thumbnail (%s)", e.javaClass.simpleName) + return@withContext null + } + label(bmp) + } + + /** The labels of a photo in Drive (its preview); null without one. */ + suspend fun ofNode(node: DNode): Map? { + if (node.isDir || node.encrypted) return null + val full = if (node.mediaId != null) node else resolver.resolve(node.id) ?: return null + if (!full.preview) return null + val bmp = previews.load(full) ?: return null + return label(bmp) + } + + private suspend fun label(bmp: Bitmap): Map? = lock.withLock { + try { + client().process(InputImage.fromBitmap(bmp, 0)).await().associate { it.index to it.confidence } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d("Drive work: labeling failed (%s)", e.javaClass.simpleName) + null + } + } + + private suspend fun Task.await(): T = suspendCancellableCoroutine { c -> + addOnSuccessListener { c.resume(it) } + addOnFailureListener { c.resumeWithException(it) } + addOnCanceledListener { c.cancel() } + } + + private companion object { + /** The labeler needs no more; small pictures are quick. */ + const val THUMB = 384 + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLogic.kt new file mode 100644 index 00000000..c652d341 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLogic.kt @@ -0,0 +1,180 @@ +package com.gatecontrol.android.drive.work + +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DriveLogic +import java.time.ZoneId +import kotlin.math.roundToInt + +/** Where a WLAN puts the photos taken in it ("Firma", "Zuhause", "Weder noch"); set by the person, kept on the phone. */ +enum class WifiZone { WORK, HOME, NEITHER } + +/** The WLANs the person assigned, by name (normalised SSID). */ +data class WifiZones(val work: Set = emptySet(), val home: Set = emptySet(), val neither: Set = emptySet()) { + fun zoneOf(ssid: String?): WifiZone? = when (ssid) { + null -> null + in work -> WifiZone.WORK + in home -> WifiZone.HOME + in neither -> WifiZone.NEITHER + else -> null + } + + /** [ssid] into [zone] (out of the others); null: forgotten. */ + fun with(ssid: String, zone: WifiZone?): WifiZones = WifiZones( + work = if (zone == WifiZone.WORK) work + ssid else work - ssid, + home = if (zone == WifiZone.HOME) home + ssid else home - ssid, + neither = if (zone == WifiZone.NEITHER) neither + ssid else neither - ssid, + ) + + /** A WLAN to ask about: not assigned yet. */ + fun unknown(ssid: String?): Boolean = ssid != null && zoneOf(ssid) == null +} + +/** Pure part of "Arbeitsfotos getrennt sichern" (unit-tested). */ +object DriveWorkLogic { + + /** Where a photo goes: the work folder or the private one (Kamera-Uploads). */ + enum class Dest { WORK, PRIVATE } + + /** Why it goes there. */ + enum class Why { WIFI_WORK, WIFI_HOME, AI_WORK, AI_PRIVATE, REVIEW } + + /** + * Where a new photo goes: [dest]; [review] it gets the mark "zu prüfen" with [hint] (the AI's "work" in percent); + * [learn] the AI learns it as work (true) or private (false), null: nothing to learn. + */ + data class Placement(val dest: Dest, val review: Boolean, val hint: Int?, val learn: Boolean?, val why: Why) + + /** The AI decides alone only this sure (and only after it learned, with "Selbst einsortieren, wenn sicher"). */ + const val SURE_WORK = 0.85 + const val SURE_PRIVATE = 0.15 + + /** Examples before the AI may decide alone ("Lernphase"). */ + const val MIN_EXAMPLES = 30 + + /** Of each kind at least, so the model has seen both. */ + const val MIN_EACH = 3 + + /** "Aufräumen": shown from, and ticked from. */ + const val CLEANUP_SHOW = 0.5 + const val CLEANUP_PRESELECT = 0.70 + + /** + * Where a new photo goes. [zone]: the WLAN it was taken in (null: none, not assigned, or unknown — no permission); + * [score]: the AI's probability "work" (null: no answer); [ready]: past the learning phase; [autoSort]: "Selbst + * einsortieren, wenn sicher". Firma → work, Zuhause → private, both are examples to learn from; else the AI when it + * is sure and may, otherwise private with the review mark. + */ + fun place(zone: WifiZone?, score: Double?, ready: Boolean, autoSort: Boolean): Placement { + when (zone) { + WifiZone.WORK -> return Placement(Dest.WORK, review = false, hint = null, learn = true, why = Why.WIFI_WORK) + WifiZone.HOME -> return Placement(Dest.PRIVATE, review = false, hint = null, learn = false, why = Why.WIFI_HOME) + WifiZone.NEITHER, null -> Unit + } + val hint = score?.let { percent(it) } + if (ready && autoSort && score != null) { + if (score >= SURE_WORK) return Placement(Dest.WORK, review = false, hint = null, learn = null, why = Why.AI_WORK) + if (score <= SURE_PRIVATE) return Placement(Dest.PRIVATE, review = false, hint = null, learn = null, why = Why.AI_PRIVATE) + } + return Placement(Dest.PRIVATE, review = true, hint = hint, learn = null, why = Why.REVIEW) + } + + fun percent(p: Double): Int = (p.coerceIn(0.0, 1.0) * 100).roundToInt() + + // ─── what the AI has learned ──────────────────────────────────── + + /** The examples so far: all, work, private, and of those how many came from the WLANs. */ + data class Learned(val total: Int = 0, val work: Int = 0, val private: Int = 0, val workFromWifi: Int = 0, val privateFromWifi: Int = 0) { + /** Past the learning phase: enough examples, of both kinds. */ + val ready: Boolean get() = total >= MIN_EXAMPLES && work >= MIN_EACH && private >= MIN_EACH + + /** "23 von 30 Beispielen" (never above 30). */ + val progress: Int get() = total.coerceAtMost(MIN_EXAMPLES) + + /** Enough to suggest anything (Aufräumen, hints): a few of each. */ + val usable: Boolean get() = work >= MIN_EACH && private >= MIN_EACH + } + + // ─── the selection ────────────────────────────────────────────── + + /** Marked "zu prüfen": on the server ([DNode.review]) or, without Drive 1.13, on this phone ([local]). */ + fun isReview(node: DNode, local: Map): Boolean = node.review != null || node.id in local + + /** The hint of a marked photo (server first, else this phone's). */ + fun hintOf(node: DNode, local: Map): Int? = node.review?.hint ?: local[node.id] + + /** The big buttons "Arbeit" and "Privat" in front of the bar: only when every photo ticked is to be reviewed. */ + fun reviewBar(selected: List, local: Map): Boolean = selected.isNotEmpty() && selected.all { isReview(it, local) } + + /** The AI's line above the buttons: "wahrscheinlich Arbeit · 72 % sicher" ([work] true) or "eher privat · 62 %". */ + data class Hint(val work: Boolean, val sure: Int) + + /** The mean of the hints of the ticked photos; null when none has one. */ + fun hint(hints: List): Hint? { + val known = hints.filterNotNull() + if (known.isEmpty()) return null + val mean = (known.sum().toDouble() / known.size).roundToInt() + return if (mean >= 50) Hint(true, mean) else Hint(false, 100 - mean) + } + + /** What "Mehr › Zuordnung" offers for [nodes]: work (not all work yet), private (some work or marked), review. */ + data class Offer(val work: Boolean, val private: Boolean, val review: Boolean) + + fun offer(nodes: List, local: Map): Offer = Offer( + work = nodes.any { !it.work || isReview(it, local) }, + private = nodes.any { it.work || isReview(it, local) }, + review = nodes.any { !isReview(it, local) }, + ) + + // ─── moving photos to where they belong ───────────────────────── + + /** The folders below the work folder or Kamera-Uploads a photo goes to: its year and month (`["2026", "10"]`). */ + fun monthPath(node: DNode, zone: ZoneId = ZoneId.systemDefault()): List { + val m = DriveLogic.monthOf(node, zone) ?: return emptyList() + return listOf(m.substring(0, 4), m.substring(5, 7)) + } + + /** + * Which of [nodes] move where for [to]: work — those not under the work folder yet ([underWork]; a marked photo + * there goes into its month's folder too), below it by month when [subfolders]; private — those under the work + * folder, into Kamera-Uploads by month (the others stay where they are). Each path (empty: the folder itself) with + * its photos, in the order given. [underWork] defaults to the server's `work` (false for a marked photo). + */ + fun moves( + nodes: List, + to: Dest, + subfolders: Boolean, + zone: ZoneId = ZoneId.systemDefault(), + underWork: (DNode) -> Boolean = { it.work }, + ): Map, List> { + val going = when (to) { + Dest.WORK -> nodes.filter { !it.work } + Dest.PRIVATE -> nodes.filter { underWork(it) } + } + val out = LinkedHashMap, MutableList>() + for (n in going) { + val path = if (to == Dest.WORK && !subfolders) emptyList() else monthPath(n, zone) + out.getOrPut(path) { mutableListOf() } += n + } + return out + } + + // ─── Aufräumen ────────────────────────────────────────────────── + + /** A suggestion of "Aufräumen": the photo, its month (`2026-10`), the AI's "work" in percent, ticked at first. */ + data class Suggestion(val node: DNode, val month: String?, val percent: Int, val preselected: Boolean) + + /** The photos the AI thinks are work (from 50 %), by month (newest first), the surest first; ticked from 70 %. */ + fun suggestions(scored: List>, zone: ZoneId = ZoneId.systemDefault()): List = + scored.asSequence() + .filter { (n, p) -> p >= CLEANUP_SHOW && !n.work && n.review == null } + .map { (n, p) -> Suggestion(n, DriveLogic.monthOf(n, zone), percent(p), p >= CLEANUP_PRESELECT) } + .sortedWith(compareByDescending { it.month ?: "" }.thenByDescending { it.percent }.thenByDescending { it.node.id }) + .toList() + + /** The suggestions by month, in their order. */ + fun byMonth(list: List): List>> { + val out = ArrayList>>() + for (s in list) if (out.isNotEmpty() && out.last().first == s.month) out.last().second += s else out += s.month to mutableListOf(s) + return out + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt new file mode 100644 index 00000000..bcafac09 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt @@ -0,0 +1,440 @@ +package com.gatecontrol.android.drive.work + +import android.net.Uri +import com.gatecontrol.android.drive.DArea +import com.gatecontrol.android.drive.DFolder +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DPhotoSettings +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DReview +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DriveOps +import com.gatecontrol.android.drive.backup.DriveCameraUploads +import com.gatecontrol.android.drive.work.DriveWorkLogic.Dest +import com.gatecontrol.android.support.CrashReports +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.time.Instant +import java.time.OffsetDateTime +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Arbeitsfotos getrennt sichern" (Drive 1.13, feature `photoClasses`; without it the same on this phone only): the work + * folder and its settings, where a new photo goes ([placeNew], for the photo backup), the review mark, "Arbeit" / + * "Privat" / "Zum Prüfen markieren" for photos in Drive ([assign], with [undo]), what the AI learns from (all on the + * phone: [DriveWorkLabeler], [DriveWorkModel]) and the suggestions of "Aufräumen" ([suggest]). + */ +@Singleton +class DriveWorkPhotos @Inject constructor( + private val api: DriveApi, + private val live: DriveLive, + private val store: DriveWorkStore, + private val labeler: DriveWorkLabeler, + private val camera: DriveCameraUploads, + private val ops: DriveOps, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) + + val settings: StateFlow = store.settings + val learned: StateFlow get() = store.learned + val localReview: StateFlow> = store.localReview + + /** Drive 1.13 keeps the settings and the marks; without it this phone does. */ + fun serverClasses(me: DMe? = live.me.value): Boolean = me?.has(DMe.FEATURE_PHOTO_CLASSES) == true + + // ─── settings ─────────────────────────────────────────────────── + + /** The person's settings from Drive 1.13 (nothing without it); a code when they could not be read. */ + suspend fun refreshSettings(): String? { + if (!live.supports(DMe.FEATURE_PHOTO_CLASSES)) return null + return when (val r = api.photoSettings()) { + is DResult.Ok -> null.also { took(r.value) } + else -> code(r) + } + } + + private suspend fun took(p: DPhotoSettings) { + val areas = live.current()?.areas.orEmpty() + store.update { + it.copy( + workFolder = p.workFolder, + folderLabel = p.workFolder?.let { _ -> label(p, areas) ?: it.folderLabel }, + subfolders = p.workSubfolders, + inMemories = p.workInMemories, + ) + } + } + + private fun label(p: DPhotoSettings, areas: List): String? { + val area = areas.firstOrNull { it.id == p.folderAreaId }?.label ?: return null + val parts = p.folderPath.orEmpty().split('/').filter { it.isNotBlank() } + return (listOf(area) + parts).joinToString(" › ") + } + + /** "Arbeitsfotos nach": [folderId] (shown as [label]); null when it was taken, else a code (`invalid:`). */ + suspend fun chooseWorkFolder(folderId: Long, label: String): String? { + if (live.supports(DMe.FEATURE_PHOTO_CLASSES)) { + when (val r = api.putPhotoSettings(workFolder = folderId)) { + is DResult.Ok -> took(r.value) + is DResult.Failed -> return r.body?.get("reason")?.takeIf { it.isJsonPrimitive }?.asString?.let { "work_folder:$it" } ?: r.code + else -> return code(r) + } + } + store.update { it.copy(workFolder = folderId, folderLabel = label) } + parents.clear() + return null + } + + suspend fun clearWorkFolder(): String? { + if (live.supports(DMe.FEATURE_PHOTO_CLASSES)) { + val r = api.putPhotoSettings(clearFolder = true) + if (r !is DResult.Ok) return code(r) + } + store.update { it.copy(workFolder = null, folderLabel = null) } + parents.clear() + return null + } + + suspend fun setSubfolders(on: Boolean): String? { + if (live.supports(DMe.FEATURE_PHOTO_CLASSES)) { + val r = api.putPhotoSettings(workSubfolders = on) + if (r !is DResult.Ok) return code(r) + } + store.update { it.copy(subfolders = on) } + return null + } + + /** "Arbeitsfotos nie in Erinnerungen und Fotorahmen" ([on] = they may show): only Drive 1.13 can. */ + suspend fun setInMemories(on: Boolean): String? { + val r = api.putPhotoSettings(workInMemories = on) + if (r !is DResult.Ok) return code(r) + store.update { it.copy(inMemories = on) } + return null + } + + fun setAutoSort(on: Boolean) = store.update { it.copy(autoSort = on) } + fun setAskNewWifi(on: Boolean) = store.update { it.copy(askNewWifi = on) } + fun setRemoveAfter(on: Boolean) = store.update { it.copy(removeAfter = on) } + fun setZone(ssid: String, zone: WifiZone?) = store.setZone(ssid, zone) + + /** "Neu lernen". */ + fun resetLearning() { + store.resetLearning() + synchronized(this) { modelOf = null; model = null } + } + + // ─── the AI ───────────────────────────────────────────────────── + + private var model: DriveWorkModel? = null + private var modelOf: List? = null + + /** The model of the examples now (trained again when they changed); null until there are a few of both kinds. */ + private suspend fun model(): DriveWorkModel? = withContext(Dispatchers.Default) { + val ex = store.examples() + synchronized(this@DriveWorkPhotos) { + if (ex !== modelOf) { + modelOf = ex + model = if (DriveWorkFeatures.learned(ex).usable) DriveWorkModel.train(ex) else null + } + model + } + } + + /** The features of a photo in Drive (labeled once, then kept). */ + private suspend fun featuresOf(node: DNode): Map? { + val key = key(node) + store.features(key)?.let { return it } + val labels = labeler.ofNode(node) ?: return null + return DriveWorkFeatures.of(labels, takenMs(node)).also { store.putFeatures(key, it) } + } + + /** The AI's probability "work" of a photo in Drive; null while it cannot tell. */ + suspend fun score(node: DNode): Double? { + val m = model() ?: return null + return featuresOf(node)?.let { m.predict(it) } + } + + /** One example per photo in Drive. */ + private fun key(node: DNode) = "n:${node.id}" + + /** Keys of an assignment taken back while it was still being learned. */ + private val undone = java.util.Collections.synchronizedSet(HashSet()) + + private fun learnLater(nodes: List, work: Boolean, source: WorkExample.Source) { + val images = nodes.filter { isImage(it) } + if (images.isEmpty()) return + images.forEach { undone.remove(key(it)) } + scope.launch { + for (n in images) { + val f = runCatching { featuresOf(n) }.getOrNull() ?: continue + if (undone.remove(key(n))) continue + store.learn(WorkExample(key(n), work, source, f, System.currentTimeMillis())) + } + } + } + + private fun isImage(n: DNode) = !n.isDir && !n.encrypted && n.mime?.startsWith("image/") == true + + // ─── the photo backup ─────────────────────────────────────────── + + /** Where a new photo goes, and what was found to get there ([features] of the photo, the WLAN [ssid]). */ + data class NewPhoto(val placement: DriveWorkLogic.Placement, val features: Map?, val ssid: String?) + + /** + * Where the photo [uri] taken at [takenMs] goes; null while no work folder is chosen (all to Kamera-Uploads). The + * WLAN at the time it was taken first; else the AI (labels of the phone's thumbnail). + */ + suspend fun placeNew(uri: Uri, takenMs: Long?, video: Boolean): NewPhoto? { + val s = store.settings.value + if (!s.active) return null + val ssid = takenMs?.takeIf { it > 0 }?.let { store.wifiAt(it) } + val zone = s.zones.zoneOf(ssid) + val features = if (video) null else runCatching { labeler.ofUri(uri) }.getOrNull()?.let { DriveWorkFeatures.of(it, takenMs) } + val needAi = zone == null || zone == WifiZone.NEITHER + val score = if (needAi && features != null) model()?.predict(features) else null + val learned = store.examples().let { DriveWorkFeatures.learned(it) } + return NewPhoto(DriveWorkLogic.place(zone, score, learned.ready, s.autoSort), features, ssid) + } + + /** The folder of a work photo: the work folder, by year and month when set ([path] from the backup). */ + suspend fun workFolderFor(path: List): DResult { + val s = store.settings.value + val base = s.workFolder ?: return DResult.Failed("no_folder", 0) + return camera.under(base, if (s.subfolders) path else emptyList()) + } + + /** + * After the backup put [node] where [p] said (the photo [mediaStoreId] on the phone): what it teaches the AI, the + * review mark, and — set so — the work photo waits to go from the phone. + */ + suspend fun afterBackup(node: DNode?, p: NewPhoto, mediaStoreId: Long, video: Boolean) { + if (node != null) { + p.features?.let { f -> + store.putFeatures(key(node), f) + p.placement.learn?.let { work -> store.learn(WorkExample(key(node), work, WorkExample.Source.WIFI, f, System.currentTimeMillis())) } + } + if (p.placement.review) mark(listOf(node.id), true, p.placement.hint) + } + if (p.placement.dest == Dest.WORK && store.settings.value.removeAfter) store.addRemoval(mediaStoreId, video) + } + + /** Sets or clears the review mark: on the server with Drive 1.13 (else, or when it refuses, on this phone). */ + private suspend fun mark(ids: List, review: Boolean, hint: Int?): Boolean { + if (ids.isEmpty()) return true + if (live.supports(DMe.FEATURE_PHOTO_CLASSES)) { + val r = api.photoReview(ids, review, hint) + if (r is DResult.Ok) { + if (!review) store.setLocalReview(ids, false, null) + return true + } + Timber.i("Drive work: review mark not set (%s)", code(r)) + if (review) return false + } + store.setLocalReview(ids, review, hint) + return true + } + + // ─── assigning photos in Drive ────────────────────────────────── + + /** What an assignment did, to take it back: where each moved photo was, the marks before, what was learned. */ + data class Undo( + val back: List>, + val marks: Map, + val learned: List, + ) + + /** Of "Arbeit" / "Privat" / "Zum Prüfen markieren": [count] photos to [to]; [undo] takes it back; [error] a code. */ + data class Assigned(val to: Target, val count: Int, val undo: Undo?, val error: String? = null) + + enum class Target { WORK, PRIVATE, REVIEW } + + private val assignLock = Mutex() + + /** + * [nodes] are work photos ([Target.WORK]: into the work folder by month, the mark gone), private ([Target.PRIVATE]: + * out of the work folder into Kamera-Uploads by month, else where they are, the mark gone) or to be reviewed (the + * mark, with the AI's hint). The AI learns from the first two ([source]). + */ + suspend fun assign(nodes: List, to: Target, source: WorkExample.Source = WorkExample.Source.ASSIGN): Assigned = assignLock.withLock { + val list = nodes.filter { !it.isDir && !it.encrypted }.distinctBy { it.id } + if (list.isEmpty()) return@withLock Assigned(to, 0, null, "invalid") + val s = store.settings.value + val local = store.localReview.value + val marksBefore = list.associate { it.id to (it.review ?: if (it.id in local) DReview(local[it.id]) else null) } + val back = ArrayList>() + when (to) { + Target.WORK, Target.PRIVATE -> { + val dest = if (to == Target.WORK) Dest.WORK else Dest.PRIVATE + if (dest == Dest.WORK && s.workFolder == null) return@withLock Assigned(to, 0, null, "no_work_folder") + val underWork = HashMap() + if (dest == Dest.PRIVATE) { + for (n in list) if (!n.work && marksBefore[n.id] != null) underWork[n.id] = n.parentId?.let { isUnderWork(it) } == true + } + val moves = DriveWorkLogic.moves(list, dest, s.subfolders) { n -> n.work || underWork[n.id] == true } + for ((path, group) in moves) { + val folder = when (val f = if (dest == Dest.WORK) workFolderFor(path) else camera.folder(path)) { + is DResult.Ok -> f.value + else -> return@withLock Assigned(to, 0, Undo(back, marksBefore, emptyList()).takeIf { back.isNotEmpty() }, code(f)) + } + val moved = moveAll(group, folder) + group.filter { it.id in moved }.forEach { n -> n.parentId?.let { back += n.id to it } } + if (moved.size < group.size && moved.isEmpty()) { + return@withLock Assigned(to, 0, Undo(back, marksBefore, emptyList()).takeIf { back.isNotEmpty() }, "move_failed") + } + } + mark(list.filter { marksBefore[it.id] != null }.map { it.id }, false, null) + learnLater(list, work = dest == Dest.WORK, source = source) + } + Target.REVIEW -> { + val hints = HashMap() + for (n in list) hints[n.id] = runCatching { score(n) }.getOrNull()?.let { DriveWorkLogic.percent(it) } ?: marksBefore[n.id]?.hint + for ((hint, ids) in hints.entries.groupBy({ it.value }, { it.key })) { + if (!mark(ids, true, hint)) return@withLock Assigned(to, 0, null, "review_failed") + } + } + } + Assigned(to, list.size, Undo(back, marksBefore, if (to == Target.REVIEW) emptyList() else list.map { key(it) })) + } + + /** "Rückgängig": the photos back where they were, their marks as before, the examples forgotten. */ + suspend fun undo(u: Undo): String? = assignLock.withLock { + undone.addAll(u.learned) + store.forget(u.learned) + var error: String? = null + for ((parent, ids) in u.back.groupBy({ it.second }, { it.first })) { + val moved = moveAll(ids.map { id -> stub(id) }, parent) + if (moved.size < ids.size) error = "move_failed" + } + val had = u.marks.filterValues { it != null } + for ((hint, ids) in had.entries.groupBy({ it.value?.hint }, { it.key })) mark(ids, true, hint) + mark(u.marks.filterValues { it == null }.keys.toList(), false, null) + error + } + + private fun stub(id: Long) = DNode(id, "", null, "", false, 0, null, null, 0, null, null, null) + + /** Moves [nodes] into [folder] (Drive 1.12: one job; else one by one); the ids moved. */ + private suspend fun moveAll(nodes: List, folder: Long): Set { + val ids = nodes.map { it.id }.filter { id -> nodes.first { it.id == id }.parentId != folder } + if (ids.isEmpty()) return nodes.map { it.id }.toSet() + if (live.supports(DMe.FEATURE_BULK)) { + when (val r = ops.start("move", ids, folder, "rename", nodes.firstOrNull()?.name?.takeIf { it.isNotEmpty() }, null)) { + is DResult.Ok -> { + val job = ops.await(r.value.id) + ops.forget(r.value.id) + val results = job.op.results + if (job.op.state != "done" && (results?.done ?: 0) == 0) return nodes.filter { it.parentId == folder }.map { it.id }.toSet() + val failed = results?.failed?.map { it.id }?.toSet().orEmpty() + return (nodes.map { it.id }.toSet() - failed) + } + else -> Timber.i("Drive work: move job not started (%s)", code(r)) + } + } + val done = HashSet() + nodes.filter { it.parentId == folder }.forEach { done += it.id } + for (id in ids) if (api.move(id, folder) is DResult.Ok) done += id + return done + } + + // whether a folder lies below the work folder (by its path), kept per folder + private val parents = java.util.concurrent.ConcurrentHashMap() + + private suspend fun isUnderWork(folderId: Long): Boolean { + val work = store.settings.value.workFolder ?: return false + if (folderId == work) return true + parents[folderId]?.let { return it } + val f = api.folder(folderId, limit = 1).valueOrNull() ?: return false + return (f.crumbs.any { it.id == work }).also { parents[folderId] = it } + } + + /** [f] is the work folder or lies below it. */ + fun isWorkFolder(f: DFolder): Boolean { + val work = store.settings.value.workFolder ?: return false + return f.node.id == work || f.crumbs.any { it.id == work } + } + + /** + * A move of the normal selection: photos into the work folder ([intoWork] true) or out of it (false) teach the AI + * as well; null: neither. + */ + fun learnMove(nodes: List, intoWork: Boolean?) { + if (intoWork == null || nodes.isEmpty() || !store.settings.value.active) return + learnLater(nodes, intoWork, WorkExample.Source.MOVE) + if (nodes.any { it.review != null || it.id in store.localReview.value }) scope.launch { mark(nodes.map { it.id }, false, null) } + } + + /** A move of the normal selection from [from] (null: the photos) into [target]: into or out of the work folder. */ + fun learnMoved(nodes: List, from: DFolder?, target: DFolder) { + if (!store.settings.value.active) return + if (isWorkFolder(target)) { + learnMove(nodes.filter { !it.work }, true) + } else { + val fromWork = from != null && isWorkFolder(from) + learnMove(nodes.filter { it.work || fromWork }, false) + } + } + + // ─── Aufräumen ────────────────────────────────────────────────── + + /** + * The suggestions of "Aufräumen": the newest [limit] private photos (Kamera-Uploads when Drive knows them), each + * labeled on the phone and scored; [onProgress] hears how far it got. Empty while the AI cannot tell yet. + */ + suspend fun suggest(limit: Int = 240, onProgress: (Int, Int) -> Unit = { _, _ -> }): List { + val m = model() ?: return emptyList() + val photos = ArrayList() + val classes = live.supports(DMe.FEATURE_PHOTO_CLASSES) + val cameraOnly = live.supports(DMe.FEATURE_PHOTOS_FOLDER) + var before: String? = null + while (photos.size < limit) { + val page = api.photos(before = before, limit = 120, camera = cameraOnly, cls = if (classes) "private" else null).valueOrNull() ?: break + photos += page.items.filter { isImage(it) && !it.work && it.review == null && it.id !in store.localReview.value } + before = page.next ?: break + } + val list = photos.take(limit) + val scored = ArrayList>() + for ((i, n) in list.withIndex()) { + onProgress(i, list.size) + featuresOf(n)?.let { scored += n to m.predict(it) } + } + onProgress(list.size, list.size) + return DriveWorkLogic.suggestions(scored).also { s -> store.setSuggested(s.count { it.preselected }) } + } + + /** "N Fotos nach Arbeit verschieben": [chosen] are work, [left] stay private — the AI learns both. */ + suspend fun cleanup(chosen: List, left: List): Assigned { + learnLater(left, work = false, source = WorkExample.Source.CLEANUP) + val r = if (chosen.isEmpty()) Assigned(Target.WORK, 0, null) else assign(chosen, Target.WORK, WorkExample.Source.CLEANUP) + if (r.error == null) store.setSuggested(0) + return r + } + + /** When "Aufräumen" last looked (ms). */ + fun suggestedAt(): Long = store.suggestedAt() + + /** How many photos it suggested then. */ + val suggested: StateFlow = store.suggested + + // ─── helpers ──────────────────────────────────────────────────── + + private fun takenMs(n: DNode): Long? = (n.takenAt ?: n.mtime)?.let { iso -> + runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull() ?: runCatching { OffsetDateTime.parse(iso).toInstant().toEpochMilli() }.getOrNull() + } + + private fun code(r: DResult<*>): String = when (r) { + is DResult.Failed -> r.code + DResult.Offline -> "offline" + DResult.NotConfigured -> "not_configured" + is DResult.Ok -> "invalid" + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt new file mode 100644 index 00000000..ddd43dfd --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt @@ -0,0 +1,292 @@ +package com.gatecontrol.android.drive.work + +import android.content.Context +import androidx.core.content.edit +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import timber.log.Timber +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Settings of "Arbeitsfotos getrennt sichern". [workFolder] … [inMemories] are the person's (Drive 1.13 keeps them on + * the server for every device; without it only here); the rest is this phone's. + */ +data class WorkSettings( + /** "Arbeitsfotos nach"; null: not chosen — every photo goes to Kamera-Uploads as before. */ + val workFolder: Long? = null, + /** The folder's path as shown ("Meine Dateien › Arbeit"). */ + val folderLabel: String? = null, + val subfolders: Boolean = true, + val inMemories: Boolean = false, + /** "Selbst einsortieren, wenn sicher". */ + val autoSort: Boolean = true, + /** "Bei neuen WLANs fragen". */ + val askNewWifi: Boolean = true, + /** "Arbeitsfotos nach der Sicherung vom Handy entfernen". */ + val removeAfter: Boolean = false, + val zones: WifiZones = WifiZones(), +) { + /** Photos are sorted at all: a work folder is chosen. */ + val active: Boolean get() = workFolder != null +} + +/** + * Everything of the work photos that stays on this phone: the settings ([WorkSettings]), the WLAN timeline and what the + * AI learned (both in `noBackupFilesDir`, never in a backup), the review marks of a Drive without 1.13, the work photos + * waiting to be removed from the phone. + */ +@Singleton +class DriveWorkStore @Inject constructor(@ApplicationContext private val context: Context) { + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val dir: File get() = context.noBackupFilesDir + private val lock = Any() + + /** This process' session of the WLAN timeline ([WifiSeen.session]). */ + val session: String = UUID.randomUUID().toString().take(12) + + private val _settings = MutableStateFlow(readSettings()) + val settings: StateFlow = _settings.asStateFlow() + + private val _examples = MutableStateFlow>(emptyList()) + private var examplesRead = false + + private val _learned = MutableStateFlow(DriveWorkLogic.Learned()) + /** What the AI learned so far (read on first use). */ + val learned: StateFlow get() = _learned.also { examples() } + + private val _localReview = MutableStateFlow(readLocalReview()) + /** Without Drive 1.13: the photos marked "zu prüfen" on this phone (node id → hint). */ + val localReview: StateFlow> = _localReview.asStateFlow() + + private val _ask = MutableStateFlow(prefs.getString(KEY_ASK, null)) + /** A new WLAN to ask about ("Wo bist du gerade?"); null: none. */ + val ask: StateFlow = _ask.asStateFlow() + + private val _removals = MutableStateFlow(readRemovals()) + /** Work photos backed up that may go from the phone (media store ids; the person confirms). */ + val removals: StateFlow> = _removals.asStateFlow() + + private val _suggested = MutableStateFlow(prefs.getInt(KEY_SUGGESTED, 0)) + /** How many photos "Aufräumen" suggested the last time it looked (for the hint in the Photos tab). */ + val suggested: StateFlow = _suggested.asStateFlow() + + // ─── settings ─────────────────────────────────────────────────── + + private fun readSettings() = WorkSettings( + workFolder = prefs.getLong(KEY_FOLDER, 0L).takeIf { it > 0 }, + folderLabel = prefs.getString(KEY_FOLDER_LABEL, null), + subfolders = prefs.getBoolean(KEY_SUBFOLDERS, true), + inMemories = prefs.getBoolean(KEY_IN_MEMORIES, false), + autoSort = prefs.getBoolean(KEY_AUTO, true), + askNewWifi = prefs.getBoolean(KEY_ASK_NEW, true), + removeAfter = prefs.getBoolean(KEY_REMOVE, false), + zones = WifiZones( + work = prefs.getStringSet(KEY_ZONE_WORK, emptySet()).orEmpty().toSet(), + home = prefs.getStringSet(KEY_ZONE_HOME, emptySet()).orEmpty().toSet(), + neither = prefs.getStringSet(KEY_ZONE_NEITHER, emptySet()).orEmpty().toSet(), + ), + ) + + fun update(f: (WorkSettings) -> WorkSettings) = synchronized(lock) { + val next = f(_settings.value) + prefs.edit { + if (next.workFolder != null) putLong(KEY_FOLDER, next.workFolder) else remove(KEY_FOLDER) + if (next.folderLabel != null) putString(KEY_FOLDER_LABEL, next.folderLabel) else remove(KEY_FOLDER_LABEL) + putBoolean(KEY_SUBFOLDERS, next.subfolders) + putBoolean(KEY_IN_MEMORIES, next.inMemories) + putBoolean(KEY_AUTO, next.autoSort) + putBoolean(KEY_ASK_NEW, next.askNewWifi) + putBoolean(KEY_REMOVE, next.removeAfter) + putStringSet(KEY_ZONE_WORK, next.zones.work) + putStringSet(KEY_ZONE_HOME, next.zones.home) + putStringSet(KEY_ZONE_NEITHER, next.zones.neither) + } + _settings.value = next + } + + fun setZone(ssid: String, zone: WifiZone?) { + update { it.copy(zones = it.zones.with(ssid, zone)) } + if (_ask.value == ssid) setAsk(null) + } + + fun setAsk(ssid: String?) { + prefs.edit { if (ssid != null) putString(KEY_ASK, ssid) else remove(KEY_ASK) } + _ask.value = ssid + } + + // ─── the WLAN timeline ────────────────────────────────────────── + + private var timeline: List? = null + + fun timeline(): List = synchronized(lock) { + timeline ?: DriveWifiTimeline.decode(read(FILE_WIFI)).also { timeline = it } + } + + /** A look at the WLAN now ([ssid] null: none, or its name unknown). */ + fun seen(ssid: String?, at: Long = System.currentTimeMillis()) = synchronized(lock) { + val before = timeline() + val next = DriveWifiTimeline.add(before, WifiSeen(at, ssid, session)) + if (next !== before) { + timeline = next + write(FILE_WIFI, DriveWifiTimeline.encode(next)) + } + } + + /** The WLAN at [t] (ms). */ + fun wifiAt(t: Long): String? = DriveWifiTimeline.wifiAt(timeline(), t, session) + + // ─── what the AI learned ──────────────────────────────────────── + + fun examples(): List = synchronized(lock) { + if (!examplesRead) { + examplesRead = true + _examples.value = DriveWorkFeatures.decode(read(FILE_AI)) + _learned.value = DriveWorkFeatures.learned(_examples.value) + } + _examples.value + } + + fun learn(e: WorkExample) = setExamples(DriveWorkFeatures.add(examples(), e)) + + /** Takes back what was learned of [keys] (an undone assignment). */ + fun forget(keys: Collection) { + if (keys.isEmpty()) return + val set = keys.toSet() + setExamples(examples().filter { it.key !in set }) + } + + /** "Neu lernen": every example goes. */ + fun resetLearning() { + setExamples(emptyList()) + synchronized(lock) { + features.clear() + File(dir, FILE_FEATURES).delete() + } + } + + private fun setExamples(list: List) = synchronized(lock) { + _examples.value = list + _learned.value = DriveWorkFeatures.learned(list) + write(FILE_AI, DriveWorkFeatures.encode(list)) + } + + // the labels found per photo, so a photo is labeled once + private val features = LinkedHashMap>(16, 0.75f, true) + private var featuresRead = false + + fun features(key: String): Map? = synchronized(lock) { + readFeatures() + features[key] + } + + fun putFeatures(key: String, f: Map) = synchronized(lock) { + readFeatures() + features[key] = f + while (features.size > MAX_FEATURES) features.remove(features.keys.first()) + val o = JsonObject() + features.forEach { (k, v) -> o.add(k, JsonObject().apply { v.forEach { (i, x) -> addProperty(i.toString(), x) } }) } + write(FILE_FEATURES, o.toString()) + } + + private fun readFeatures() { + if (featuresRead) return + featuresRead = true + runCatching { + val text = read(FILE_FEATURES) ?: return + JsonParser.parseString(text).asJsonObject.entrySet().forEach { (k, v) -> + features[k] = v.asJsonObject.entrySet().mapNotNull { (i, x) -> i.toIntOrNull()?.let { it to x.asFloat } }.toMap() + } + } + } + + // ─── review marks without Drive 1.13 ──────────────────────────── + + private fun readLocalReview(): Map = runCatching { + val text = prefs.getString(KEY_LOCAL_REVIEW, null) ?: return emptyMap() + JsonParser.parseString(text).asJsonObject.entrySet().mapNotNull { (k, v) -> + k.toLongOrNull()?.let { id -> id to v.takeIf { it.isJsonPrimitive }?.asInt?.coerceIn(0, 100) } + }.toMap() + }.getOrDefault(emptyMap()) + + fun setLocalReview(ids: Collection, review: Boolean, hint: Int?) { + _localReview.update { cur -> + val next = if (review) cur + ids.associateWith { id -> hint ?: cur[id] } else cur - ids.toSet() + val o = JsonObject() + next.entries.toList().takeLast(MAX_LOCAL_REVIEW).forEach { (id, h) -> if (h != null) o.addProperty(id.toString(), h) else o.add(id.toString(), com.google.gson.JsonNull.INSTANCE) } + prefs.edit { putString(KEY_LOCAL_REVIEW, o.toString()) } + next + } + } + + // ─── removing work photos from the phone ──────────────────────── + + private fun readRemovals(): List = + prefs.getString(KEY_REMOVALS, null)?.split(',')?.mapNotNull { it.toLongOrNull() }.orEmpty() + + /** A work photo of the phone to remove ([video]: from the videos; kept as a negative id). */ + fun addRemoval(mediaId: Long, video: Boolean) = addRemoval(if (video) -mediaId else mediaId) + + private fun addRemoval(mediaId: Long) = _removals.update { cur -> + (cur + mediaId).distinct().takeLast(MAX_REMOVALS).also { prefs.edit { putString(KEY_REMOVALS, it.joinToString(",")) } } + } + + fun clearRemovals(ids: Collection) = _removals.update { cur -> + (cur - ids.toSet()).also { prefs.edit { putString(KEY_REMOVALS, it.joinToString(",")) } } + } + + fun setSuggested(n: Int) { + prefs.edit { putInt(KEY_SUGGESTED, n); putLong(KEY_SUGGESTED_AT, System.currentTimeMillis()) } + _suggested.value = n + } + + /** When "Aufräumen" last looked (ms; 0: never). */ + fun suggestedAt(): Long = prefs.getLong(KEY_SUGGESTED_AT, 0L) + + // ─── files ────────────────────────────────────────────────────── + + private fun read(name: String): String? = runCatching { File(dir, name).takeIf { it.isFile }?.readText() }.getOrNull() + + private fun write(name: String, text: String) { + try { + val tmp = File(dir, "$name.tmp") + tmp.writeText(text) + if (!tmp.renameTo(File(dir, name))) tmp.delete() + } catch (e: Exception) { + Timber.w("Drive work: not saved (%s)", e.javaClass.simpleName) + } + } + + private companion object { + const val PREFS = "drive_work" + const val FILE_WIFI = "drive-work-wifi.json" + const val FILE_AI = "drive-work-ai.json" + const val FILE_FEATURES = "drive-work-features.json" + const val KEY_FOLDER = "folder" + const val KEY_FOLDER_LABEL = "folder_label" + const val KEY_SUBFOLDERS = "subfolders" + const val KEY_IN_MEMORIES = "in_memories" + const val KEY_AUTO = "auto" + const val KEY_ASK_NEW = "ask_new" + const val KEY_REMOVE = "remove" + const val KEY_ZONE_WORK = "zone_work" + const val KEY_ZONE_HOME = "zone_home" + const val KEY_ZONE_NEITHER = "zone_neither" + const val KEY_ASK = "ask" + const val KEY_LOCAL_REVIEW = "local_review" + const val KEY_REMOVALS = "removals" + const val KEY_SUGGESTED = "suggested" + const val KEY_SUGGESTED_AT = "suggested_at" + const val MAX_FEATURES = 3000 + const val MAX_LOCAL_REVIEW = 5000 + const val MAX_REMOVALS = 2000 + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt new file mode 100644 index 00000000..e6f65f79 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt @@ -0,0 +1,251 @@ +package com.gatecontrol.android.drive.work + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import com.gatecontrol.android.R +import com.gatecontrol.android.support.CrashReports +import com.gatecontrol.android.trustednet.TrustedNetSnapshot +import com.gatecontrol.android.trustednet.TrustedNetworkMonitor +import com.gatecontrol.android.trustednet.TrustedNetworkRules +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DriveWorkEntryPoint { + fun driveWorkWifi(): DriveWorkWifi + fun driveWorkStore(): DriveWorkStore +} + +/** + * The WLAN timeline of the work photos: which WLAN the phone is in, and since when. While the app runs it follows the + * network callback of [TrustedNetworkMonitor] ("Vertrauenswürdige WLANs", with its location permission and SSID); + * when it does not, Android wakes it for every WLAN it joins (a network request with a PendingIntent, kept by the + * system across the app's death and Doze), and the photo backup looks again when new photos come and when it runs. + * Nothing of it leaves the phone. A WLAN not assigned yet is asked about ("Wo bist du gerade?") with a notification. + */ +@Singleton +class DriveWorkWifi @Inject constructor( + @ApplicationContext private val context: Context, + private val monitor: TrustedNetworkMonitor, + private val store: DriveWorkStore, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + CrashReports.coroutineHandler) + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private var started = false + + /** The network now (live, from the trusted-network monitor). */ + val network: kotlinx.coroutines.flow.StateFlow get() = monitor.network + + /** Android tells WLAN names only with the precise location (and the location switched on). */ + fun canSeeNames(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED && + context.getSystemService(LocationManager::class.java)?.isLocationEnabled != false + + /** Also while the app is in the background ("Immer zulassen"). */ + fun canSeeInBackground(): Boolean = canSeeNames() && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED + + /** On app start (once): the wake for new WLANs, then every change while the app runs. */ + @OptIn(FlowPreview::class) + @Synchronized + fun start() { + if (started) return + started = true + registerWake() + scope.launch { + monitor.network + .map { ssidOf(it) } + .distinctUntilChanged() + // the callback reports a WLAN in steps (connected, then validated, then its name) + .debounce(SETTLE_MS) + .collect { onSeen(it) } + } + } + + /** The WLAN now, as far as Android tells it; recorded. [wait]: give the callback a moment (just woken up). */ + suspend fun sample(wait: Boolean = false): String? { + var ssid = ssidOf(monitor.network.value) + if (ssid == null && wait) { + ssid = withTimeoutOrNull(WAKE_WAIT_MS) { monitor.network.map { ssidOf(it) }.first { it != null } } + } + if (ssid == null && monitor.network.value.kind == TrustedNetSnapshot.Kind.WIFI) ssid = fromWifiManager() + onSeen(ssid) + return ssid + } + + /** After the location permission was granted: the callback anew (it decides then whether it may tell names). */ + fun refresh() { + monitor.refresh() + registerWake() + } + + private fun ssidOf(s: TrustedNetSnapshot): String? = if (s.kind == TrustedNetSnapshot.Kind.WIFI) s.ssid else null + + @Suppress("DEPRECATION") + private fun fromWifiManager(): String? = if (!canSeeNames()) null else runCatching { + TrustedNetworkRules.normalizeSsid(context.getSystemService(WifiManager::class.java)?.connectionInfo?.ssid) + }.getOrNull() + + private fun onSeen(ssid: String?) { + store.seen(ssid) + val s = store.settings.value + if (ssid == null || !s.active || !s.askNewWifi || !s.zones.unknown(ssid)) return + if (store.ask.value == ssid) return + val last = prefs.getString(KEY_NOTIFIED, null) + val lastAt = prefs.getLong(KEY_NOTIFIED_AT, 0L) + val now = System.currentTimeMillis() + if (last == ssid && now - lastAt < RENOTIFY_MS) return + prefs.edit { putString(KEY_NOTIFIED, ssid); putLong(KEY_NOTIFIED_AT, now) } + store.setAsk(ssid) + notifyNew(ssid) + } + + /** The answer of the dialog or the notification's buttons; the notification goes. */ + fun answer(ssid: String, zone: WifiZone?, stopAsking: Boolean = false) { + if (zone != null) store.setZone(ssid, zone) else if (store.ask.value == ssid) store.setAsk(null) + if (stopAsking) store.update { it.copy(askNewWifi = false) } + runCatching { NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) } + } + + // ─── the wake for new WLANs ───────────────────────────────────── + + private fun wakeIntent(): PendingIntent = PendingIntent.getBroadcast( + context, + REQUEST_WAKE, + Intent(context, DriveWorkWifiReceiver::class.java).setAction(ACTION_WAKE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + /** + * Android sends [ACTION_WAKE] each time a WLAN comes up — the app's process started for it if needed. Kept by the + * system until the app is removed; registered again on every start (after a reboot it is gone). + */ + private fun registerWake() { + val cm = context.getSystemService(ConnectivityManager::class.java) ?: return + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build() + val pi = wakeIntent() + runCatching { cm.unregisterNetworkCallback(pi) } + try { + cm.registerNetworkCallback(request, pi) + } catch (e: Exception) { + Timber.w("Drive work: WLAN wake not registered (%s)", e.javaClass.simpleName) + } + } + + // ─── the notification ─────────────────────────────────────────── + + private fun notifyNew(ssid: String) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return + val nm = context.getSystemService(NotificationManager::class.java) ?: return + if (nm.getNotificationChannel(CHANNEL) == null) { + nm.createNotificationChannel(NotificationChannel(CHANNEL, context.getString(R.string.drive_work_channel), NotificationManager.IMPORTANCE_DEFAULT)) + } + val open = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { + PendingIntent.getActivity(context, NOTIFICATION_ID, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + } + fun action(zone: WifiZone, label: Int, request: Int): NotificationCompat.Action { + val intent = Intent(context, DriveWorkWifiReceiver::class.java) + .setAction(ACTION_ANSWER) + .putExtra(EXTRA_SSID, ssid) + .putExtra(EXTRA_ZONE, zone.name) + val pi = PendingIntent.getBroadcast(context, request, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + return NotificationCompat.Action(0, context.getString(label), pi) + } + val n = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.drive_work_notify_title)) + .setContentText(context.getString(R.string.drive_work_notify_text, ssid)) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.drive_work_notify_text, ssid))) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + .addAction(action(WifiZone.WORK, R.string.drive_work_zone_work, REQUEST_WORK)) + .addAction(action(WifiZone.HOME, R.string.drive_work_zone_home, REQUEST_HOME)) + .addAction(action(WifiZone.NEITHER, R.string.drive_work_zone_neither, REQUEST_NEITHER)) + .apply { open?.let { setContentIntent(it) } } + .build() + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, n) } + } + + internal companion object { + const val ACTION_WAKE = "com.gatecontrol.android.drive.work.WIFI_WAKE" + const val ACTION_ANSWER = "com.gatecontrol.android.drive.work.WIFI_ANSWER" + const val EXTRA_SSID = "ssid" + const val EXTRA_ZONE = "zone" + private const val PREFS = "drive_work_wifi" + private const val KEY_NOTIFIED = "notified" + private const val KEY_NOTIFIED_AT = "notified_at" + private const val CHANNEL = "drive_work" + private const val NOTIFICATION_ID = 0x6E000010 + private const val REQUEST_WAKE = 0x6E10 + private const val REQUEST_WORK = 0x6E11 + private const val REQUEST_HOME = 0x6E12 + private const val REQUEST_NEITHER = 0x6E13 + private const val SETTLE_MS = 2_000L + private const val WAKE_WAIT_MS = 4_000L + /** The same new WLAN is asked about again after this long (the answer "Später"). */ + private const val RENOTIFY_MS = 12L * 60 * 60 * 1000 + } +} + +/** + * Android's wake for a new WLAN ([DriveWorkWifi.ACTION_WAKE]) and the buttons of the "Neues WLAN" notification. NOT + * exported: only the system's network request and the app's own notification reach it (their PendingIntents). + */ +class DriveWorkWifiReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val entry = runCatching { EntryPointAccessors.fromApplication(context.applicationContext, DriveWorkEntryPoint::class.java) }.getOrNull() ?: return + when (intent.action) { + DriveWorkWifi.ACTION_WAKE -> { + val pending = goAsync() + val wifi = entry.driveWorkWifi() + wifi.start() + CoroutineScope(Dispatchers.Default).launch { + try { + wifi.sample(wait = true) + } finally { + pending.finish() + } + } + } + DriveWorkWifi.ACTION_ANSWER -> { + val ssid = intent.getStringExtra(DriveWorkWifi.EXTRA_SSID) ?: return + val zone = runCatching { WifiZone.valueOf(intent.getStringExtra(DriveWorkWifi.EXTRA_ZONE).orEmpty()) }.getOrNull() ?: return + entry.driveWorkWifi().answer(ssid, zone) + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index 6dc31dbc..2aeedbc2 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1538,6 +1538,7 @@ private fun NavGraphBuilder.driveRoutes(navController: NavHostController, naviga deadlines = { go(Screen.DriveDeadlines.route) }, vault = vault, settings = { go(Screen.DriveSettings.route) }, + cleanup = { go(Screen.DriveCleanup.route) }, ), ) } @@ -1577,7 +1578,10 @@ private fun NavGraphBuilder.driveRoutes(navController: NavHostController, naviga } } composable(Screen.DriveBackup.route) { - GcContentWidth { com.gatecontrol.android.ui.drive.DriveBackupScreen(onBack = back) } + GcContentWidth { com.gatecontrol.android.ui.drive.DriveBackupScreen(onBack = back, onCleanup = { go(Screen.DriveCleanup.route) }) } + } + composable(Screen.DriveCleanup.route) { + GcContentWidth { com.gatecontrol.android.ui.drive.DriveCleanupScreen(onBack = back) } } composable(Screen.DriveFolders.route) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveFolderBackupScreen(onBack = back) } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index c0edb570..ba66e01f 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -141,6 +141,8 @@ sealed class Screen(val route: String) { data object DriveTrash : Screen("drive/trash") data object DriveSearch : Screen("drive/search") data object DriveBackup : Screen("drive/backup") + /** "Aufräumen" of the work photos (Drive 1.13): the AI's suggestions among the photos backed up. */ + data object DriveCleanup : Screen("drive/cleanup") /** "Drive-Einstellungen" (Drive 1.11): the gear of the Drive menu, the app settings' row "Drive". */ data object DriveSettings : Screen("drive/settings") /** "Ordner sichern": phone folders backed up into Drive. */ diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBackupScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBackupScreen.kt index 4e8533de..bf05092f 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBackupScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBackupScreen.kt @@ -57,9 +57,9 @@ import com.gatecontrol.android.ui.components.GcBanner import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcIconSquare import com.gatecontrol.android.ui.components.GcIcons -import com.gatecontrol.android.ui.components.GcListRow import com.gatecontrol.android.ui.components.GcOutlineButton import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcSectionLabel import com.gatecontrol.android.ui.components.GcSubpageBar import com.gatecontrol.android.ui.components.GcSwitchRow import com.gatecontrol.android.ui.components.GcTone @@ -152,7 +152,7 @@ internal fun backupText(context: Context, code: String): String = when (code) { /** Drive tab › Foto-Sicherung. */ @Composable -fun DriveBackupScreen(onBack: () -> Unit, viewModel: DriveBackupViewModel = hiltViewModel()) { +fun DriveBackupScreen(onBack: () -> Unit, onCleanup: () -> Unit = {}, viewModel: DriveBackupViewModel = hiltViewModel()) { val context = LocalContext.current val config by viewModel.backup.config.collectAsStateWithLifecycle() val stats by viewModel.backup.stats.collectAsStateWithLifecycle() @@ -204,12 +204,6 @@ fun DriveBackupScreen(onBack: () -> Unit, viewModel: DriveBackupViewModel = hilt enabled = !busy, onCheckedChange = { on -> if (on) turnOn() else viewModel.disable() }, ) - GcRowDivider() - // fixed: Meine Dateien › Bilder › Kamera-Uploads › year › month - GcListRow( - title = stringResource(R.string.drive_backup_folder), - description = stringResource(R.string.drive_backup_camera_target), - ) } if (busy) DriveLoading() @@ -229,6 +223,10 @@ fun DriveBackupScreen(onBack: () -> Unit, viewModel: DriveBackupViewModel = hilt BackupStatusCard(config, stats, status, pending, onRun = viewModel::runNow) } + // Drive 1.13: work photos apart (targets, WLANs, the AI, on the phone) + WorkPhotoSections(onCleanup = onCleanup) + + GcSectionLabel(stringResource(R.string.drive_work_section_when), Modifier.padding(horizontal = 4.dp)) GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { GcSwitchRow( label = stringResource(R.string.drive_backup_wifi), diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkActions.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkActions.kt index 9e4e11c9..e011439e 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkActions.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkActions.kt @@ -61,6 +61,8 @@ internal class DriveBulkActions( private val nameOf: (DNode) -> String = { it.name }, /** The vault's own folder (the target never goes above it). */ private val vaultRoot: () -> Long? = { null }, + /** Items moved from the folder shown (null: the photos, the search) into a target (work photos learn from it). */ + private val onMoved: (nodes: List, from: DFolder?, target: DFolder) -> Unit = { _, _, _ -> }, ) { private val me: DMe? get() = live.me.value @@ -346,6 +348,7 @@ internal class DriveBulkActions( when (val r = ops.start(t.mode.op, allowed, f.node.id, onConflict, title, label, pending)) { is DResult.Ok -> { if (check.blocked.isNotEmpty()) _message.value = "bulk_some_blocked:" + check.blocked.size + if (t.mode == DriveBulk.Mode.MOVE) onMoved(nodes, here(), f) follow(r.value.id, t.mode.op, nodes.size, label, allowed) } else -> failed(r.errorCode(), chosen) @@ -401,6 +404,7 @@ internal class DriveBulkActions( } } ops.finishLocal(id, DOpResults(done, skipped, failed)) + if (moved.isNotEmpty()) onMoved(t.nodes.filter { it.id in moved }, here(), f) follow(id, "move", done, label, moved) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt index d30a7847..bc991c8d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt @@ -294,6 +294,8 @@ internal fun BulkMoreSheet( single: List, last: List, onDismiss: () -> Unit, + /** Further sections at the end, each with its label (null: none). */ + sections: List>> = emptyList(), ) { val x = GateControlTheme.extraColors val context = LocalContext.current @@ -344,6 +346,12 @@ internal fun BulkMoreSheet( GcRowDivider(Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) last.forEach { MoreRow(it, onDismiss) } } + sections.forEach { (label, items) -> + if (items.isEmpty()) return@forEach + GcRowDivider(Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) + if (label != null) GcSectionLabel(label, modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 4.dp).semantics { heading() }) + items.forEach { MoreRow(it, onDismiss) } + } } } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt index c3b8b657..1ded8268 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt @@ -712,7 +712,9 @@ private fun NodeTile( if (!node.isDir && !node.encrypted) Text(DriveLogic.badge(node, stringResource(R.string.drive_photo)), style = MaterialTheme.typography.labelSmall, color = look.fg) } } - if (kind != null) DocKindBadge(kind, 22, Modifier.align(Alignment.TopEnd).padding(6.dp)) + // Drive 1.13: a photo to be reviewed (work or private?) shows the yellow question mark + if (node.review != null) ReviewMark(Modifier.align(Alignment.TopEnd).padding(6.dp), description = stringResource(R.string.drive_work_tile_review)) + else if (kind != null) DocKindBadge(kind, 22, Modifier.align(Alignment.TopEnd).padding(6.dp)) if (selecting) SelectCheck(selected, Modifier.align(Alignment.TopStart).padding(6.dp), onImage = picture != null) if (progress != null) { LinearProgressIndicator(progress = { progress }, modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(3.dp)) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderChooser.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderChooser.kt index 9e0251fe..cbd86d54 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderChooser.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderChooser.kt @@ -103,6 +103,14 @@ class DriveFolderChooserViewModel @Inject constructor( return f.node.id } + /** The path of the folder shown ("Meine Dateien › Arbeit"); null when none is. */ + fun pickedLabel(): String? { + val f = (_state.value.folder as? DriveLoad.Ready)?.value ?: return null + val area = _state.value.areas.firstOrNull { it.id == f.node.areaId }?.label ?: f.crumbs.firstOrNull()?.name ?: return null + val names = f.crumbs.drop(1).map { it.name } + listOfNotNull(f.node.name.takeIf { f.node.parentId != null && it.isNotEmpty() }) + return (listOf(area) + names).joinToString(" › ") + } + private companion object { const val KEY_LAST = "save_folder" } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt index 8f8d91d6..50988bc6 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt @@ -846,6 +846,8 @@ internal fun FolderPickerDialog( onDismiss: () -> Unit, hide: Long? = null, onNewFolder: (suspend (parentId: Long, name: String) -> DResult)? = null, + /** A further button next to "Abbrechen" (e.g. "Kein Ordner für Arbeitsfotos"). */ + extra: (@Composable () -> Unit)? = null, ) { val x = GateControlTheme.extraColors val ready = (folder as? DriveLoad.Ready)?.value @@ -915,6 +917,11 @@ internal fun FolderPickerDialog( } }, confirmButton = { TextButton(onClick = onPick, enabled = ready != null && canPick(ready)) { Text(confirm) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.drive_cancel)) } }, + dismissButton = { + Row { + extra?.invoke() + TextButton(onClick = onDismiss) { Text(stringResource(R.string.drive_cancel)) } + } + }, ) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt index 078c402b..f62de4bf 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt @@ -103,6 +103,8 @@ data class DriveNav( val vault: (String, Long) -> Unit = { _, _ -> }, /** "Drive-Einstellungen" (Drive 1.11, the gear of the menu). */ val settings: () -> Unit = {}, + /** "Aufräumen" of the work photos (Drive 1.13). */ + val cleanup: () -> Unit = {}, /** The folder the Files page shows now (null: none, or the photos) — a folder page's path goes back to it. */ val shown: (Long?) -> Unit = {}, ) @@ -282,7 +284,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel key = { it }, ) { page -> if (DriveLogic.isPhotosTab(page, hasPhotos)) { - DrivePhotosContent(viewModel = photosViewModel, topBar = {}, onAlbum = nav.album) + DrivePhotosContent(viewModel = photosViewModel, topBar = {}, onAlbum = nav.album, onCleanup = nav.cleanup, onFile = nav.file) } else { DriveFilesPage( folderViewModel = folderViewModel, diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt index 576fc10f..0b38d962 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt @@ -96,6 +96,10 @@ import com.gatecontrol.android.drive.DMemories import com.gatecontrol.android.drive.DNode import com.gatecontrol.android.drive.DResult import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.work.DriveWorkLogic +import com.gatecontrol.android.drive.work.DriveWorkPhotos +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcFilterChip import com.gatecontrol.android.ui.components.GcIconButton @@ -127,7 +131,8 @@ internal fun photosText(context: Context, code: String): String { code == "album_cover" -> context.getString(R.string.drive_album_cover_done) code == "album_frame_on" -> context.getString(R.string.drive_album_frame_on) code == "album_frame_off" -> context.getString(R.string.drive_album_frame_off) - else -> driveText(context, code) + code == "work_undone" -> context.getString(R.string.drive_work_snack_undone) + else -> workText(context, code) } } @@ -160,7 +165,13 @@ internal fun DriveModeSwitch(photos: Boolean, onPhotos: (Boolean) -> Unit) { */ @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Composable () -> Unit, onAlbum: (String) -> Unit) { +internal fun DrivePhotosContent( + viewModel: DrivePhotosViewModel, + topBar: @Composable () -> Unit, + onAlbum: (String) -> Unit, + onCleanup: () -> Unit = {}, + onFile: ((Long) -> Unit)? = null, +) { val context = LocalContext.current val scope = rememberCoroutineScope() val me by viewModel.live.me.collectAsStateWithLifecycle() @@ -180,7 +191,23 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos var choosing by remember { mutableStateOf(false) } var creating by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { viewModel.start() } + // Drive 1.13: work photos apart (Alle | Privat | Arbeit | Prüfen, the review mark, Arbeit / Privat in front) + val cls by viewModel.cls.collectAsStateWithLifecycle() + val classes by viewModel.classes.collectAsStateWithLifecycle() + val workSettings by viewModel.workSettings.collectAsStateWithLifecycle() + val localReview by viewModel.localReview.collectAsStateWithLifecycle() + val learned by viewModel.learned.collectAsStateWithLifecycle() + val suggested by viewModel.work.suggested.collectAsStateWithLifecycle() + val workSnack by viewModel.workSnack.collectAsStateWithLifecycle() + val serverClasses = viewModel.work.serverClasses(me) + val workOn = workSettings.active || serverClasses + var moreOpen by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.start() + val cm = context.getSystemService(android.net.ConnectivityManager::class.java) + viewModel.lookForSuggestions(unmetered = cm != null && !cm.isActiveNetworkMetered) + } LaunchedEffect(me?.features) { viewModel.featuresChanged() } LaunchedEffect(tab) { if (tab == "albums" && albums == null) viewModel.loadAlbums() } LaunchedEffect(message) { @@ -197,6 +224,26 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos // back ends the selection first BackHandler(enabled = selecting) { viewModel.clearSelection() } val chosen = remember(selected, timeline.items) { timeline.items.filter { it.id in selected } } + // without Drive 1.13 "Prüfen" shows this phone's marks among the photos loaded + val shown = if (!serverClasses && cls == "review") timeline.copy(items = timeline.items.filter { it.id in localReview }) else timeline + val reviewCount = if (serverClasses) classes?.review ?: 0 else localReview.size + val reviewBar = DriveWorkLogic.reviewBar(chosen, localReview) + val workSnackLearns = stringResource(R.string.drive_work_snack_learns) + val undoLabel = stringResource(R.string.drive_snack_undo) + LaunchedEffect(workSnack) { + val w = workSnack ?: return@LaunchedEffect + viewModel.workSnackShown() + val r = context.resources + val target = workSettings.folderLabel ?: context.getString(R.string.drive_work_work_default) + val line = when (w.to) { + DriveWorkPhotos.Target.WORK -> r.getQuantityString(R.plurals.drive_work_snack_work, w.count, w.count, target) + DriveWorkPhotos.Target.PRIVATE -> r.getQuantityString(R.plurals.drive_work_snack_private, w.count, w.count) + DriveWorkPhotos.Target.REVIEW -> r.getQuantityString(R.plurals.drive_work_snack_review, w.count, w.count) + } + val text = if (w.to == DriveWorkPhotos.Target.REVIEW) line else line + "\n" + workSnackLearns + val result = snackbar.showSnackbar(text, actionLabel = if (w.undo != null) undoLabel else null, withDismissAction = w.undo == null, duration = SnackbarDuration.Long) + if (result == SnackbarResult.ActionPerformed) w.undo?.let(viewModel::undoWork) + } val where = DriveBulk.Where( writable = { n -> me?.areas?.firstOrNull { it.id == n.areaId }?.writable == true }, child = { n -> me?.areas?.firstOrNull { it.id == n.areaId }?.via == "parent" }, @@ -205,11 +252,36 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { if (selecting) { - SelectionHead(count = selected.size, line = stringResource(R.string.drive_photos_sel_hint), allSelected = false, onClose = viewModel::clearSelection, onAll = null) + val line = when { + reviewBar -> stringResource(R.string.drive_work_sel_review) + workOn -> stringResource(R.string.drive_work_sel_normal) + else -> stringResource(R.string.drive_photos_sel_hint) + } + SelectionHead(count = selected.size, line = line, allSelected = false, onClose = viewModel::clearSelection, onAll = null) } else { topBar() } - if (!selecting && (hasAlbums || hasCamera)) { + if (!selecting && workOn) { + val why = if (serverClasses) null else stringResource(R.string.drive_work_needs_1130) + val reviewDesc = stringResource(R.string.drive_work_review_count, reviewCount) + Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 0.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + val onTimeline = tab == "timeline" || !hasAlbums + WorkChip(stringResource(R.string.drive_work_all), selected = onTimeline && cls == null, onClick = { tab = "timeline"; viewModel.setClass(null) }) + WorkChip(stringResource(R.string.drive_work_private), selected = onTimeline && cls == "private", why = why, onClick = { tab = "timeline"; viewModel.setClass("private") }) + WorkChip(stringResource(R.string.drive_work_work), selected = onTimeline && cls == "work", why = why, onClick = { tab = "timeline"; viewModel.setClass("work") }) + WorkChip( + stringResource(R.string.drive_work_review), + selected = onTimeline && cls == "review", + count = reviewCount, + description = reviewDesc, + onClick = { tab = "timeline"; viewModel.setClass("review") }, + ) + if (hasAlbums) GcFilterChip(stringResource(R.string.drive_photos_albums), selected = tab == "albums", onClick = { tab = "albums" }, modifier = Modifier.padding(start = 4.dp)) + if (hasCamera && onTimeline) { + GcFilterChip(stringResource(R.string.drive_photos_camera_only), selected = cameraOnly, onClick = { viewModel.setCameraOnly(!cameraOnly) }, modifier = Modifier.padding(start = 8.dp)) + } + } + } else if (!selecting && (hasAlbums || hasCamera)) { Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (hasAlbums) { GcFilterChip(stringResource(R.string.drive_photos_timeline), selected = tab == "timeline", onClick = { tab = "timeline" }) @@ -230,14 +302,28 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos BulkBusyCard(busy, viewModel.bulk::cancelBusy) } } + if (!selecting && workOn && tab != "albums") { + if (reviewCount > 0) { + WorkBand( + pluralStringResource(R.plurals.drive_work_banner, reviewCount, reviewCount), + button = if (cls != "review") stringResource(R.string.drive_work_banner_show) else null, + warn = true, + onClick = { viewModel.setClass("review") }, + ) + } + if (workSettings.active && learned.usable && suggested > 0 && cls != "review") { + WorkBand(pluralStringResource(R.plurals.drive_work_cleanup_hint, suggested, suggested), button = stringResource(R.string.drive_work_cleanup_open), warn = false, onClick = onCleanup) + } + } Box(Modifier.weight(1f)) { if (tab == "albums" && hasAlbums) { AlbumGrid(albums, onAlbum = { onAlbum(it.id) }, onNew = { creating = false }, onRetry = viewModel::loadAlbums) } else { PhotoGrid( - pages = timeline, + pages = shown, selected = selected, - months = months, + months = if (shown !== timeline) emptyMap() else months, + badge = if (workOn) ({ n -> TileBadge(review = DriveWorkLogic.isReview(n, localReview), work = n.work && cls == null) }) else null, onOpen = { i -> viewing = null to i }, onToggle = viewModel::toggle, onMore = viewModel::more, @@ -253,15 +339,28 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos } } if (selecting) { - SelectionActionBar( - listOf( - BarAction(GcIcons.Share, stringResource(R.string.drive_bar_send), viewModel.bulk.can(DriveBulk.Action.SEND, chosen, where)) { viewModel.bulk.send() }, - BarAction(GcIcons.Photo, stringResource(R.string.drive_bar_album), viewModel.bulk.can(DriveBulk.Action.ALBUM, chosen, where)) { viewModel.loadAlbums(); choosing = true }, - BarAction(GcIcons.Download, stringResource(R.string.drive_bar_save), viewModel.bulk.can(DriveBulk.Action.SAVE, chosen, where)) { viewModel.bulk.save() }, - BarAction(GcIcons.FolderMove, stringResource(R.string.drive_bar_move), viewModel.bulk.can(DriveBulk.Action.MOVE, chosen, where)) { viewModel.bulk.openTarget(DriveBulk.Mode.MOVE) }, - BarAction(GcIcons.Trash, stringResource(R.string.drive_bar_delete), viewModel.bulk.can(DriveBulk.Action.DELETE, chosen, where), danger = true) { viewModel.bulk.delete() }, - ), - ) + val send = BarAction(GcIcons.Share, stringResource(R.string.drive_bar_send), viewModel.bulk.can(DriveBulk.Action.SEND, chosen, where)) { viewModel.bulk.send() } + val delete = BarAction(GcIcons.Trash, stringResource(R.string.drive_bar_delete), viewModel.bulk.can(DriveBulk.Action.DELETE, chosen, where), danger = true) { viewModel.bulk.delete() } + val more = BarAction(GcIcons.MoreHorizontal, stringResource(R.string.drive_bar_more), DriveBulk.Can.YES) { moreOpen = true } + if (reviewBar) { + // every photo ticked is to be reviewed: "Arbeit" and "Privat" in front + ReviewAssignBar( + hint = DriveWorkLogic.hint(chosen.map { DriveWorkLogic.hintOf(it, localReview) }), + onWork = { viewModel.assign(DriveWorkPhotos.Target.WORK) }, + onPrivate = { viewModel.assign(DriveWorkPhotos.Target.PRIVATE) }, + small = listOf(send, delete, more), + ) + } else { + SelectionActionBar( + listOf( + send, + BarAction(GcIcons.Photo, stringResource(R.string.drive_bar_album), viewModel.bulk.can(DriveBulk.Action.ALBUM, chosen, where)) { viewModel.loadAlbums(); choosing = true }, + BarAction(GcIcons.FolderMove, stringResource(R.string.drive_bar_move), viewModel.bulk.can(DriveBulk.Action.MOVE, chosen, where)) { viewModel.bulk.openTarget(DriveBulk.Mode.MOVE) }, + delete, + more, + ), + ) + } } } // jobs on several items: the ring of the round button (a tap shows them one by one) @@ -275,6 +374,19 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = if (selecting) 88.dp else if (running.isNotEmpty()) 84.dp else 16.dp)) } DriveBulkHost(viewModel.bulk, snackbar) + if (moreOpen && chosen.isNotEmpty()) { + PhotosMoreSheet( + nodes = chosen, + viewModel = viewModel, + where = where, + workOn = workOn, + workLabel = workSettings.folderLabel ?: stringResource(R.string.drive_work_work_default), + workFolderSet = workSettings.active, + localReview = localReview, + onFile = onFile, + onDismiss = { moreOpen = false }, + ) + } viewing?.let { (years, start) -> PhotoViewer( @@ -356,6 +468,8 @@ internal fun PhotoGrid( months: Map? = null, header: (LazyGridScope.() -> Unit)? = null, onSelect: ((Set) -> Unit)? = null, + /** Drive 1.13: the review mark and the work bag of a tile. */ + badge: ((DNode) -> TileBadge)? = null, ) { val state = rememberLazyGridState() val nearEnd by remember { @@ -458,6 +572,7 @@ internal fun PhotoGrid( onLongClick = { onToggle(n) }, // with a swipe to tick the grid takes the long press itself longPressGesture = onSelect == null, + badge = badge?.invoke(n), ) } } @@ -479,16 +594,32 @@ internal fun PhotoGrid( * A photo's tile: the tick top left while ticking (the ticked tile inset), the play mark of a video. [longPressGesture] * false: the grid takes the long press (a swipe ticks several), TalkBack still has "Auswählen". */ +/** What a tile shows of the work photos: the yellow question mark ([review]), the blue bag ([work]). */ +internal data class TileBadge(val review: Boolean, val work: Boolean) + @OptIn(ExperimentalFoundationApi::class) @Composable -private fun PhotoTile(node: DNode, selecting: Boolean, ticked: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, longPressGesture: Boolean = true) { +private fun PhotoTile( + node: DNode, + selecting: Boolean, + ticked: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + longPressGesture: Boolean = true, + badge: TileBadge? = null, +) { val look = kindLook(DriveLogic.kind(node)) val picture = rememberDrivePreview(node) val haptic = LocalHapticFeedback.current val selectLabel = stringResource(R.string.drive_photos_select) val clickLabel = if (selecting) stringResource(if (ticked) R.string.drive_sel_deselect else R.string.drive_sel_select) else null val state = stringResource(if (ticked) R.string.drive_sel_selected else R.string.drive_sel_not_selected) - val label = listOfNotNull(node.name, photoDate(node)).joinToString(", ") + val label = listOfNotNull( + node.name, + photoDate(node), + if (badge?.review == true) stringResource(R.string.drive_work_tile_review) else null, + if (badge?.work == true) stringResource(R.string.drive_work_tile_work) else null, + ).joinToString(", ") Box( Modifier .aspectRatio(1f) @@ -529,6 +660,8 @@ private fun PhotoTile(node: DNode, selecting: Boolean, ticked: Boolean, onClick: if (DriveLogic.kind(node) == DriveLogic.Kind.VIDEO) VideoMark(24) } if (selecting) SelectCheck(ticked, Modifier.align(Alignment.TopStart).padding(6.dp), onImage = true) + if (badge?.review == true) ReviewMark(Modifier.align(Alignment.TopEnd).padding(6.dp)) + if (badge?.work == true) WorkBag(Modifier.align(Alignment.BottomStart).padding(6.dp)) } } @@ -794,3 +927,87 @@ internal fun FrameCard(album: DAlbum, onFrame: (Boolean) -> Unit) { ) } } + +/** + * "Mehr" of the ticked photos: save on the phone, copy, one link, then — with work photos apart (Drive 1.13) — the + * section "Zuordnung" ("Ist ein Arbeitsfoto", "Ist privat", "Zum Prüfen markieren"), and the details of one. + */ +@Composable +private fun PhotosMoreSheet( + nodes: List, + viewModel: DrivePhotosViewModel, + where: DriveBulk.Where, + workOn: Boolean, + workLabel: String, + workFolderSet: Boolean, + localReview: Map, + onFile: ((Long) -> Unit)?, + onDismiss: () -> Unit, +) { + val bulk = viewModel.bulk + val one = nodes.singleOrNull() + val can: (DriveBulk.Action) -> DriveBulk.Can = { a -> bulk.can(a, nodes, where) } + val multi = listOf( + MoreItem(GcIcons.Download, stringResource(R.string.drive_more_save), stringResource(R.string.drive_more_save_desc), can(DriveBulk.Action.SAVE)) { bulk.save(nodes) }, + MoreItem(GcIcons.Copy, stringResource(R.string.drive_bar_copy), can = can(DriveBulk.Action.COPY)) { bulk.openTarget(DriveBulk.Mode.COPY, nodes) }, + MoreItem( + GcIcons.Link, + stringResource(R.string.drive_share_link), + if (one != null) stringResource(R.string.drive_more_link_one) else pluralStringResource(R.plurals.drive_more_link, nodes.size, nodes.size), + can(DriveBulk.Action.LINK), + ) { bulk.openLink(nodes) }, + ) + val assign = if (!workOn) emptyList() else { + val offer = DriveWorkLogic.offer(nodes, localReview) + listOf( + MoreItem( + GcIcons.Briefcase, + stringResource(R.string.drive_work_more_work), + stringResource(R.string.drive_work_more_work_desc, workLabel), + when { + !workFolderSet -> DriveBulk.Can(false, null) + !offer.work -> DriveBulk.Can(false, null) + else -> can(DriveBulk.Action.MOVE) + }, + ) { viewModel.assign(DriveWorkPhotos.Target.WORK, nodes) }, + MoreItem( + GcIcons.House, + stringResource(R.string.drive_work_more_private), + stringResource(if (offer.private) R.string.drive_work_more_private_desc else R.string.drive_work_more_private_already), + if (offer.private) can(DriveBulk.Action.MOVE) else DriveBulk.Can(false, null), + ) { viewModel.assign(DriveWorkPhotos.Target.PRIVATE, nodes) }, + MoreItem( + GcIcons.Alert, + stringResource(R.string.drive_work_more_review), + if (offer.review) null else stringResource(R.string.drive_work_more_review_already), + if (offer.review) can(DriveBulk.Action.MOVE) else DriveBulk.Can(false, null), + ) { viewModel.assign(DriveWorkPhotos.Target.REVIEW, nodes) }, + ).map { item -> + // a greyed-out entry says why below its title + if (item.can.ok || item.description != null) item + else item.copy(description = stringResource(if (!workFolderSet) R.string.drive_work_more_no_folder else R.string.drive_work_more_work_already)) + } + } + val state = one?.let { + when { + DriveWorkLogic.isReview(it, localReview) -> stringResource(R.string.drive_work_state_review) + it.work -> stringResource(R.string.drive_work_state_work) + else -> stringResource(R.string.drive_work_state_private) + } + } + val details = if (one != null && onFile != null) listOf(MoreItem(GcIcons.Info, stringResource(R.string.drive_details)) { onFile(one.id) }) else emptyList() + BulkMoreSheet( + count = nodes.size, + title = null, + summary = if (one != null && workOn) "${one.name} · $state" else one?.name ?: summaryText(DriveBulk.summary(nodes)), + quick = emptyList(), + multi = multi, + single = emptyList(), + last = emptyList(), + sections = listOfNotNull( + if (assign.isNotEmpty()) stringResource(R.string.drive_work_more_section) to assign else null, + if (details.isNotEmpty()) null to details else null, + ), + onDismiss = onDismiss, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt index 347b8911..72ebf624 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosViewModels.kt @@ -14,6 +14,10 @@ import com.gatecontrol.android.drive.DriveApi import com.gatecontrol.android.drive.DriveLive import com.gatecontrol.android.drive.DriveLogic import com.gatecontrol.android.drive.DriveTransfers +import com.gatecontrol.android.drive.DPhotoClasses +import com.gatecontrol.android.drive.work.DriveWorkLogic +import com.gatecontrol.android.drive.work.DriveWorkPhotos +import com.gatecontrol.android.drive.work.WorkSettings import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job @@ -85,6 +89,7 @@ class DrivePhotosViewModel @Inject constructor( private val transfers: DriveTransfers, val live: DriveLive, val ops: com.gatecontrol.android.drive.DriveOps, + val work: DriveWorkPhotos, ) : ViewModel() { private val _cameraOnly = MutableStateFlow(DriveViewPref.cameraOnly(context)) @@ -94,9 +99,25 @@ class DrivePhotosViewModel @Inject constructor( /** The filter as it is sent: only while Drive knows the camera uploads. */ private fun camera(): Boolean = DriveLogic.cameraFilter(_cameraOnly.value, live.me.value) - private val pager = PhotoPager(viewModelScope) { before -> api.photos(before = before, camera = camera()) } + private val _cls = MutableStateFlow(null) + /** "Alle" (null) | `private` | `work` | `review` (Drive 1.13; without it `review` filters this phone's marks). */ + val cls: StateFlow = _cls.asStateFlow() + + /** The class as it is sent: only while Drive knows them. */ + private fun serverCls(): String? = if (work.serverClasses(live.me.value)) _cls.value else null + + private val pager = PhotoPager(viewModelScope) { before -> api.photos(before = before, camera = camera(), cls = serverCls()) } val timeline: StateFlow = pager.state + private val _classes = MutableStateFlow(null) + /** How many photos are work, private, to be reviewed (Drive 1.13). */ + val classes: StateFlow = _classes.asStateFlow() + + val workSettings: StateFlow = work.settings + val learned: StateFlow = work.learned + /** Without Drive 1.13: the marks of this phone. */ + val localReview: StateFlow> = work.localReview + private val _months = MutableStateFlow>(emptyMap()) /** Photos per month ("2026-10" → 120) for the headings. */ val months: StateFlow> = _months.asStateFlow() @@ -117,6 +138,8 @@ class DrivePhotosViewModel @Inject constructor( live = live, items = { timeline.value.items }, changed = { load(quiet = true) }, + // into or out of the work folder: the work photos' AI learns from it (Drive 1.13) + onMoved = { nodes, from, target -> work.learnMoved(nodes, from, target) }, ) /** Photos ticked (a long press, then taps or a swipe). */ @@ -148,9 +171,11 @@ class DrivePhotosViewModel @Inject constructor( private fun load(quiet: Boolean) { val camera = camera() + val cls = serverCls() loadedCamera = camera pager.reload(quiet = quiet) - viewModelScope.launch { _months.value = api.photoMonths(camera = camera).valueOrNull()?.associate { it.month to it.count }.orEmpty() } + viewModelScope.launch { _months.value = api.photoMonths(camera = camera, cls = cls).valueOrNull()?.associate { it.month to it.count }.orEmpty() } + if (work.serverClasses(live.me.value)) viewModelScope.launch { _classes.value = api.photoClasses(camera = camera).valueOrNull() } else _classes.value = null viewModelScope.launch { _memories.value = api.memories(LocalDate.now().toString(), camera = camera).valueOrNull().orEmpty() } if (_albums.value != null) loadAlbums() } @@ -168,6 +193,61 @@ class DrivePhotosViewModel @Inject constructor( fun more() = pager.more() + /** "Alle | Privat | Arbeit | Prüfen": the timeline of that class. */ + fun setClass(c: String?) { + if (c == _cls.value) return + _cls.value = c + bulk.clear() + if (work.serverClasses(live.me.value)) { + _months.value = emptyMap() + load(quiet = false) + } + } + + // ─── work photos (Drive 1.13) ─────────────────────────────────── + + /** The snackbar after "Arbeit" / "Privat" / "Zum Prüfen markieren" ([undo]: "Rückgängig"). */ + data class WorkSnack(val to: DriveWorkPhotos.Target, val count: Int, val undo: DriveWorkPhotos.Undo?, val stamp: Long = System.nanoTime()) + + private val _workSnack = MutableStateFlow(null) + val workSnack: StateFlow = _workSnack.asStateFlow() + + private val _assigning = MutableStateFlow(false) + val assigning: StateFlow = _assigning.asStateFlow() + + fun workSnackShown() { _workSnack.value = null } + + /** The ticked photos (or [only]) are work, private or to be reviewed. */ + fun assign(to: DriveWorkPhotos.Target, only: List? = null) { + val nodes = only ?: bulk.nodes() + if (nodes.isEmpty() || _assigning.value) return + bulk.clear() + viewModelScope.launch { + _assigning.value = true + try { + val r = work.assign(nodes, to) + if (r.error != null) _message.value = r.error else _workSnack.value = WorkSnack(to, r.count, r.undo) + } finally { + _assigning.value = false + load(quiet = true) + } + } + } + + fun undoWork(u: DriveWorkPhotos.Undo) { + viewModelScope.launch { + _message.value = work.undo(u) ?: "work_undone" + load(quiet = true) + } + } + + /** "N Fotos sind wahrscheinlich Arbeitsfotos": from time to time, on WLAN, a short look at the newest photos. */ + fun lookForSuggestions(unmetered: Boolean) { + if (!unmetered || !work.settings.value.active || !work.learned.value.usable) return + if (System.currentTimeMillis() - work.suggestedAt() < SUGGEST_EVERY_MS) return + viewModelScope.launch { runCatching { work.suggest(limit = SUGGEST_LOOK) } } + } + fun loadAlbums() { if (_albums.value !is DriveLoad.Ready) _albums.value = DriveLoad.Loading viewModelScope.launch { _albums.value = api.albums().toLoad { list -> list.sortedByDescending { it.updatedAt.orEmpty() } } } @@ -220,6 +300,8 @@ class DrivePhotosViewModel @Inject constructor( companion object { const val NAME_MAX = 100 + private const val SUGGEST_EVERY_MS = 3L * 24 * 60 * 60 * 1000 + private const val SUGGEST_LOOK = 60 } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt index 8ec1a751..0195dd2c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt @@ -296,6 +296,7 @@ class DriveFolderViewModel @Inject constructor( private val vaultLock: com.gatecontrol.android.drive.vault.DriveVaultLock, private val settings: com.gatecontrol.android.drive.DriveSettings, val ops: com.gatecontrol.android.drive.DriveOps, + private val workPhotos: com.gatecontrol.android.drive.work.DriveWorkPhotos, ) : ViewModel() { /** The route's `id` (a string argument, see Screen.DriveFolder); on the Drive tab the chosen area's top ([open]). */ @@ -328,6 +329,8 @@ class DriveFolderViewModel @Inject constructor( items = { (_state.value as? DriveLoad.Ready)?.value?.items.orEmpty() }, here = { (_state.value as? DriveLoad.Ready)?.value }, changed = { refresh(quiet = true) }, + // into or out of the work folder: the work photos' AI learns from it (Drive 1.13) + onMoved = { nodes, from, target -> workPhotos.learnMoved(nodes, from, target) }, ) init { diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt new file mode 100644 index 00000000..8e5ac35c --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt @@ -0,0 +1,275 @@ +package com.gatecontrol.android.ui.drive + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.work.DriveWorkLogic +import com.gatecontrol.android.drive.work.DriveWorkPhotos +import com.gatecontrol.android.drive.work.WorkSettings +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +/** "Aufräumen": the AI's suggestions among the photos already backed up, ticked from 70 %, moved together. */ +@HiltViewModel +class DriveCleanupViewModel @Inject constructor(private val work: DriveWorkPhotos) : ViewModel() { + + sealed interface State { + data class Looking(val done: Int, val total: Int) : State + /** The AI cannot tell yet (too few examples). */ + data object Learning : State + data class Ready(val list: List) : State + } + + private val _state = MutableStateFlow(State.Looking(0, 0)) + val state: StateFlow = _state.asStateFlow() + private val _selected = MutableStateFlow>(emptySet()) + val selected: StateFlow> = _selected.asStateFlow() + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + private val _done = MutableStateFlow(false) + /** Moved: the page closes. */ + val done: StateFlow = _done.asStateFlow() + + val settings: StateFlow = work.settings + val learned: StateFlow = work.learned + + init { + viewModelScope.launch { + if (!work.learned.value.usable) { + _state.value = State.Learning + return@launch + } + val list = work.suggest { done, total -> _state.value = State.Looking(done, total) } + _selected.value = list.filter { it.preselected }.map { it.node.id }.toSet() + _state.value = if (list.isEmpty() && !work.learned.value.usable) State.Learning else State.Ready(list) + } + } + + fun toggle(id: Long) = _selected.update { if (id in it) it - id else it + id } + + /** "Alle" / "Keine" of a month. */ + fun allOrNone(ids: List) = _selected.update { cur -> if (ids.all { it in cur }) cur - ids.toSet() else cur + ids } + + fun clearMessage() { _message.value = null } + + fun go() { + val list = (_state.value as? State.Ready)?.list ?: return + if (_busy.value) return + val chosen = list.filter { it.node.id in _selected.value }.map { it.node } + val left = list.filter { it.node.id !in _selected.value }.map { it.node } + viewModelScope.launch { + _busy.value = true + try { + val r = work.cleanup(chosen, left) + if (r.error != null) { + _message.value = r.error + } else { + _message.value = if (chosen.isEmpty()) "cleanup_left" else "cleanup_done:${r.count}" + _done.value = true + } + } finally { + _busy.value = false + } + } + } +} + +private fun monthName(month: String?): String? = month?.let { + runCatching { YearMonth.parse(it).format(DateTimeFormatter.ofPattern("LLLL yyyy", Locale.getDefault())) }.getOrNull() +} + +/** Drive › Foto-Sicherung › Aufräumen. */ +@Composable +fun DriveCleanupScreen(onBack: () -> Unit, viewModel: DriveCleanupViewModel = hiltViewModel()) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val state by viewModel.state.collectAsStateWithLifecycle() + val selected by viewModel.selected.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val done by viewModel.done.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val learned by viewModel.learned.collectAsStateWithLifecycle() + val workLabel = settings.folderLabel ?: stringResource(R.string.drive_work_work_default) + val workName = workLabel.substringAfterLast(" › ") + + LaunchedEffect(message) { + val m = message ?: return@LaunchedEffect + val n = m.substringAfter(':', "").toIntOrNull() ?: 0 + val text = when { + m.startsWith("cleanup_done:") -> context.resources.getQuantityString(R.plurals.drive_work_cleanup_done, n, n) + m == "cleanup_left" -> context.getString(R.string.drive_work_cleanup_left) + else -> workText(context, m) + } + Toast.makeText(context, text, Toast.LENGTH_LONG).show() + viewModel.clearMessage() + } + LaunchedEffect(done) { if (done) onBack() } + + Column(Modifier.fillMaxSize()) { + GcSubpageBar(stringResource(R.string.drive_work_cleanup_title), onBack = onBack, backDescription = stringResource(R.string.drive_back)) + when (val s = state) { + is DriveCleanupViewModel.State.Looking -> Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.drive_work_cleanup_looking, s.done, s.total), color = x.muted, style = MaterialTheme.typography.bodyMedium) + LinearProgressIndicator( + progress = { if (s.total > 0) s.done.toFloat() / s.total else 0f }, + modifier = Modifier.fillMaxWidth(), + color = x.blue, + trackColor = x.panel2, + ) + } + DriveCleanupViewModel.State.Learning -> GcCard(Modifier.padding(16.dp)) { + Text(stringResource(R.string.drive_work_cleanup_learning, learned.progress, DriveWorkLogic.MIN_EXAMPLES), style = MaterialTheme.typography.bodyMedium) + } + is DriveCleanupViewModel.State.Ready -> { + val list = s.list + val likely = list.count { it.preselected } + GcCard(Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + if (list.isEmpty()) stringResource(R.string.drive_work_cleanup_empty) else pluralStringResource(R.plurals.drive_work_cleanup_head, likely, likely), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + if (list.isNotEmpty()) Text(stringResource(R.string.drive_work_cleanup_text), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 96.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + DriveWorkLogic.byMonth(list).forEach { (month, group) -> + item(key = "m-$month", span = { GridItemSpan(maxLineSpan) }) { + val ids = group.map { it.node.id } + val all = ids.all { it in selected } + val title = monthName(month) ?: stringResource(R.string.drive_photos_undated) + val desc = stringResource(if (all) R.string.drive_sel_none_of else R.string.drive_sel_all_of, title) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f).padding(start = 4.dp).semantics { heading() }) + TextButton(onClick = { viewModel.allOrNone(ids) }, modifier = Modifier.heightIn(min = 44.dp).semantics { contentDescription = desc }) { + Text(stringResource(if (all) R.string.drive_sel_none else R.string.drive_sel_all), color = x.accentText, fontWeight = FontWeight.SemiBold) + } + } + } + items(group, key = { "p-${it.node.id}" }) { sug -> + SuggestionTile(sug.node, sug.percent, sug.node.id in selected) { viewModel.toggle(sug.node.id) } + } + } + } + val n = list.count { it.node.id in selected } + Column( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).navigationBarsPadding().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.drive_work_cleanup_target, workLabel + if (settings.subfolders) " › …" else ""), style = MaterialTheme.typography.bodySmall, color = x.muted) + Button( + onClick = viewModel::go, + enabled = !busy && list.isNotEmpty() && settings.active, + modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = x.blue, contentColor = Color(0xFF0D1015)), + ) { + Text( + if (n == 0) stringResource(R.string.drive_work_cleanup_nothing) else pluralStringResource(R.plurals.drive_work_cleanup_go, n, n, workName), + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +@Composable +private fun SuggestionTile(node: DNode, percent: Int, ticked: Boolean, onToggle: () -> Unit) { + val look = kindLook(DriveLogic.kind(node)) + val picture = rememberDrivePreview(node) + val label = stringResource(R.string.drive_work_cleanup_tile, node.name, percent) + Box( + Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(6.dp)) + .background(if (ticked) GateControlTheme.extraColors.accentBg else Color.Transparent) + .toggleable(value = ticked, role = Role.Checkbox, onValueChange = { onToggle() }) + .semantics { contentDescription = label }, + ) { + Box( + Modifier.matchParentSize().padding(if (ticked) 8.dp else 0.dp).clip(RoundedCornerShape(if (ticked) 10.dp else 6.dp)).background(look.bg), + contentAlignment = Alignment.Center, + ) { + if (picture != null) Image(picture, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + else Icon(look.icon, contentDescription = null, tint = look.fg, modifier = Modifier.size(24.dp)) + } + SelectCheck(ticked, Modifier.align(Alignment.TopStart).padding(6.dp), onImage = true) + Text( + stringResource(R.string.drive_work_cleanup_percent, percent), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp).clip(RoundedCornerShape(6.dp)).background(Color.Black.copy(alpha = 0.6f)).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt new file mode 100644 index 00000000..dda7b74d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt @@ -0,0 +1,837 @@ +package com.gatecontrol.android.ui.drive + +import android.Manifest +import android.app.Activity +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.work.DriveWorkLogic +import com.gatecontrol.android.drive.work.DriveWorkPhotos +import com.gatecontrol.android.drive.work.DriveWorkStore +import com.gatecontrol.android.drive.work.DriveWorkWifi +import com.gatecontrol.android.drive.work.WifiZone +import com.gatecontrol.android.drive.work.WorkSettings +import com.gatecontrol.android.trustednet.TrustedNetSnapshot +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIconSquare +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcListRow +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.util.Locale +import javax.inject.Inject + +/** The settings of the work photos (Foto-Sicherung) and the question about a new WLAN. */ +@HiltViewModel +class DriveWorkViewModel @Inject constructor( + val work: DriveWorkPhotos, + val wifi: DriveWorkWifi, + private val store: DriveWorkStore, + val live: DriveLive, +) : ViewModel() { + val settings: StateFlow = work.settings + val learned: StateFlow = work.learned + val network: StateFlow = wifi.network + val ask: StateFlow = store.ask + val removals: StateFlow> = store.removals + + private val _message = MutableStateFlow(null) + /** A code after a change that did not take ([workText]); the screen shows and clears it. */ + val message: StateFlow = _message.asStateFlow() + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private var started = false + + fun start() { + if (started) return + started = true + viewModelScope.launch { + live.current() + work.refreshSettings() + } + } + + fun clearMessage() { _message.value = null } + + private fun run(block: suspend () -> String?) { + viewModelScope.launch { + _busy.value = true + try { + _message.value = block() + } finally { + _busy.value = false + } + } + } + + fun chooseFolder(id: Long, label: String) = run { work.chooseWorkFolder(id, label) } + fun clearFolder() = run { work.clearWorkFolder() } + fun setSubfolders(on: Boolean) = run { work.setSubfolders(on) } + fun setNeverInMemories(never: Boolean) = run { work.setInMemories(!never) } + fun setAutoSort(on: Boolean) = work.setAutoSort(on) + fun setAskNewWifi(on: Boolean) = work.setAskNewWifi(on) + fun setRemoveAfter(on: Boolean) = work.setRemoveAfter(on) + fun resetLearning() = work.resetLearning() + + /** The answer of "Wo bist du gerade?" (null: "Später" — asked again another time). */ + fun answer(ssid: String, zone: WifiZone?, stopAsking: Boolean) = wifi.answer(ssid, zone, stopAsking) + + fun forget(ssid: String) = work.setZone(ssid, null) + + /** After a permission: the WLAN callback anew, then a look. */ + fun refreshWifi() { + wifi.refresh() + viewModelScope.launch { wifi.sample(wait = true) } + } + + /** The phone's work photos waiting to go (their media store URIs). */ + fun removalUris(): List = removals.value.map { id -> + if (id < 0) ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, -id) + else ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + } + + fun removalsDone(ids: List) = store.clearRemovals(ids) + + fun hasClasses(): Boolean = work.serverClasses() +} + +/** A work-photo code as a sentence (outside of composition). */ +internal fun workText(context: Context, code: String): String = when { + code == "no_work_folder" -> context.getString(R.string.drive_work_error_no_folder) + code == "move_failed" -> context.getString(R.string.drive_work_error_move) + code == "review_failed" -> context.getString(R.string.drive_work_error_review) + code == "work_folder:camera" -> context.getString(R.string.drive_work_error_camera) + code == "work_folder:encrypted" -> context.getString(R.string.drive_work_error_encrypted) + code == "work_folder:forbidden" -> context.getString(R.string.drive_work_error_forbidden) + code == "work_folder:archived" -> context.getString(R.string.drive_work_error_archived) + code.startsWith("work_folder:") -> context.getString(R.string.drive_work_error_folder) + else -> driveText(context, code) +} + +/** What Android lets the app know of WLAN names. */ +private data class WifiAccess(val fine: Boolean, val background: Boolean, val locationOn: Boolean) + +private fun wifiAccess(context: Context): WifiAccess { + fun granted(p: String) = ContextCompat.checkSelfPermission(context, p) == android.content.pm.PackageManager.PERMISSION_GRANTED + return WifiAccess( + fine = granted(Manifest.permission.ACCESS_FINE_LOCATION), + background = granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION), + locationOn = context.getSystemService(android.location.LocationManager::class.java)?.isLocationEnabled != false, + ) +} + +private fun openDetails(context: Context) { + runCatching { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } +} + +/** + * Foto-Sicherung › the work photos: "Ziele" (private, "Arbeitsfotos nach", subfolders), "WLANs" (Firma / Zuhause, the + * current one, asking about new ones), "KI für Arbeitsfotos" (what it learned, sorting alone, Aufräumen, Neu lernen) and + * "Auf dem Handy". + */ +@Composable +internal fun WorkPhotoSections(onCleanup: () -> Unit, viewModel: DriveWorkViewModel = hiltViewModel()) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val s by viewModel.settings.collectAsStateWithLifecycle() + val learned by viewModel.learned.collectAsStateWithLifecycle() + val network by viewModel.network.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + val removals by viewModel.removals.collectAsStateWithLifecycle() + val me by viewModel.live.me.collectAsStateWithLifecycle() + val classes = viewModel.work.serverClasses(me) + var access by remember { mutableStateOf(wifiAccess(context)) } + var choosing by remember { mutableStateOf(false) } + var editing by remember { mutableStateOf(null) } + var resetAsk by remember { mutableStateOf(false) } + var rationale by rememberSaveable { mutableStateOf(false) } + var bgRationale by rememberSaveable { mutableStateOf(false) } + val workLabel = s.folderLabel ?: stringResource(R.string.drive_work_work_default) + + LaunchedEffect(Unit) { viewModel.start() } + LaunchedEffect(message) { + message?.let { + Toast.makeText(context, workText(context, it), Toast.LENGTH_LONG).show() + viewModel.clearMessage() + } + } + LifecycleResumeEffect(Unit) { + access = wifiAccess(context) + onPauseOrDispose { } + } + val fineLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + access = wifiAccess(context) + if (access.fine) { + viewModel.refreshWifi() + } else { + val activity = context as? Activity + if (result.isNotEmpty() && activity != null && !ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.ACCESS_FINE_LOCATION)) openDetails(context) + } + } + val bgLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + access = wifiAccess(context) + val activity = context as? Activity + if (!granted && activity != null && !ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.ACCESS_BACKGROUND_LOCATION)) openDetails(context) + viewModel.refreshWifi() + } + val trash = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { r -> + if (r.resultCode == Activity.RESULT_OK) viewModel.removalsDone(removals) + } + + if (removals.isNotEmpty()) { + GcBanner(tone = GcTone.Info, icon = GcIcons.Phone) { + Text(pluralStringResource(R.plurals.drive_work_remove_pending, removals.size, removals.size), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = { + val uris = viewModel.removalUris() + val sender = runCatching { MediaStore.createTrashRequest(context.contentResolver, uris, true).intentSender }.getOrNull() + if (sender != null) trash.launch(IntentSenderRequest.Builder(sender).build()) + }, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.drive_work_remove_now)) } + } + } + + // ─── Ziele ─────────────────────────────────────────────────────── + GcSectionLabel(stringResource(R.string.drive_work_section_targets), Modifier.padding(horizontal = 4.dp).semantics { heading() }) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + GcListRow( + title = stringResource(R.string.drive_work_private_photos), + description = stringResource(R.string.drive_work_private_path), + leading = { ZoneIcon(GcIcons.House, x.accentText, x.accentBg) }, + ) + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_work_target), + description = s.folderLabel ?: stringResource(R.string.drive_work_target_none), + onClick = { if (!busy) choosing = true }, + leading = { ZoneIcon(GcIcons.Briefcase, x.blue, x.blueBg) }, + trailing = { SmallButton(stringResource(if (s.active) R.string.drive_work_change else R.string.drive_work_choose)) { if (!busy) choosing = true } }, + ) + if (s.active) { + GcRowDivider() + val now = LocalDate.now() + GcSwitchRow( + label = stringResource(R.string.drive_work_subfolders), + description = stringResource( + R.string.drive_work_subfolders_example, + workLabel.substringAfterLast(" › "), + "%04d".format(Locale.ROOT, now.year), + "%02d".format(Locale.ROOT, now.monthValue), + ), + checked = s.subfolders, + enabled = !busy, + onCheckedChange = viewModel::setSubfolders, + ) + } + GcRowDivider() + Text( + stringResource(R.string.drive_work_team_note), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + // ─── WLANs ─────────────────────────────────────────────────────── + GcSectionLabel(stringResource(R.string.drive_work_section_wifi), Modifier.padding(horizontal = 4.dp).semantics { heading() }) + when { + !access.fine -> WifiWarning(stringResource(R.string.drive_work_wifi_perm), stringResource(R.string.drive_work_wifi_perm_grant)) { rationale = true } + !access.locationOn -> WifiWarning(stringResource(R.string.drive_work_wifi_perm_off), stringResource(R.string.drive_work_wifi_perm_settings)) { + runCatching { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + } + !access.background -> WifiWarning(stringResource(R.string.drive_work_wifi_perm_bg), stringResource(R.string.drive_work_wifi_perm_settings)) { bgRationale = true } + } + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + val rows = s.zones.work.sorted().map { it to WifiZone.WORK } + s.zones.home.sorted().map { it to WifiZone.HOME } + s.zones.neither.sorted().map { it to WifiZone.NEITHER } + if (rows.isEmpty()) { + Text(stringResource(R.string.drive_work_wifi_none_yet), style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) + } + rows.forEach { (ssid, zone) -> + val edit = stringResource(R.string.drive_work_wifi_edit, ssid) + GcListRow( + title = ssid, + description = stringResource( + when (zone) { + WifiZone.WORK -> R.string.drive_work_wifi_to_work + WifiZone.HOME -> R.string.drive_work_wifi_to_private + WifiZone.NEITHER -> R.string.drive_work_wifi_to_review + }, + ), + onClick = { editing = ssid }, + modifier = Modifier.semantics { contentDescription = edit }, + leading = { ZoneIcon(GcIcons.Wifi, zoneColor(zone), zoneBg(zone)) }, + trailing = { ZoneTag(zone) }, + ) + GcRowDivider() + } + val current = if (network.kind == TrustedNetSnapshot.Kind.WIFI) network.ssid else null + val currentZone = s.zones.zoneOf(current) + GcListRow( + title = when { + current != null -> stringResource(R.string.drive_work_wifi_current, current) + network.kind == TrustedNetSnapshot.Kind.WIFI -> stringResource(R.string.drive_work_wifi_current_hidden) + else -> stringResource(R.string.drive_work_wifi_current_none) + }, + description = if (current != null && currentZone == null) stringResource(R.string.drive_work_wifi_current_unknown) else null, + trailing = if (current != null) ({ + if (currentZone == null) SmallButton(stringResource(R.string.drive_work_wifi_assign)) { editing = current } else ZoneTag(currentZone) + }) else null, + onClick = if (current != null) ({ editing = current }) else null, + ) + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_work_wifi_ask), + description = null, + checked = s.askNewWifi, + onCheckedChange = viewModel::setAskNewWifi, + ) + GcRowDivider() + Row(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(GcIcons.Info, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.drive_work_wifi_note), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } + + // ─── KI für Arbeitsfotos ───────────────────────────────────────── + GcSectionLabel(stringResource(R.string.drive_work_section_ai), Modifier.padding(horizontal = 4.dp).semantics { heading() }) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(if (learned.ready) R.string.drive_work_ai_ready else R.string.drive_work_ai_learning), style = MaterialTheme.typography.bodyLarge) + Text( + if (learned.ready) stringResource(R.string.drive_work_ai_progress_ready, learned.total) + else stringResource(R.string.drive_work_ai_progress, learned.progress, DriveWorkLogic.MIN_EXAMPLES), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + val progressLabel = stringResource(R.string.drive_work_ai_progress_label) + LinearProgressIndicator( + progress = { learned.progress.toFloat() / DriveWorkLogic.MIN_EXAMPLES }, + color = x.blue, + trackColor = x.panel2, + modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).semantics { + contentDescription = progressLabel + progressBarRangeInfo = ProgressBarRangeInfo(learned.progress.toFloat(), 0f..DriveWorkLogic.MIN_EXAMPLES.toFloat()) + }, + ) + Text( + stringResource(R.string.drive_work_ai_learned, learned.work, learned.workFromWifi, learned.private), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + } + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_work_ai_auto), + description = stringResource(R.string.drive_work_ai_auto_desc), + checked = s.autoSort, + onCheckedChange = viewModel::setAutoSort, + ) + GcRowDivider() + GcListRow(title = stringResource(R.string.drive_work_ai_local), description = stringResource(R.string.drive_work_ai_local_desc)) + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_work_ai_cleanup), + description = stringResource(R.string.drive_work_ai_cleanup_desc), + onClick = onCleanup, + trailing = { SmallButton(stringResource(R.string.drive_work_ai_open), onClick = onCleanup) }, + ) + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_work_ai_reset), + description = stringResource(R.string.drive_work_ai_reset_desc), + trailing = { SmallButton(stringResource(R.string.drive_work_ai_reset_button), danger = true) { resetAsk = true } }, + ) + } + + // ─── Auf dem Handy ─────────────────────────────────────────────── + GcSectionLabel(stringResource(R.string.drive_work_section_phone), Modifier.padding(horizontal = 4.dp).semantics { heading() }) + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + GcSwitchRow( + label = stringResource(R.string.drive_work_remove), + description = stringResource(R.string.drive_work_remove_desc), + checked = s.removeAfter, + onCheckedChange = viewModel::setRemoveAfter, + ) + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_work_memories), + description = if (classes) null else stringResource(R.string.drive_work_needs_1130), + checked = !s.inMemories, + enabled = classes && !busy, + onCheckedChange = viewModel::setNeverInMemories, + ) + } + + if (choosing) { + WorkFolderChooser( + onPicked = { id, label -> choosing = false; viewModel.chooseFolder(id, label) }, + onNone = if (s.active) ({ choosing = false; viewModel.clearFolder() }) else null, + onDismiss = { choosing = false }, + ) + } + editing?.let { ssid -> + WifiZoneDialog( + ssid = ssid, + workLabel = workLabel, + initial = s.zones.zoneOf(ssid), + asking = false, + onSave = { zone, _ -> editing = null; viewModel.work.setZone(ssid, zone) }, + onLater = { editing = null }, + onForget = if (s.zones.zoneOf(ssid) != null) ({ editing = null; viewModel.forget(ssid) }) else null, + ) + } + if (resetAsk) { + AlertDialog( + onDismissRequest = { resetAsk = false }, + title = { Text(stringResource(R.string.drive_work_ai_reset_title)) }, + text = { Text(stringResource(R.string.drive_work_ai_reset_text)) }, + confirmButton = { + TextButton(onClick = { resetAsk = false; viewModel.resetLearning() }, modifier = Modifier.heightIn(min = 48.dp)) { + Text(stringResource(R.string.drive_work_ai_reset_button), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { resetAsk = false }, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.drive_cancel)) } }, + ) + } + if (rationale) { + AlertDialog( + onDismissRequest = { rationale = false }, + title = { Text(stringResource(R.string.drive_work_perm_title)) }, + text = { Text(stringResource(R.string.drive_work_perm_explain)) }, + confirmButton = { + TextButton(onClick = { + rationale = false + fineLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + }) { Text(stringResource(R.string.drive_work_perm_continue)) } + }, + dismissButton = { TextButton(onClick = { rationale = false }) { Text(stringResource(R.string.drive_cancel)) } }, + ) + } + if (bgRationale) { + AlertDialog( + onDismissRequest = { bgRationale = false }, + title = { Text(stringResource(R.string.drive_work_bg_title)) }, + text = { Text(stringResource(R.string.drive_work_bg_explain)) }, + confirmButton = { + TextButton(onClick = { + bgRationale = false + bgLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + }) { Text(stringResource(R.string.drive_work_perm_continue)) } + }, + dismissButton = { TextButton(onClick = { bgRationale = false }) { Text(stringResource(R.string.drive_cancel)) } }, + ) + } +} + +@Composable +private fun WifiWarning(text: String, button: String, onClick: () -> Unit) { + GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + GcOutlineButton(text = button, onClick = onClick, minHeight = 44.dp) + } + } +} + +/** A small outlined button at the end of a row (≥ 44 dp high). */ +@Composable +private fun SmallButton(text: String, danger: Boolean = false, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val color = if (danger) MaterialTheme.colorScheme.error else x.accentText + TextButton( + onClick = onClick, + modifier = Modifier.heightIn(min = 44.dp).border(1.dp, if (danger) color.copy(alpha = 0.4f) else x.border2, RoundedCornerShape(10.dp)), + shape = RoundedCornerShape(10.dp), + ) { Text(text, color = color, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) } +} + +@Composable +private fun ZoneIcon(icon: ImageVector, tint: Color, bg: Color) { + GcIconSquare(size = 36.dp, background = bg) { Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) } +} + +@Composable +private fun zoneColor(zone: WifiZone): Color { + val x = GateControlTheme.extraColors + return when (zone) { + WifiZone.WORK -> x.blue + WifiZone.HOME -> x.accentText + WifiZone.NEITHER -> x.warn + } +} + +@Composable +private fun zoneBg(zone: WifiZone): Color { + val x = GateControlTheme.extraColors + return when (zone) { + WifiZone.WORK -> x.blueBg + WifiZone.HOME -> x.accentBg + WifiZone.NEITHER -> x.warnBg + } +} + +@Composable +private fun zoneLabel(zone: WifiZone): String = stringResource( + when (zone) { + WifiZone.WORK -> R.string.drive_work_zone_work + WifiZone.HOME -> R.string.drive_work_zone_home + WifiZone.NEITHER -> R.string.drive_work_zone_neither + }, +) + +@Composable +private fun ZoneTag(zone: WifiZone) { + Text( + zoneLabel(zone), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = zoneColor(zone), + modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(zoneBg(zone)).padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +/** + * "Wo bist du gerade?": the WLAN [ssid] is Firma, Zuhause or Weder noch. [asking]: the question about a new WLAN (with + * "Bei neuen WLANs nicht mehr fragen"); else changing one of the list ([onForget] takes it off). + */ +@Composable +internal fun WifiZoneDialog( + ssid: String, + workLabel: String, + initial: WifiZone?, + asking: Boolean, + onSave: (WifiZone, Boolean) -> Unit, + onLater: () -> Unit, + onForget: (() -> Unit)? = null, +) { + var zone by rememberSaveable(ssid) { mutableStateOf(initial ?: WifiZone.NEITHER) } + var stop by rememberSaveable(ssid) { mutableStateOf(false) } + val group = stringResource(R.string.drive_work_ask_group) + AlertDialog( + onDismissRequest = onLater, + title = { Text(stringResource(R.string.drive_work_ask_title), modifier = Modifier.semantics { heading() }) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.drive_work_ask_text, ssid), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted) + Column(Modifier.selectableGroup().semantics { contentDescription = group }, verticalArrangement = Arrangement.spacedBy(8.dp)) { + ZoneChoice(WifiZone.WORK, stringResource(R.string.drive_work_ask_work_desc, workLabel), GcIcons.Briefcase, zone == WifiZone.WORK) { zone = WifiZone.WORK } + ZoneChoice(WifiZone.HOME, stringResource(R.string.drive_work_ask_home_desc), GcIcons.House, zone == WifiZone.HOME) { zone = WifiZone.HOME } + ZoneChoice(WifiZone.NEITHER, stringResource(R.string.drive_work_ask_neither_desc), GcIcons.Alert, zone == WifiZone.NEITHER) { zone = WifiZone.NEITHER } + } + if (asking) { + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).toggleable(value = stop, role = Role.Checkbox, onValueChange = { stop = it }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox(checked = stop, onCheckedChange = null) + Text(stringResource(R.string.drive_work_ask_stop), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(zone, stop) }, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.drive_work_ask_save), fontWeight = FontWeight.SemiBold) } + }, + dismissButton = { + Row { + if (onForget != null) { + TextButton(onClick = onForget, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.drive_work_ask_forget), color = MaterialTheme.colorScheme.error) } + } + TextButton(onClick = onLater, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.drive_work_ask_later)) } + } + }, + ) +} + +@Composable +private fun ZoneChoice(zone: WifiZone, description: String, icon: ImageVector, selected: Boolean, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val color = zoneColor(zone) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clip(RoundedCornerShape(14.dp)) + .background(if (selected) color.copy(alpha = 0.10f) else Color.Transparent) + .border(1.dp, if (selected) color else x.border2, RoundedCornerShape(14.dp)) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton(selected = selected, onClick = null) + ZoneIcon(icon, color, zoneBg(zone)) + Column(Modifier.weight(1f)) { + Text(zoneLabel(zone), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } +} + +/** Over every screen: the question about a new WLAN, while one waits and work photos are set up. */ +@Composable +fun DriveWorkAskHost(viewModel: DriveWorkViewModel = hiltViewModel()) { + val ask by viewModel.ask.collectAsStateWithLifecycle() + val s by viewModel.settings.collectAsStateWithLifecycle() + val ssid = ask ?: return + if (!s.active) return + WifiZoneDialog( + ssid = ssid, + workLabel = s.folderLabel ?: stringResource(R.string.drive_work_work_default), + initial = null, + asking = true, + onSave = { zone, stop -> viewModel.answer(ssid, zone, stop) }, + onLater = { viewModel.answer(ssid, null, false) }, + ) +} + +/** "Arbeitsfotos nach": a folder the person may write to (no vault); its path for the settings. */ +@Composable +private fun WorkFolderChooser(onPicked: (Long, String) -> Unit, onNone: (() -> Unit)?, onDismiss: () -> Unit, viewModel: DriveFolderChooserViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { viewModel.start() } + Box { + FolderPickerDialog( + title = stringResource(R.string.drive_work_choose_title), + areas = state.areas, + folder = state.folder, + confirm = stringResource(R.string.drive_work_choose_here), + canPick = { it.writable && !it.node.encrypted }, + onBrowse = viewModel::browse, + onPick = { + val label = viewModel.pickedLabel() ?: return@FolderPickerDialog + val id = viewModel.picked() ?: return@FolderPickerDialog + onPicked(id, label) + }, + onDismiss = onDismiss, + onNewFolder = viewModel::createFolderIn, + extra = onNone?.let { none -> { TextButton(onClick = none, modifier = Modifier.heightIn(min = 48.dp)) { Text(stringResource(R.string.drive_work_choose_none), color = MaterialTheme.colorScheme.error) } } }, + ) + } +} + +// ─── on the photos ────────────────────────────────────────────────── + +/** The yellow question mark of a photo to be reviewed (top right of its tile). */ +@Composable +internal fun ReviewMark(modifier: Modifier = Modifier, size: Int = 24, description: String? = null) { + val x = GateControlTheme.extraColors + Box( + modifier + .then(if (description != null) Modifier.clearAndSetSemantics { contentDescription = description } else Modifier.clearAndSetSemantics { }) + .size(size.dp) + .clip(CircleShape) + .background(x.warn) + .border(2.dp, Color.Black.copy(alpha = 0.45f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Text("?", color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = (size * 0.6f).sp) + } +} + +/** The small blue bag of a work photo (bottom left of its tile, in "Alle"). */ +@Composable +internal fun WorkBag(modifier: Modifier = Modifier) { + val x = GateControlTheme.extraColors + Box(modifier.size(22.dp).clip(RoundedCornerShape(7.dp)).background(Color.Black.copy(alpha = 0.7f)), contentAlignment = Alignment.Center) { + Icon(GcIcons.Briefcase, contentDescription = null, tint = x.blue, modifier = Modifier.size(13.dp)) + } +} + +/** + * The bar of a selection whose photos are all to be reviewed: the AI's hint, the big "Arbeit" (blue) and "Privat" + * (green) in front, small Senden, Löschen, Mehr. + */ +@Composable +internal fun ReviewAssignBar(hint: DriveWorkLogic.Hint?, onWork: () -> Unit, onPrivate: () -> Unit, small: List) { + val x = GateControlTheme.extraColors + val context = LocalContext.current + val barLabel = stringResource(R.string.drive_work_assign_bar) + Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)) { + GcRowDivider() + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (hint != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 4.dp)) { + Icon(GcIcons.Zap, contentDescription = null, tint = x.blue, modifier = Modifier.size(14.dp)) + Text( + if (hint.work) stringResource(R.string.drive_work_hint_work, hint.sure) else stringResource(R.string.drive_work_hint_private, hint.sure), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + } + } + Row(Modifier.fillMaxWidth().semantics { contentDescription = barLabel }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + BigAssign(stringResource(R.string.drive_work_to_work), GcIcons.Briefcase, x.blue, Color(0xFF0D1015), onWork, Modifier.weight(1.6f)) + BigAssign(stringResource(R.string.drive_work_to_private), GcIcons.House, MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary, onPrivate, Modifier.weight(1.6f)) + small.forEach { a -> + val why = whyText(a.can.why) + com.gatecontrol.android.ui.components.GcIconButton( + icon = a.icon, + contentDescription = a.label, + onClick = { if (a.can.ok) a.onClick() else if (why != null) Toast.makeText(context, why, Toast.LENGTH_SHORT).show() }, + tint = if (!a.can.ok) x.faint else if (a.danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@Composable +private fun BigAssign(text: String, icon: ImageVector, bg: Color, fg: Color, onClick: () -> Unit, modifier: Modifier) { + Button( + onClick = onClick, + modifier = modifier.heightIn(min = 52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = bg, contentColor = fg), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(text, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp), maxLines = 1) + } +} + +/** A hint line shown as a band (Prüfen, Aufräumen) with a button at the end. */ +@Composable +internal fun WorkBand(text: String, button: String?, warn: Boolean, onClick: (() -> Unit)?) { + val x = GateControlTheme.extraColors + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (warn) x.warnBg else x.blueBg) + .padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (warn) ReviewMark(size = 24) else Icon(GcIcons.Briefcase, contentDescription = null, tint = x.blue, modifier = Modifier.size(20.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f).padding(vertical = 6.dp)) + if (button != null && onClick != null) { + TextButton(onClick = onClick, modifier = Modifier.heightIn(min = 44.dp)) { Text(button, color = x.accentText, fontWeight = FontWeight.SemiBold) } + } + } +} + +/** + * A chip of "Alle | Privat | Arbeit | Prüfen (N)": as the other filter chips, with the yellow count of [count]; + * [why] greys it out (a tap says why). The touch area is at least 48 dp. + */ +@Composable +internal fun WorkChip(text: String, selected: Boolean, onClick: () -> Unit, count: Int? = null, why: String? = null, description: String? = null) { + val x = GateControlTheme.extraColors + val context = LocalContext.current + val enabled = why == null + Row( + Modifier + .minimumInteractiveComponentSize() + .height(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (selected) x.accentBg else Color.Transparent) + .border(1.dp, if (selected) Color.Transparent else x.border2, RoundedCornerShape(10.dp)) + .selectable(selected = selected, role = Role.Tab) { if (enabled) onClick() else Toast.makeText(context, why, Toast.LENGTH_SHORT).show() } + .then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(text, style = MaterialTheme.typography.titleSmall, color = if (selected) x.accentText else if (enabled) x.muted else x.faint) + if (count != null && count > 0) { + Text( + if (count > 999) "999+" else count.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF0D1015), + modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(x.warn).padding(horizontal = 6.dp, vertical = 1.dp), + ) + } + } +} diff --git a/app/src/main/res/values-de/strings_drive8.xml b/app/src/main/res/values-de/strings_drive8.xml new file mode 100644 index 00000000..23e8a223 --- /dev/null +++ b/app/src/main/res/values-de/strings_drive8.xml @@ -0,0 +1,170 @@ + + + + Arbeitsfotos + Neues WLAN + Neues WLAN „%1$s“ – wohin sollen Fotos von hier? + Firma + Zuhause + Weder noch + Wo bist du gerade? + Verbunden mit „%1$s“. Fotos, die du in diesem WLAN machst, landen dann automatisch richtig. + Dieses WLAN gehört zu + Fotos → %1$s + Fotos → Privat (Kamera-Uploads) + z. B. beim Kunden, im Café – Fotos bekommen den Prüfen-Marker, die KI schlägt vor + Bei neuen WLANs nicht mehr fragen + Später + Speichern + Entfernen + Arbeit + Alle + Privat + Arbeit + Prüfen + Prüfen, %1$d Fotos + Braucht Drive 1.13 + + %1$d Foto zu prüfen – aufgenommen außerhalb von Firma und Zuhause + %1$d Fotos zu prüfen – aufgenommen außerhalb von Firma und Zuhause + + Zeigen + + %1$d Foto ist wahrscheinlich ein Arbeitsfoto + %1$d Fotos sind wahrscheinlich Arbeitsfotos + + Aufräumen + zu prüfen + Arbeit + Zu prüfen – wohin gehört es? + Arbeit/Privat unter „Mehr“ + KI: wahrscheinlich Arbeit · %1$d %% sicher + KI: eher privat · %1$d %% sicher + Zuordnen + Arbeit + Privat + Ist ein Arbeitsfoto + Ist privat + Zuordnung + Ist ein Arbeitsfoto + Verschiebt nach „%1$s“, die KI lernt mit + Schon Arbeit + Ist privat + Nach Kamera-Uploads, die KI lernt mit + Schon privat + Zum Prüfen markieren + Schon markiert + Erst einen Ordner für Arbeitsfotos wählen (Foto-Sicherung) + Arbeit + Privat + Zu prüfen + + %1$d Foto → %2$s + %1$d Fotos → %2$s + + + %1$d Foto → Privat (Kamera-Uploads) + %1$d Fotos → Privat (Kamera-Uploads) + + + %1$d Foto zum Prüfen markiert + %1$d Fotos zum Prüfen markiert + + Die KI lernt aus dieser Zuordnung + Rückgängig gemacht + Wähle zuerst in der Foto-Sicherung, wohin Arbeitsfotos sollen. + Nicht alle Fotos ließen sich verschieben. + Der Prüfen-Marker ließ sich nicht setzen. + Nicht Kamera-Uploads oder ein Ordner darüber – sonst wäre alles Arbeit. + Kein Tresor. + Dort darfst du nicht schreiben. + Der Bereich ist archiviert. + Dieser Ordner geht nicht. + Ziele + Private Fotos + Meine Dateien › Bilder › Kamera-Uploads › Jahr › Monat + Arbeitsfotos nach + Nicht gewählt – alle Fotos gehen nach Kamera-Uploads + Ändern + Wählen + Ordner für Arbeitsfotos + Arbeitsfotos hierhin + Kein Ordner für Arbeitsfotos + Unterordner nach Jahr und Monat + %1$s › %2$s › %3$s + Später geht hier auch ein Firmen- oder Team-Ordner, sobald es ihn gibt. + WLANs + Fotos → Arbeit + Fotos → Privat + Fotos → Prüfen + Noch kein WLAN zugeordnet. + Aktuell: %1$s + Nicht zugeordnet – Fotos bekommen den Prüfen-Marker + Gerade in keinem WLAN + In einem WLAN – Android nennt seinen Namen nicht + Zuordnen + %1$s ändern + Bei neuen WLANs fragen + Android zeigt WLAN-Namen nur mit Standortberechtigung und eingeschaltetem Standort. Der Standort selbst wird nicht gespeichert. + Ohne Standortberechtigung sieht die App keine WLAN-Namen – Fotos bekommen dann den Prüfen-Marker, nur die KI schlägt vor. + Erlauben + Damit die App WLANs auch im Hintergrund erkennt, den Standort „Immer zulassen“. + Der Standort ist aus – Android nennt keine WLAN-Namen. + Einstellungen + KI für Arbeitsfotos + Lernt noch + Hat gelernt + %1$d von %2$d Beispielen – bis dahin schlägt sie nur vor + %1$d Beispiele – sie sortiert ein, wenn sie sicher ist + Lernfortschritt + Gelernt aus %1$d Arbeitsfotos (davon %2$d aus dem Firmen-WLAN) und %3$d privaten Fotos + Selbst einsortieren, wenn sicher + Erst nach der Lernphase; unsichere Fotos bekommen weiter den Prüfen-Marker + Läuft nur auf diesem Handy + Kein Foto geht für die KI an einen Dienst + Aufräumen + Vorschläge für schon gesicherte Fotos + Öffnen + Neu lernen + Vergisst alle Beispiele + Zurücksetzen + Alles Gelernte vergessen? + Die KI beginnt wieder mit der Lernphase. Fotos und Ordner bleiben, wie sie sind. + Auf dem Handy + Arbeitsfotos nach der Sicherung vom Handy entfernen + Sie bleiben in Drive; die Galerie zeigt dann nur private Fotos + + %1$d Arbeitsfoto ist gesichert und kann vom Handy + %1$d Arbeitsfotos sind gesichert und können vom Handy + + Entfernen + Arbeitsfotos nie in Erinnerungen und Fotorahmen + Standort-Berechtigung für WLAN-Namen + GateControl erhebt Standortdaten, um den Namen des verbundenen WLANs zu erkennen und neue Fotos als Arbeits- oder Privatfotos einzuordnen – auch wenn die App geschlossen ist oder nicht verwendet wird. Der Standort verlässt dein Gerät nicht und wird nicht gespeichert; nur der WLAN-Name und die Uhrzeit bleiben 30 Tage auf diesem Handy. Damit das im Hintergrund klappt, wähle danach in den Einstellungen „Immer zulassen“. + Standort im Hintergrund + GateControl erhebt Standortdaten, um den Namen des verbundenen WLANs auch dann zu erkennen, wenn die App geschlossen ist oder nicht verwendet wird – nur so landen Fotos aus der Firma oder von zu Hause von selbst richtig. Der Standort verlässt dein Gerät nicht und wird nicht gespeichert. Wähle auf der nächsten Seite „Immer zulassen“. + Weiter + Wann sichern + Aufräumen + + %1$d Foto ist wahrscheinlich ein Arbeitsfoto + %1$d Fotos sind wahrscheinlich Arbeitsfotos + + Gefunden in „Bilder › Kamera-Uploads“. Abgewählte Fotos bleiben privat – die KI lernt aus beidem. + Ziel: %1$s + + %1$d Foto nach „%2$s“ verschieben + %1$d Fotos nach „%2$s“ verschieben + + Nichts verschieben + Schaue Fotos an … %1$d von %2$d + Die KI lernt noch (%1$d von %2$d Beispielen). Vorschläge gibt es, sobald sie Arbeits- und private Fotos kennt. + Keine Vorschläge – alles sieht privat aus. + %1$d %% + %1$s, %2$d %% Arbeit + + %1$d Foto nach Arbeit verschoben + %1$d Fotos nach Arbeit verschoben + + Nichts verschoben – die KI lernt daraus + diff --git a/app/src/main/res/values/strings_drive8.xml b/app/src/main/res/values/strings_drive8.xml new file mode 100644 index 00000000..5bba318d --- /dev/null +++ b/app/src/main/res/values/strings_drive8.xml @@ -0,0 +1,170 @@ + + + + Work photos + New WLAN + New WLAN “%1$s” – where should photos from here go? + Company + Home + Neither + Where are you right now? + Connected to “%1$s”. Photos you take in this WLAN will then end up in the right place automatically. + This WLAN belongs to + Photos → %1$s + Photos → Private (camera uploads) + e.g. at a customer’s, in a café – photos get the review mark, the AI suggests + Don’t ask about new WLANs any more + Later + Save + Remove + Work + All + Private + Work + Review + Review, %1$d photos + Needs Drive 1.13 + + %1$d photo to review – taken outside the company and home + %1$d photos to review – taken outside the company and home + + Show + + %1$d photo is probably a work photo + %1$d photos are probably work photos + + Clean up + to review + work + To review – where does it belong? + Work/private under “More” + AI: probably work · %1$d %% sure + AI: rather private · %1$d %% sure + Assign + Work + Private + Is a work photo + Is private + Assignment + Is a work photo + Moves to “%1$s”, the AI learns along + Already work + Is private + Into the camera uploads, the AI learns along + Already private + Mark for review + Already marked + Choose a folder for work photos first (photo backup) + Work + Private + To review + + %1$d photo → %2$s + %1$d photos → %2$s + + + %1$d photo → Private (camera uploads) + %1$d photos → Private (camera uploads) + + + %1$d photo marked for review + %1$d photos marked for review + + The AI learns from this assignment + Undone + Choose in the photo backup first where work photos go. + Not all photos could be moved. + The review mark could not be set. + Not the camera uploads or a folder above them – otherwise everything would be work. + Not a vault. + You may not write there. + The area is archived. + This folder cannot be used. + Targets + Private photos + My files › Pictures › Camera uploads › Year › Month + Work photos to + Not chosen – all photos go to the camera uploads + Change + Choose + Folder for work photos + Work photos here + No folder for work photos + Subfolders by year and month + %1$s › %2$s › %3$s + Later a company or team folder will work here too, once there is one. + WLANs + Photos → Work + Photos → Private + Photos → Review + No WLAN assigned yet. + Current: %1$s + Not assigned – photos get the review mark + Not in a WLAN right now + In a WLAN – Android does not tell its name + Assign + Change %1$s + Ask about new WLANs + Android shows WLAN names only with the location permission and location switched on. The location itself is not stored. + Without the location permission the app sees no WLAN names – photos then get the review mark, only the AI suggests. + Allow + To recognise WLANs in the background too, allow the location “all the time”. + Location is switched off – Android does not tell WLAN names. + Settings + AI for work photos + Still learning + Has learned + %1$d of %2$d examples – until then it only suggests + %1$d examples – it sorts in when it is sure + Learning progress + Learned from %1$d work photos (%2$d of them from the company WLAN) and %3$d private photos + Sort in by itself when sure + Only after the learning phase; unsure photos keep getting the review mark + Runs only on this phone + No photo goes to a service for the AI + Clean up + Suggestions for photos already backed up + Open + Learn anew + Forgets all examples + Reset + Forget everything learned? + The AI starts again with its learning phase. Photos and folders stay as they are. + On the phone + Remove work photos from the phone after the backup + They stay in Drive; the gallery then shows only private photos + + %1$d work photo is backed up and can go from the phone + %1$d work photos are backed up and can go from the phone + + Remove + Never show work photos in memories and the photo frame + Location permission for WLAN names + GateControl collects location data to recognise the name of the connected WLAN and sort new photos into work and private photos – also when the app is closed or not in use. The location never leaves your device and is not stored; only the WLAN name and the time stay on this phone for 30 days. For it to work in the background, then choose “Allow all the time” in the settings. + Location in the background + GateControl collects location data to recognise the name of the connected WLAN also when the app is closed or not in use – only then do photos taken in the company or at home end up in the right place by themselves. The location never leaves your device and is not stored. Choose “Allow all the time” on the next page. + Continue + When to back up + Clean up + + %1$d photo is probably a work photo + %1$d photos are probably work photos + + Found in “Pictures › Camera uploads”. Photos left unticked stay private – the AI learns from both. + Target: %1$s + + Move %1$d photo to “%2$s” + Move %1$d photos to “%2$s” + + Move nothing + Looking at photos … %1$d of %2$d + The AI is still learning (%1$d of %2$d examples). Suggestions come once it knows work and private photos. + No suggestions – everything looks private. + %1$d %% + %1$s, %2$d %% work + + %1$d photo moved to work + %1$d photos moved to work + + Nothing moved – the AI learns from it + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveJsonWorkTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonWorkTest.kt new file mode 100644 index 00000000..bd3230fd --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonWorkTest.kt @@ -0,0 +1,71 @@ +package com.gatecontrol.android.drive + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Drive 1.13: work photos apart (`photoClasses`). */ +class DriveJsonWorkTest { + + private fun json(s: String): JsonObject = JsonParser.parseString(s).asJsonObject + + @Test + fun `photo items carry the review mark and the work flag`() { + val p = DriveJson.photos( + json( + """{"items":[ + {"id":5,"name":"a.jpg","areaId":"mine:7","parentId":3,"review":{"hint":72},"work":false}, + {"id":6,"name":"b.jpg","areaId":"mine:7","parentId":3,"review":{"hint":null},"work":false}, + {"id":7,"name":"c.jpg","areaId":"mine:7","parentId":4,"review":null,"work":true}, + {"id":8,"name":"d.jpg","areaId":"mine:7","parentId":4,"review":{"hint":180}}]}""", + ), + )!! + assertEquals(DReview(72), p.items[0].review) + assertEquals(DReview(null), p.items[1].review) + assertNull(p.items[2].review) + assertTrue(p.items[2].work) + assertFalse(p.items[0].work) + assertEquals(DReview(100), p.items[3].review) + assertFalse(p.items[3].work) + } + + @Test + fun `the settings, with the folder's path`() { + val s = DriveJson.photoSettings(json("""{"ok":true,"workFolder":123,"workSubfolders":false,"workInMemories":true,"folder":{"id":123,"path":"Arbeit/Fotos","areaId":"mine:7"}}"""))!! + assertEquals(DPhotoSettings(123, false, true, "Arbeit/Fotos", "mine:7"), s) + val unset = DriveJson.photoSettings(json("""{"ok":true,"workFolder":null,"workSubfolders":true,"workInMemories":false,"folder":null}"""))!! + assertEquals(DPhotoSettings(), unset) + assertNull(DriveJson.photoSettings(json("""{"ok":true}"""))) + } + + @Test + fun `settings, review and class bodies`() { + assertEquals("""{"workFolder":5}""", DriveJson.photoSettingsBody(workFolder = 5).toString()) + assertEquals("""{"workFolder":null,"workSubfolders":true}""", DriveJson.photoSettingsBody(clearFolder = true, workSubfolders = true).toString()) + assertEquals("""{"workInMemories":false}""", DriveJson.photoSettingsBody(workInMemories = false).toString()) + assertEquals("""{"ids":[1,2],"review":true,"hint":100}""", DriveJson.reviewBody(listOf(1, 2, 2), true, 140).toString()) + assertEquals("""{"ids":[1],"review":false}""", DriveJson.reviewBody(listOf(1), false, 40).toString()) + assertEquals(mapOf("class" to "review", "camera" to "1"), DriveJson.photosQuery(camera = true, cls = "review")) + assertNull(DriveJson.photosQuery(cls = "everything")) + } + + @Test + fun `review answer and the counts`() { + val r = DriveJson.reviewAnswer(json("""{"ok":true,"changed":2,"items":[{"id":5,"review":{"hint":72}},{"id":6,"review":null},{"x":1}]}"""))!! + assertEquals(mapOf(5L to DReview(72), 6L to null), r) + assertNull(DriveJson.reviewAnswer(json("""{"ok":true}"""))) + assertEquals(DPhotoClasses(4, 10, 2), DriveJson.photoClasses(json("""{"ok":true,"work":4,"private":10,"review":2}"""))) + assertEquals(16, DPhotoClasses(4, 10, 2).all) + assertNull(DriveJson.photoClasses(json("""{"ok":true,"work":4}"""))) + } + + @Test + fun `me knows photoClasses`() { + val me = DriveJson.me(json("""{"me":{"id":7},"features":["photoClasses"]}"""))!! + assertTrue(me.has(DMe.FEATURE_PHOTO_CLASSES)) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/backup/DriveBackupLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/backup/DriveBackupLogicTest.kt index f059d5c1..6b0d4171 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/backup/DriveBackupLogicTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/backup/DriveBackupLogicTest.kt @@ -121,4 +121,21 @@ class DriveBackupLogicTest { assertFalse(c.videos) assertTrue(c.cameraOnly) } + + @Test + fun `when a photo was taken for the WLAN timeline`() { + assertEquals(1_793_261_520_000L, DriveBackupLogic.takenMs("2026-10-29T09:12:00+01:00", 5L, 6L)) + assertEquals(5L, DriveBackupLogic.takenMs("not a date", 5L, 6L)) + assertEquals(6L, DriveBackupLogic.takenMs(null, 0L, 6L)) + assertNull(DriveBackupLogic.takenMs(null, null, null)) + } + + @Test + fun `a work photo whose folder is gone goes the private way, to be reviewed`() { + val p = com.gatecontrol.android.drive.work.DriveWorkLogic.place(com.gatecontrol.android.drive.work.WifiZone.WORK, null, ready = false, autoSort = true) + val r = DriveBackupLogic.reviewInstead(p) + assertEquals(com.gatecontrol.android.drive.work.DriveWorkLogic.Dest.PRIVATE, r.dest) + assertTrue(r.review) + assertNull(r.learn) + } } diff --git a/app/src/test/java/com/gatecontrol/android/drive/work/DriveWifiTimelineTest.kt b/app/src/test/java/com/gatecontrol/android/drive/work/DriveWifiTimelineTest.kt new file mode 100644 index 00000000..937c601e --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/work/DriveWifiTimelineTest.kt @@ -0,0 +1,83 @@ +package com.gatecontrol.android.drive.work + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DriveWifiTimelineTest { + + private val min = 60_000L + private val t0 = 1_800_000_000_000L + + private fun at(m: Int) = t0 + m * min + + @Test + fun `a look of the same session and WLAN adds nothing, another session does`() { + var list = DriveWifiTimeline.add(emptyList(), WifiSeen(at(0), "Firma", "a")) + val same = DriveWifiTimeline.add(list, WifiSeen(at(5), "Firma", "a")) + assertSame(list, same) + list = DriveWifiTimeline.add(list, WifiSeen(at(10), "Firma", "b")) + assertEquals(2, list.size) + list = DriveWifiTimeline.add(list, WifiSeen(at(12), null, "b")) + assertEquals(listOf("Firma", "Firma", null), list.map { it.ssid }) + // a late look goes into its place + list = DriveWifiTimeline.add(list, WifiSeen(at(11), "Firma", "c"), now = at(12)) + assertEquals(listOf(at(0), at(10), at(11), at(12)), list.map { it.at }) + } + + @Test + fun `within one session the WLAN holds until the next look`() { + val list = listOf(WifiSeen(at(0), "Firma", "a"), WifiSeen(at(300), null, "a")) + assertEquals("Firma", DriveWifiTimeline.wifiAt(list, at(200), current = "x")) + assertNull(DriveWifiTimeline.wifiAt(list, at(301), current = "x")) + } + + @Test + fun `across a gap only the same WLAN on both sides holds`() { + val same = listOf(WifiSeen(at(0), "Firma", "a"), WifiSeen(at(300), "Firma", "b")) + assertEquals("Firma", DriveWifiTimeline.wifiAt(same, at(150), current = "c")) + val other = listOf(WifiSeen(at(0), "Firma", "a"), WifiSeen(at(300), null, "b")) + // the app was not running: when it left the WLAN is unknown + assertNull(DriveWifiTimeline.wifiAt(other, at(150), current = "c")) + // a look close to the photo counts + assertEquals("Firma", DriveWifiTimeline.wifiAt(other, at(1), current = "c")) + assertNull(DriveWifiTimeline.wifiAt(other, at(299), current = "c")) + val home = listOf(WifiSeen(at(0), "Firma", "a"), WifiSeen(at(300), "Zuhause", "b")) + assertEquals("Zuhause", DriveWifiTimeline.wifiAt(home, at(299), current = "c")) + } + + @Test + fun `after the last look only this running session, or a moment, counts`() { + val list = listOf(WifiSeen(at(0), "Firma", "a")) + assertEquals("Firma", DriveWifiTimeline.wifiAt(list, at(500), current = "a")) + assertNull(DriveWifiTimeline.wifiAt(list, at(500), current = "b")) + assertEquals("Firma", DriveWifiTimeline.wifiAt(list, at(1), current = "b")) + // before the first look + assertEquals("Firma", DriveWifiTimeline.wifiAt(list, at(-1), current = "a")) + assertNull(DriveWifiTimeline.wifiAt(list, at(-10), current = "a")) + assertNull(DriveWifiTimeline.wifiAt(emptyList(), at(0), current = "a")) + } + + @Test + fun `old looks go, the last before the cut stays`() { + val day = 24 * 60 + val list = listOf(WifiSeen(at(0), "A", "a"), WifiSeen(at(day), "B", "a"), WifiSeen(at(40 * day), "C", "a")) + val pruned = DriveWifiTimeline.prune(list, now = at(40 * day)) + assertEquals(listOf("B", "C"), pruned.map { it.ssid }) + val all = DriveWifiTimeline.prune(list.take(2), now = at(40 * day)) + assertEquals(listOf("B"), all.map { it.ssid }) + val many = (0 until DriveWifiTimeline.MAX + 10).map { WifiSeen(at(it), if (it % 2 == 0) "A" else "B", "s") } + assertEquals(DriveWifiTimeline.MAX, DriveWifiTimeline.prune(many, now = at(DriveWifiTimeline.MAX + 10)).size) + } + + @Test + fun `kept as JSON, anything broken reads as empty`() { + val list = listOf(WifiSeen(at(0), "Firma \"Gast\"", "a"), WifiSeen(at(1), null, "b")) + assertEquals(list, DriveWifiTimeline.decode(DriveWifiTimeline.encode(list))) + assertTrue(DriveWifiTimeline.decode(null).isEmpty()) + assertTrue(DriveWifiTimeline.decode("{").isEmpty()) + assertEquals(1, DriveWifiTimeline.decode("""{"v":1,"s":[[1,"A","s"],["x","B","s"],[2,"C"]]}""").size) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkClassifierTest.kt b/app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkClassifierTest.kt new file mode 100644 index 00000000..5c5f5f6c --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkClassifierTest.kt @@ -0,0 +1,91 @@ +package com.gatecontrol.android.drive.work + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneId + +class DriveWorkClassifierTest { + + private val zone = ZoneId.of("Europe/Berlin") + + private fun ex(key: String, work: Boolean, vararg f: Pair, source: WorkExample.Source = WorkExample.Source.ASSIGN, at: Long = 0) = + WorkExample(key, work, source, f.toMap(), at) + + // label indices as the image labeling might find them: 10 whiteboard, 11 desk, 12 computer; 20 dog, 21 beach, 22 food + private val examples = buildList { + repeat(10) { i -> add(ex("w$i", true, 10 to 0.9f, 11 to 0.7f, DriveWorkFeatures.WORK_HOURS to 1f)) } + repeat(5) { i -> add(ex("w2$i", true, 12 to 0.8f)) } + repeat(10) { i -> add(ex("p$i", false, 20 to 0.9f, 21 to 0.6f, DriveWorkFeatures.WEEKEND to 1f)) } + repeat(5) { i -> add(ex("p2$i", false, 22 to 0.8f)) } + } + + @Test + fun `learns work and private apart`() { + val m = DriveWorkModel.train(examples)!! + assertTrue(m.predict(mapOf(10 to 0.95f, DriveWorkFeatures.WORK_HOURS to 1f)) > 0.85) + assertTrue(m.predict(mapOf(20 to 0.9f, DriveWorkFeatures.WEEKEND to 1f)) < 0.15) + val unknown = m.predict(emptyMap()) + assertTrue(unknown in 0.3..0.7, "nothing known: unsure ($unknown)") + // both kinds weigh the same whatever their numbers + val skew = DriveWorkModel.train(examples + List(40) { ex("x$it", false, 22 to 0.8f) })!! + assertTrue(skew.predict(mapOf(10 to 0.9f, 11 to 0.7f)) > 0.7) + } + + @Test + fun `no model without both kinds`() { + assertNull(DriveWorkModel.train(emptyList())) + assertNull(DriveWorkModel.train(listOf(ex("a", true, 1 to 1f)))) + } + + @Test + fun `features are the sure labels and when it was taken`() { + val weekday = LocalDateTime.of(2026, 10, 29, 9, 12).atZone(zone).toInstant().toEpochMilli() + val evening = LocalDateTime.of(2026, 10, 29, 21, 0).atZone(zone).toInstant().toEpochMilli() + val saturday = LocalDateTime.of(2026, 10, 31, 11, 0).atZone(zone).toInstant().toEpochMilli() + val f = DriveWorkFeatures.of(mapOf(1 to 0.9f, 2 to 0.3f), weekday, zone) + assertEquals(mapOf(1 to 0.9f, DriveWorkFeatures.WORK_HOURS to 1f), f) + assertEquals(mapOf(1 to 0.9f), DriveWorkFeatures.of(mapOf(1 to 0.9f), evening, zone)) + assertEquals(mapOf(DriveWorkFeatures.WEEKEND to 1f), DriveWorkFeatures.of(emptyMap(), saturday, zone)) + assertEquals(emptyMap(), DriveWorkFeatures.of(mapOf(DriveWorkFeatures.WEEKEND to 1f), null, zone)) + } + + @Test + fun `one example per photo, the newest answer wins, the oldest go`() { + var list = DriveWorkFeatures.add(emptyList(), ex("n:1", true, at = 1)) + list = DriveWorkFeatures.add(list, ex("n:1", false, at = 2)) + assertEquals(listOf(false), list.map { it.work }) + val full = (0 until DriveWorkFeatures.MAX_EXAMPLES).map { ex("k$it", it % 2 == 0, at = it.toLong() + 10) } + val more = DriveWorkFeatures.add(full, ex("new", true, at = 99_999)) + assertEquals(DriveWorkFeatures.MAX_EXAMPLES, more.size) + assertTrue(more.none { it.key == "k0" }) + assertTrue(more.any { it.key == "new" }) + } + + @Test + fun `what was learned`() { + val l = DriveWorkFeatures.learned( + listOf( + ex("a", true, source = WorkExample.Source.WIFI), + ex("b", true), + ex("c", false, source = WorkExample.Source.WIFI), + ex("d", false, source = WorkExample.Source.CLEANUP), + ), + ) + assertEquals(DriveWorkLogic.Learned(total = 4, work = 2, private = 2, workFromWifi = 1, privateFromWifi = 1), l) + } + + @Test + fun `kept as JSON on the phone`() { + val list = listOf(ex("n:5", true, 3 to 0.75f, source = WorkExample.Source.MOVE, at = 42), ex("n:6", false)) + assertEquals(list, DriveWorkFeatures.decode(DriveWorkFeatures.encode(list))) + assertTrue(DriveWorkFeatures.decode("nonsense").isEmpty()) + assertTrue(DriveWorkFeatures.decode(null).isEmpty()) + // an unknown source or a broken entry is left out + val mixed = DriveWorkFeatures.decode("""{"v":1,"e":[{"k":"a","w":true,"s":"x","f":{}},{"k":"b","w":false,"s":"wifi","f":{"1":0.5,"y":2}},{"w":true}]}""") + assertEquals(listOf("b"), mixed.map { it.key }) + assertEquals(mapOf(1 to 0.5f), mixed.single().features) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkLogicTest.kt new file mode 100644 index 00000000..b099c81b --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/work/DriveWorkLogicTest.kt @@ -0,0 +1,188 @@ +package com.gatecontrol.android.drive.work + +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DReview +import com.gatecontrol.android.drive.work.DriveWorkLogic.Dest +import com.gatecontrol.android.drive.work.DriveWorkLogic.Why +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.ZoneId + +class DriveWorkLogicTest { + + private val utc = ZoneId.of("UTC") + + private fun photo(id: Long, takenAt: String? = "2026-10-29T09:12:00Z", work: Boolean = false, review: DReview? = null, parent: Long = 3) = + DNode(id, "mine:7", parent, "IMG_$id.jpg", false, 10, "image/jpeg", null, 1, null, null, null, takenAt = takenAt, work = work, review = review) + + // ─── where a new photo goes ───────────────────────────────────── + + @Test + fun `company WLAN means work, home WLAN private — both teach the AI`() { + val w = DriveWorkLogic.place(WifiZone.WORK, score = 0.1, ready = true, autoSort = true) + assertEquals(Dest.WORK, w.dest) + assertFalse(w.review) + assertEquals(true, w.learn) + assertEquals(Why.WIFI_WORK, w.why) + val h = DriveWorkLogic.place(WifiZone.HOME, score = 0.99, ready = true, autoSort = true) + assertEquals(Dest.PRIVATE, h.dest) + assertFalse(h.review) + assertEquals(false, h.learn) + } + + @Test + fun `elsewhere in the learning phase always private with the review mark and the hint`() { + val p = DriveWorkLogic.place(null, score = 0.97, ready = false, autoSort = true) + assertEquals(Dest.PRIVATE, p.dest) + assertTrue(p.review) + assertEquals(97, p.hint) + assertNull(p.learn) + assertEquals(Why.REVIEW, p.why) + // "Weder noch" is the same as an unknown WLAN + assertTrue(DriveWorkLogic.place(WifiZone.NEITHER, score = 0.97, ready = false, autoSort = true).review) + } + + @Test + fun `after learning only a sure AI decides alone, and only when allowed`() { + val work = DriveWorkLogic.place(null, score = 0.85, ready = true, autoSort = true) + assertEquals(Dest.WORK, work.dest) + assertFalse(work.review) + assertEquals(Why.AI_WORK, work.why) + assertNull(work.learn, "its own guesses are no examples") + val priv = DriveWorkLogic.place(WifiZone.NEITHER, score = 0.15, ready = true, autoSort = true) + assertEquals(Dest.PRIVATE, priv.dest) + assertFalse(priv.review) + assertEquals(Why.AI_PRIVATE, priv.why) + // unsure in between + val unsure = DriveWorkLogic.place(null, score = 0.84, ready = true, autoSort = true) + assertTrue(unsure.review) + assertEquals(84, unsure.hint) + assertTrue(DriveWorkLogic.place(null, score = 0.16, ready = true, autoSort = true).review) + // "Selbst einsortieren" off + val off = DriveWorkLogic.place(null, score = 0.99, ready = true, autoSort = false) + assertEquals(Dest.PRIVATE, off.dest) + assertTrue(off.review) + // no answer of the AI (a video, no Play services) + val none = DriveWorkLogic.place(null, score = null, ready = true, autoSort = true) + assertTrue(none.review) + assertNull(none.hint) + } + + @Test + fun `learning phase ends with 30 examples of both kinds`() { + assertFalse(DriveWorkLogic.Learned(total = 29, work = 10, private = 19).ready) + assertTrue(DriveWorkLogic.Learned(total = 30, work = 10, private = 20).ready) + assertFalse(DriveWorkLogic.Learned(total = 40, work = 2, private = 38).ready) + assertEquals(23, DriveWorkLogic.Learned(total = 23).progress) + assertEquals(30, DriveWorkLogic.Learned(total = 80).progress) + assertTrue(DriveWorkLogic.Learned(total = 6, work = 3, private = 3).usable) + assertFalse(DriveWorkLogic.Learned(total = 6, work = 1, private = 5).usable) + } + + @Test + fun `wlan zones`() { + val z = WifiZones().with("Firma-Corp", WifiZone.WORK).with("FRITZ!Box 7590", WifiZone.HOME).with("Café", WifiZone.NEITHER) + assertEquals(WifiZone.WORK, z.zoneOf("Firma-Corp")) + assertEquals(WifiZone.HOME, z.zoneOf("FRITZ!Box 7590")) + assertEquals(WifiZone.NEITHER, z.zoneOf("Café")) + assertNull(z.zoneOf(null)) + assertTrue(z.unknown("Kunde-Mueller-Gast")) + assertFalse(z.unknown("Café")) + assertFalse(z.unknown(null)) + // moved to another zone: out of the old one + val moved = z.with("Firma-Corp", WifiZone.HOME) + assertEquals(WifiZone.HOME, moved.zoneOf("Firma-Corp")) + assertFalse("Firma-Corp" in moved.work) + assertTrue(moved.with("Firma-Corp", null).unknown("Firma-Corp")) + } + + // ─── the selection's bar ──────────────────────────────────────── + + @Test + fun `the big buttons only when every ticked photo is to be reviewed`() { + val r1 = photo(1, review = DReview(72)) + val r2 = photo(2, review = DReview(null)) + val plain = photo(3) + assertTrue(DriveWorkLogic.reviewBar(listOf(r1, r2), emptyMap())) + assertFalse(DriveWorkLogic.reviewBar(listOf(r1, plain), emptyMap())) + assertFalse(DriveWorkLogic.reviewBar(emptyList(), emptyMap())) + // without Drive 1.13: this phone's marks + assertTrue(DriveWorkLogic.reviewBar(listOf(r1, plain), mapOf(3L to 40))) + assertEquals(40, DriveWorkLogic.hintOf(plain, mapOf(3L to 40))) + assertEquals(72, DriveWorkLogic.hintOf(r1, mapOf(1L to 10))) + } + + @Test + fun `the AI's line is the mean of the hints`() { + assertEquals(DriveWorkLogic.Hint(true, 72), DriveWorkLogic.hint(listOf(72))) + assertEquals(DriveWorkLogic.Hint(true, 60), DriveWorkLogic.hint(listOf(80, 40, null))) + assertEquals(DriveWorkLogic.Hint(false, 62), DriveWorkLogic.hint(listOf(38))) + assertEquals(DriveWorkLogic.Hint(true, 50), DriveWorkLogic.hint(listOf(50))) + assertNull(DriveWorkLogic.hint(listOf(null, null))) + assertNull(DriveWorkLogic.hint(emptyList())) + } + + @Test + fun `more offers what changes something`() { + val work = photo(1, work = true) + val priv = photo(2) + val marked = photo(3, review = DReview(50)) + assertEquals(DriveWorkLogic.Offer(work = false, private = true, review = true), DriveWorkLogic.offer(listOf(work), emptyMap())) + assertEquals(DriveWorkLogic.Offer(work = true, private = false, review = true), DriveWorkLogic.offer(listOf(priv), emptyMap())) + assertEquals(DriveWorkLogic.Offer(work = true, private = true, review = false), DriveWorkLogic.offer(listOf(marked), emptyMap())) + assertEquals(DriveWorkLogic.Offer(work = true, private = true, review = true), DriveWorkLogic.offer(listOf(work, priv), emptyMap())) + } + + // ─── moving ───────────────────────────────────────────────────── + + @Test + fun `work moves the private ones into the work folder by month`() { + val a = photo(1, takenAt = "2026-10-29T09:00:00Z") + val b = photo(2, takenAt = "2026-09-01T09:00:00Z") + val c = photo(3, takenAt = "2026-10-01T09:00:00Z", work = true) + val d = photo(4, takenAt = "2026-10-03T09:00:00Z", review = DReview(80)) + val m = DriveWorkLogic.moves(listOf(a, b, c, d), Dest.WORK, subfolders = true, zone = utc) + assertEquals(listOf(listOf("2026", "10"), listOf("2026", "09")), m.keys.toList()) + assertEquals(listOf(1L, 4L), m[listOf("2026", "10")]!!.map { it.id }) + assertEquals(listOf(2L), m[listOf("2026", "09")]!!.map { it.id }) + val flat = DriveWorkLogic.moves(listOf(a, b), Dest.WORK, subfolders = false, zone = utc) + assertEquals(mapOf(emptyList() to listOf(a, b)), flat) + } + + @Test + fun `private moves only what is under the work folder, into Kamera-Uploads by month`() { + val w = photo(1, takenAt = "2026-10-29T09:00:00Z", work = true) + val p = photo(2) + val markedInWork = photo(3, takenAt = "2025-01-31T23:30:00Z", review = DReview(70)) + val m = DriveWorkLogic.moves(listOf(w, p, markedInWork), Dest.PRIVATE, subfolders = false, zone = utc) { it.work || it.id == 3L } + assertEquals(listOf(listOf("2026", "10"), listOf("2025", "01")), m.keys.toList()) + assertEquals(listOf(1L), m[listOf("2026", "10")]!!.map { it.id }) + assertTrue(DriveWorkLogic.moves(listOf(p), Dest.PRIVATE, subfolders = true, zone = utc).isEmpty()) + // without a date: the folder itself + assertEquals(emptyList(), DriveWorkLogic.monthPath(photo(9, takenAt = null), utc)) + } + + // ─── Aufräumen ────────────────────────────────────────────────── + + @Test + fun `suggestions from 50 percent, ticked from 70, by month and surest first`() { + val list = DriveWorkLogic.suggestions( + listOf( + photo(1, takenAt = "2026-10-02T09:00:00Z") to 0.96, + photo(2, takenAt = "2026-10-03T09:00:00Z") to 0.58, + photo(3, takenAt = "2026-09-03T09:00:00Z") to 0.70, + photo(4, takenAt = "2026-10-04T09:00:00Z") to 0.49, + photo(5, takenAt = "2026-10-04T09:00:00Z", work = true) to 0.99, + photo(6, takenAt = "2026-10-04T09:00:00Z", review = DReview(90)) to 0.99, + ), + utc, + ) + assertEquals(listOf(1L, 2L, 3L), list.map { it.node.id }) + assertEquals(listOf(96, 58, 70), list.map { it.percent }) + assertEquals(listOf(true, false, true), list.map { it.preselected }) + assertEquals(listOf("2026-10", "2026-09"), DriveWorkLogic.byMonth(list).map { it.first }) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65af78bd..6cb13c79 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ camerax = "1.6.0" mlkit-barcode = "17.3.0" mlkit-document-scanner = "16.0.0" mlkit-text-recognition = "19.0.1" +mlkit-image-labeling = "16.0.8" timber = "5.0.1" coroutines = "1.11.0" junit5 = "6.1.3" @@ -121,6 +122,7 @@ camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkit-barcode" } mlkit-document-scanner = { group = "com.google.android.gms", name = "play-services-mlkit-document-scanner", version.ref = "mlkit-document-scanner" } mlkit-text-recognition = { group = "com.google.android.gms", name = "play-services-mlkit-text-recognition", version.ref = "mlkit-text-recognition" } +mlkit-image-labeling = { group = "com.google.android.gms", name = "play-services-mlkit-image-labeling", version.ref = "mlkit-image-labeling" } # Vault bouncycastle-prov = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } From 7a6c46271022b7e2f9ad4737d736bb083716478a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:10:49 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Drive:=20Arbeitsfotos=20=E2=80=93=20Lernsta?= =?UTF-8?q?nd=20im=20Hintergrund=20lesen,=20Mehr-Texte,=20Aufr=C3=A4umen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beispiele der KI beim App-Start im Hintergrund lesen, nicht im UI-Thread - Mehr › Zuordnung: ausgegraute Einträge sagen den Grund (Schon Arbeit, …) - Thumbnail für die KI über die einfache Medien-URI - Texte der Snackbar und des Aufräumens über LocalResources Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../android/drive/backup/DriveBackup.kt | 4 ++- .../android/drive/work/DriveWorkPhotos.kt | 5 +++- .../android/drive/work/DriveWorkStore.kt | 4 +-- .../android/drive/work/DriveWorkWifi.kt | 2 ++ .../android/ui/drive/DrivePhotosUi.kt | 25 +++++++++---------- .../android/ui/drive/DriveWorkCleanup.kt | 10 +++++--- .../android/ui/drive/DriveWorkUi.kt | 2 -- app/src/main/res/values-de/strings_drive8.xml | 2 -- app/src/main/res/values/strings_drive8.xml | 2 -- 9 files changed, 29 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt index b6c6aa21..fcc07d6c 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt @@ -309,7 +309,9 @@ class DriveBackup @Inject constructor( val path = DriveBackupLogic.cameraPath(own, item.taken, item.modified, item.added * 1000, ZoneId.systemDefault()) // 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 { - work.placeNew(item.uri, DriveBackupLogic.takenMs(own, item.taken, item.modified), item.video) + // 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) { diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt index bcafac09..1aca704b 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkPhotos.kt @@ -46,7 +46,7 @@ class DriveWorkPhotos @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) val settings: StateFlow = store.settings - val learned: StateFlow get() = store.learned + val learned: StateFlow = store.learned val localReview: StateFlow> = store.localReview /** Drive 1.13 keeps the settings and the marks; without it this phone does. */ @@ -150,6 +150,9 @@ class DriveWorkPhotos @Inject constructor( } } + /** What was learned, read from the phone if not yet. */ + suspend fun learnedNow(): DriveWorkLogic.Learned = withContext(Dispatchers.IO) { DriveWorkFeatures.learned(store.examples()) } + /** The features of a photo in Drive (labeled once, then kept). */ private suspend fun featuresOf(node: DNode): Map? { val key = key(node) diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt index ddd43dfd..bb09e538 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkStore.kt @@ -60,8 +60,8 @@ class DriveWorkStore @Inject constructor(@ApplicationContext private val context private var examplesRead = false private val _learned = MutableStateFlow(DriveWorkLogic.Learned()) - /** What the AI learned so far (read on first use). */ - val learned: StateFlow get() = _learned.also { examples() } + /** What the AI learned so far (read at app start, [examples]). */ + val learned: StateFlow = _learned.asStateFlow() private val _localReview = MutableStateFlow(readLocalReview()) /** Without Drive 1.13: the photos marked "zu prüfen" on this phone (node id → hint). */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt index e6f65f79..69e01743 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkWifi.kt @@ -84,6 +84,8 @@ class DriveWorkWifi @Inject constructor( if (started) return started = true registerWake() + // what the AI learned, read once off the main thread + scope.launch { store.examples() } scope.launch { monitor.network .map { ssidOf(it) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt index 0b38d962..c0d8cd1b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -229,12 +230,13 @@ internal fun DrivePhotosContent( val reviewCount = if (serverClasses) classes?.review ?: 0 else localReview.size val reviewBar = DriveWorkLogic.reviewBar(chosen, localReview) val workSnackLearns = stringResource(R.string.drive_work_snack_learns) + val resources = LocalResources.current val undoLabel = stringResource(R.string.drive_snack_undo) LaunchedEffect(workSnack) { val w = workSnack ?: return@LaunchedEffect viewModel.workSnackShown() - val r = context.resources - val target = workSettings.folderLabel ?: context.getString(R.string.drive_work_work_default) + val r = resources + val target = workSettings.folderLabel ?: r.getString(R.string.drive_work_work_default) val line = when (w.to) { DriveWorkPhotos.Target.WORK -> r.getQuantityString(R.plurals.drive_work_snack_work, w.count, w.count, target) DriveWorkPhotos.Target.PRIVATE -> r.getQuantityString(R.plurals.drive_work_snack_private, w.count, w.count) @@ -959,34 +961,31 @@ private fun PhotosMoreSheet( ) val assign = if (!workOn) emptyList() else { val offer = DriveWorkLogic.offer(nodes, localReview) + val no = DriveBulk.Can(false, null) listOf( MoreItem( GcIcons.Briefcase, stringResource(R.string.drive_work_more_work), - stringResource(R.string.drive_work_more_work_desc, workLabel), when { - !workFolderSet -> DriveBulk.Can(false, null) - !offer.work -> DriveBulk.Can(false, null) - else -> can(DriveBulk.Action.MOVE) + !workFolderSet -> stringResource(R.string.drive_work_more_no_folder) + !offer.work -> stringResource(R.string.drive_work_more_work_already) + else -> stringResource(R.string.drive_work_more_work_desc, workLabel) }, + if (workFolderSet && offer.work) can(DriveBulk.Action.MOVE) else no, ) { viewModel.assign(DriveWorkPhotos.Target.WORK, nodes) }, MoreItem( GcIcons.House, stringResource(R.string.drive_work_more_private), stringResource(if (offer.private) R.string.drive_work_more_private_desc else R.string.drive_work_more_private_already), - if (offer.private) can(DriveBulk.Action.MOVE) else DriveBulk.Can(false, null), + if (offer.private) can(DriveBulk.Action.MOVE) else no, ) { viewModel.assign(DriveWorkPhotos.Target.PRIVATE, nodes) }, MoreItem( GcIcons.Alert, stringResource(R.string.drive_work_more_review), if (offer.review) null else stringResource(R.string.drive_work_more_review_already), - if (offer.review) can(DriveBulk.Action.MOVE) else DriveBulk.Can(false, null), + if (offer.review) can(DriveBulk.Action.MOVE) else no, ) { viewModel.assign(DriveWorkPhotos.Target.REVIEW, nodes) }, - ).map { item -> - // a greyed-out entry says why below its title - if (item.can.ok || item.description != null) item - else item.copy(description = stringResource(if (!workFolderSet) R.string.drive_work_more_no_folder else R.string.drive_work_more_work_already)) - } + ) } val state = one?.let { when { diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt index 8e5ac35c..63152850 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkCleanup.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -97,13 +98,13 @@ class DriveCleanupViewModel @Inject constructor(private val work: DriveWorkPhoto init { viewModelScope.launch { - if (!work.learned.value.usable) { + if (!work.learnedNow().usable) { _state.value = State.Learning return@launch } val list = work.suggest { done, total -> _state.value = State.Looking(done, total) } _selected.value = list.filter { it.preselected }.map { it.node.id }.toSet() - _state.value = if (list.isEmpty() && !work.learned.value.usable) State.Learning else State.Ready(list) + _state.value = State.Ready(list) } } @@ -144,6 +145,7 @@ private fun monthName(month: String?): String? = month?.let { @Composable fun DriveCleanupScreen(onBack: () -> Unit, viewModel: DriveCleanupViewModel = hiltViewModel()) { val context = LocalContext.current + val resources = LocalResources.current val x = GateControlTheme.extraColors val state by viewModel.state.collectAsStateWithLifecycle() val selected by viewModel.selected.collectAsStateWithLifecycle() @@ -159,8 +161,8 @@ fun DriveCleanupScreen(onBack: () -> Unit, viewModel: DriveCleanupViewModel = hi val m = message ?: return@LaunchedEffect val n = m.substringAfter(':', "").toIntOrNull() ?: 0 val text = when { - m.startsWith("cleanup_done:") -> context.resources.getQuantityString(R.plurals.drive_work_cleanup_done, n, n) - m == "cleanup_left" -> context.getString(R.string.drive_work_cleanup_left) + m.startsWith("cleanup_done:") -> resources.getQuantityString(R.plurals.drive_work_cleanup_done, n, n) + m == "cleanup_left" -> resources.getString(R.string.drive_work_cleanup_left) else -> workText(context, m) } Toast.makeText(context, text, Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt index dda7b74d..8dd3831c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveWorkUi.kt @@ -176,8 +176,6 @@ class DriveWorkViewModel @Inject constructor( } fun removalsDone(ids: List) = store.clearRemovals(ids) - - fun hasClasses(): Boolean = work.serverClasses() } /** A work-photo code as a sentence (outside of composition). */ diff --git a/app/src/main/res/values-de/strings_drive8.xml b/app/src/main/res/values-de/strings_drive8.xml index 23e8a223..1e622f79 100644 --- a/app/src/main/res/values-de/strings_drive8.xml +++ b/app/src/main/res/values-de/strings_drive8.xml @@ -43,8 +43,6 @@ Zuordnen Arbeit Privat - Ist ein Arbeitsfoto - Ist privat Zuordnung Ist ein Arbeitsfoto Verschiebt nach „%1$s“, die KI lernt mit diff --git a/app/src/main/res/values/strings_drive8.xml b/app/src/main/res/values/strings_drive8.xml index 5bba318d..9c084717 100644 --- a/app/src/main/res/values/strings_drive8.xml +++ b/app/src/main/res/values/strings_drive8.xml @@ -43,8 +43,6 @@ Assign Work Private - Is a work photo - Is private Assignment Is a work photo Moves to “%1$s”, the AI learns along