diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67e170fa..36d6acc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -230,6 +230,12 @@ jobs: cp app/build/outputs/mapping/release/mapping.txt "$RUNNER_TEMP/apk-mapping.txt" ./gradlew bundleRelease -Pgc.distribution=play + - name: Fetch the face model for the release + if: steps.check_tag.outputs.exists == 'false' + # Drive "Personen erkennen": the app loads SFace from this release when switched on (not in the APK/AAB). + # faceModels checks its SHA-256 against OpenCV Zoo's file. + run: ./gradlew :app:faceModels + - name: Verify 16 KB page alignment of native libs if: steps.check_tag.outputs.exists == 'false' run: python3 scripts/check_elf_alignment.py app/build/outputs/apk/release/ app/build/outputs/bundle/release/ @@ -303,6 +309,7 @@ jobs: app/build/outputs/bundle/release/GateControl-Android-*.aab app/build/outputs/mapping/release/GateControl-Android-*-mapping.txt app/GateControl-Android-*-native-debug-symbols.zip + app/build/faceModels/face_recognition_sface_2021dec.onnx # ─── Google Play: every release goes to a test track ───────────── # Production happens only on request through play-promote.yml. diff --git a/NOTICE b/NOTICE index 20a97470..62f092f6 100644 --- a/NOTICE +++ b/NOTICE @@ -12,6 +12,22 @@ This product includes software developed at: Licensed under the Mozilla Public License, v. 2.0 (see freerdp/client/Android/Studio/freeRDPCore/src/main/java/com/freerdp/freerdpcore/domain/*.java) + YuNet face detection model (face_detection_yunet_2023mar.onnx, OpenCV Zoo) + Copyright (c) 2020 Shiqi Yu + Licensed under the MIT License + + SFace face recognition model (face_recognition_sface_2021dec.onnx, OpenCV Zoo) + Contributed by Yaoyao Zhong, ONNX conversion by Chengrui Wang + Licensed under the Apache License, Version 2.0 + + ONNX Runtime (com.microsoft.onnxruntime:onnxruntime-android) + Copyright (c) Microsoft Corporation + Licensed under the MIT License + + YuNet ships unchanged in the app (assets/faces/); SFace is loaded unchanged by the app when + "Recognise people" is switched on (attached to each GitHub release by the release workflow). + Their licence texts ship in the app under assets/faces/. + The full text of the Apache License 2.0 is available at http://www.apache.org/licenses/LICENSE-2.0 The full text of the Mozilla Public License 2.0 is available at diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ad1488a..da70f543 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,7 @@ +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -114,6 +118,8 @@ android { testOptions { unitTests.isReturnDefaultValues = true + // Robolectric (Compose UI tests) needs the merged resources and manifest + unitTests.isIncludeAndroidResources = true } } @@ -207,6 +213,10 @@ dependencies { // 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) + // Drive: people in the photos — faces found and described on the phone (YuNet in the assets, SFace loaded when + // switched on; models from OpenCV Zoo), no picture leaves the phone for it + implementation(libs.onnxruntime.android) + // Vault: end-to-end encryption (README "Kryptoformat" of gatecontrol-vault) and the recovery key's QR implementation(libs.bouncycastle.prov) implementation(libs.zxing.core) @@ -233,6 +243,10 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.robolectric) + // Compose UI tests on the JVM (Robolectric): the Drive grid must not move when ticking starts + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.ui.test.junit4) + testRuntimeOnly(libs.junit.vintage.engine) testImplementation(libs.okhttp) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.compose.ui.test.junit4) @@ -261,8 +275,85 @@ gradle.taskGraph.whenReady { } } +// Drive: SFace, the face feature model (OpenCV Zoo, unchanged, 39 MB), is not in the app: the phone loads it when the +// person switches on "Personen erkennen" (DriveFaceModel). This task fetches it, checked against its SHA-256, for the +// release workflow, which attaches it to every GitHub release (the app's first source). YuNet (0.2 MB) is in assets/faces. +// Kept in Gradle's cache; another mirror is tried when one fails. +abstract class FaceModelsTask : DefaultTask() { + @get:Input abstract val models: MapProperty + @get:Input abstract val mirrors: ListProperty + @get:Internal abstract val cacheDir: DirectoryProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty + + @TaskAction + fun fetch() { + val out = outputDir.get().asFile.apply { deleteRecursively(); mkdirs() } + val cache = cacheDir.get().asFile.apply { mkdirs() } + for ((path, sha) in models.get()) { + val name = path.substringAfterLast('/') + val cached = cache.resolve("$sha-$name") + if (!cached.isFile || sha256(cached) != sha) { + var last: Exception? = null + val ok = mirrors.get().any { base -> + try { + val tmp = cache.resolve("$name.part") + val url = base.replace("{dir}", path.substringBeforeLast('/')).replace("{file}", name) + val conn = URI(url).toURL().openConnection() as HttpURLConnection + conn.instanceFollowRedirects = true + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } } + if (sha256(tmp) != sha) throw GradleException("SHA-256 of $name from $base does not match") + tmp.renameTo(cached) + } catch (e: Exception) { + last = e + false + } + } + if (!ok) throw GradleException("Face model $name could not be fetched", last) + } + cached.copyTo(out.resolve(name), overwrite = true) + } + } + + private fun sha256(f: File): String { + val md = MessageDigest.getInstance("SHA-256") + f.inputStream().use { input -> + val buf = ByteArray(1 shl 16) + while (true) { + val n = input.read(buf) + if (n < 0) break + md.update(buf, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it) } + } +} + +val faceModels = tasks.register("faceModels") { + // directory/file in OpenCV Zoo → SHA-256 (the same as DriveFaceModel's; licences: assets/faces/NOTICE.txt) + models.put("face_recognition_sface/face_recognition_sface_2021dec.onnx", "0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79") + mirrors.add("https://media.githubusercontent.com/media/opencv/opencv_zoo/main/models/{dir}/{file}") + mirrors.add("https://huggingface.co/opencv/{dir}/resolve/main/{file}") + cacheDir.set(File(gradle.gradleUserHomeDir, "caches/gatecontrol-face-models")) + outputDir.set(layout.buildDirectory.dir("faceModels")) +} + +// Robolectric's Android jar (Compose UI tests) through Gradle and its cache, not Robolectric's own downloader +val robolectricJars: Configuration by configurations.creating +dependencies { + robolectricJars("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") +} +val robolectricDeps = tasks.register("robolectricDeps") { + from(robolectricJars) + into(layout.buildDirectory.dir("robolectric")) +} + tasks.withType { useJUnitPlatform() + dependsOn(robolectricDeps) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.dependency.dir", layout.buildDirectory.dir("robolectric").get().asFile.absolutePath) finalizedBy(tasks.named("jacocoTestReport")) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 96aad397..a919c8e2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -141,3 +141,7 @@ # ── General ─────────────────────────────────────────────── -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile + +# ── ONNX Runtime (Drive: faces) ─────────────────────────── +# The native library creates and calls the Java API classes by name (JNI). +-keep class ai.onnxruntime.** { *; } diff --git a/app/src/main/assets/faces/LICENSE-SFace.txt b/app/src/main/assets/faces/LICENSE-SFace.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/app/src/main/assets/faces/LICENSE-SFace.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/src/main/assets/faces/LICENSE-YuNet.txt b/app/src/main/assets/faces/LICENSE-YuNet.txt new file mode 100644 index 00000000..4cdf89a4 --- /dev/null +++ b/app/src/main/assets/faces/LICENSE-YuNet.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Shiqi Yu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/app/src/main/assets/faces/NOTICE.txt b/app/src/main/assets/faces/NOTICE.txt new file mode 100644 index 00000000..e6a71a58 --- /dev/null +++ b/app/src/main/assets/faces/NOTICE.txt @@ -0,0 +1,14 @@ +Face models used by GateControl (Drive › Fotos › Personen), unchanged from OpenCV Zoo +(https://github.com/opencv/opencv_zoo). They run on the phone only. + +YuNet — face_detection_yunet_2023mar.onnx + https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet + Copyright (c) 2020 Shiqi Yu. MIT License (LICENSE-YuNet.txt). + +SFace — face_recognition_sface_2021dec.onnx (loaded by the app when switched on, not part of the package) + https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface + Contributed by Yaoyao Zhong; ONNX conversion by Chengrui Wang. + Apache License 2.0 (LICENSE-SFace.txt). + +ONNX Runtime (com.microsoft.onnxruntime:onnxruntime-android) + Copyright (c) Microsoft Corporation. MIT License. diff --git a/app/src/main/assets/faces/face_detection_yunet_2023mar.onnx b/app/src/main/assets/faces/face_detection_yunet_2023mar.onnx new file mode 100644 index 00000000..f9beb304 Binary files /dev/null and b/app/src/main/assets/faces/face_detection_yunet_2023mar.onnx differ diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt index e00dd8a1..a3bf7e75 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -256,11 +256,76 @@ 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, cls: String? = null, q: String? = null, unlabeled: Boolean = false): DResult = - get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls, q = q, unlabeled = unlabeled) ?: 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, + q: String? = null, + unlabeled: Boolean = false, + filter: DPhotoFilter = DPhotoFilter(), + ): DResult = + get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls, q = q, unlabeled = unlabeled, filter = filter) ?: 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) + suspend fun photoMonths(area: String? = null, camera: Boolean = false, cls: String? = null, q: String? = null, filter: DPhotoFilter = DPhotoFilter()): DResult> = + get("photos/months", DriveJson.photosQuery(area, camera = camera, cls = cls, q = q, filter = filter) ?: return bad(), DriveJson::months) + + // ─── places and people (Drive 1.16, features `photoPlaces`, `photoPeople`) ── + + /** The places of the photos, most photos first (the same filters as `photos`). */ + suspend fun photoPlaces(camera: Boolean = false, cls: String? = null, q: String? = null, filter: DPhotoFilter = DPhotoFilter()): DResult> = + get("photos/places", DriveJson.photosQuery(camera = camera, cls = cls, q = q, filter = filter.copy(bbox = null)) ?: return bad(), DrivePlacesJson::places) + + /** Bundles of photos in [bbox] at [zoom] (0 … 19), at most 200. */ + suspend fun photoMap(bbox: DBbox, zoom: Int, camera: Boolean = false, cls: String? = null, q: String? = null, filter: DPhotoFilter = DPhotoFilter()): DResult> { + val query = DriveJson.photosQuery(camera = camera, cls = cls, q = q, filter = filter.copy(bbox = bbox)) ?: return bad() + return get("photos/map", query + ("zoom" to zoom.coerceIn(0, 19).toString()), DrivePlacesJson::clusters) + } + + /** Where photo [id] was taken (null [at]: looked at, it has no position). */ + suspend fun putGeo(id: Long, at: com.gatecontrol.android.drive.work.GeoPoint?): DResult = + put("nodes/$id/geo", if (at == null) DrivePlacesJson.noGeoBody() else DrivePlacesJson.geoBody(at) ?: return bad(), unit) + + /** The places of every photo this person uploaded go → how many. */ + suspend fun deleteGeo(): DResult = del("photos/geo", DrivePlacesJson::deleted) + + /** The switches of places and people in `photos/settings`. */ + suspend fun putPlacesSettings(keepGeo: Boolean? = null, stripGeoOnShare: Boolean? = null, people: Boolean? = null, peopleKids: Boolean? = null): DResult = + put("photos/settings", DrivePlacesJson.settingsBody(keepGeo, stripGeoOnShare, people, peopleKids), DriveJson::photoSettings) + + /** What this phone found on photo [id] for [model] (`[]`: looked at, no faces). */ + suspend fun putFaces(id: Long, model: String, faces: List): DResult = + put("nodes/$id/faces", DrivePlacesJson.facesBody(model, faces), unit) + + suspend fun people(): DResult = get("people", parse = DrivePlacesJson::people) + + suspend fun person(id: Long): DResult = get("people/$id", parse = DrivePlacesJson::personDetail) + + suspend fun patchPerson(id: Long, name: String? = null, hidden: Boolean? = null, contact: DPersonContact? = null, clearContact: Boolean = false): DResult = + patch("people/$id", DrivePlacesJson.personPatchBody(name, hidden, contact, clearContact), DrivePlacesJson::patched) + + suspend fun mergePerson(id: Long, into: Long): DResult = post("people/$id/merge", DrivePlacesJson.mergeBody(into), DrivePlacesJson::personAnswer) + + suspend fun similarPeople(id: Long): DResult> = get("people/$id/similar", parse = DrivePlacesJson::similar) + + suspend fun uncertainFaces(id: Long, limit: Int = 40): DResult> = + get("people/$id/uncertain", mapOf("limit" to limit.coerceIn(1, 200).toString()), DrivePlacesJson::uncertain) + + /** The unsure faces are this person ([yes]) or never again suggested. */ + suspend fun confirmFaces(id: Long, faceIds: List, yes: Boolean): DResult { + if (faceIds.isEmpty()) return bad() + return post("people/$id/confirm", DrivePlacesJson.confirmBody(faceIds, yes), unit) + } + + /** One face: "Nicht diese Person" (null) or "Zu … verschieben". */ + suspend fun moveFace(faceId: Long, person: Long?): DResult = post("faces/$faceId/move", DrivePlacesJson.moveBody(person), unit) + + /** What the typed words may mean (people, places, what is in the photos). */ + suspend fun photoSuggest(q: String): DResult { + val t = q.trim().take(DriveJson.PHOTO_Q_MAX).ifEmpty { return bad() } + return get("photos/suggest", mapOf("q" to t), DrivePlacesJson::suggest) + } // ─── work photos (Drive 1.13, feature `photoClasses`) ─────────── @@ -621,8 +686,9 @@ class DriveApi @Inject constructor( onConflict: String, text: String? = null, takenAt: String? = null, + geo: com.gatecontrol.android.drive.work.GeoPoint? = null, ): DResult> = - post("uploads/commit", DriveJson.commitBody(mediaId, parentId, name, mtimeMs, onConflict, text, takenAt), DriveJson::commit) + post("uploads/commit", DriveJson.commitBody(mediaId, parentId, name, mtimeMs, onConflict, text, takenAt, geo), DriveJson::commit) /** An empty file `name` in [parentId] without media (feature `emptyFiles`, Drive 1.5+). */ suspend fun commitEmpty(parentId: Long, name: String, mime: String?, onConflict: String): DResult> = diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveGeoBackfill.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveGeoBackfill.kt new file mode 100644 index 00000000..9c9d1155 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveGeoBackfill.kt @@ -0,0 +1,141 @@ +package com.gatecontrol.android.drive + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.gatecontrol.android.drive.work.DriveWorkGeo +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.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Orte nachtragen" (Drive 1.16, `photoPlaces`): photos backed up before Drive knew places get theirs from the original + * still on this phone (EXIF GPS, read with "Zugriff auf Fotostandorte"). Drive lists the photos without a place where + * one writes (`photos?unlocated=1`); a photo found here gets its position, or "none" when it has none + * (`PUT nodes//geo`). A photo not on this phone stays for another device. Nothing with "Aufnahmeort mitsichern" off. + */ +@Singleton +class DriveGeoBackfill @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + private val live: DriveLive, + private val prefs: DrivePlacesPrefs, + private val geo: DriveWorkGeo, +) { + private val lock = Mutex() + + enum class Outcome { DONE, MORE, RETRY } + + fun canLook(): Boolean = geo.canLook() + + /** One run over up to [budget] photos of Drive's list, going on where the last one stopped. */ + suspend fun run(budget: Int = BUDGET): Outcome = lock.withLock { + if (!live.supports(DMe.FEATURE_PHOTO_PLACES) || !prefs.settings.value.keepGeo || !geo.canLook()) return Outcome.DONE + val key = DrivePlacesPrefs.CURSOR_GEO + var cursor = prefs.cursor(key) + var looked = 0 + var pages = 0 + while (looked < budget && pages < MAX_PAGES) { + val page = when (val r = api.photos(before = cursor, limit = PAGE, filter = DPhotoFilter(unlocated = true))) { + is DResult.Ok -> r.value + DResult.Offline -> return Outcome.RETRY + else -> return Outcome.DONE + } + pages++ + for (n in page.items) { + currentCoroutineContext().ensureActive() + if (n.located == true || n.geo != null) continue + val r = when (val f = geo.find(n)) { + DriveWorkGeo.Found.Unknown, DriveWorkGeo.Found.NotHere -> continue + DriveWorkGeo.Found.NoPosition -> api.putGeo(n.id, null).also { prefs.geoLooked(false) } + is DriveWorkGeo.Found.At -> api.putGeo(n.id, f.at).also { prefs.geoLooked(true) } + } + if (r == DResult.Offline) return Outcome.RETRY + looked++ + } + cursor = page.next + prefs.setCursor(key, cursor) + if (cursor == null) { + Timber.i("Drive places: a pass through the photos is done") + return Outcome.DONE + } + } + return Outcome.MORE + } + + companion object { + const val BUDGET = 400 + private const val PAGE = 150 + private const val MAX_PAGES = 30 + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DriveGeoBackfillEntryPoint { + fun driveGeoBackfill(): DriveGeoBackfill + fun drivePlacesPrefs(): DrivePlacesPrefs +} + +class DriveGeoBackfillWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val entry = EntryPointAccessors.fromApplication(applicationContext, DriveGeoBackfillEntryPoint::class.java) + entry.drivePlacesPrefs().geoRunning(true, fresh = runAttemptCount == 0 && !inputData.getBoolean(KEY_GOING_ON, false)) + val outcome = try { + entry.driveGeoBackfill().run() + } finally { + entry.drivePlacesPrefs().geoRunning(false) + } + return when (outcome) { + DriveGeoBackfill.Outcome.DONE -> Result.success() + DriveGeoBackfill.Outcome.MORE -> Result.success().also { DriveGeoBackfillWork.next(applicationContext) } + DriveGeoBackfill.Outcome.RETRY -> if (runAttemptCount < 5) Result.retry() else Result.success() + } + } + + companion object { + const val KEY_GOING_ON = "going_on" + } +} + +/** "Orte nachtragen": started from the settings, goes on in parts until Drive's list is through. */ +object DriveGeoBackfillWork { + private const val RUN = "drive-geo-backfill" + + fun start(context: Context) = enqueue(context, ExistingWorkPolicy.REPLACE, 0, goingOn = false) + + fun next(context: Context) = enqueue(context, ExistingWorkPolicy.APPEND_OR_REPLACE, 1, goingOn = true) + + private fun enqueue(context: Context, policy: ExistingWorkPolicy, delayMinutes: Long, goingOn: Boolean) { + runCatching { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(if (DriveSettings.wifiOnly(context)) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build(), + ) + .setInputData(androidx.work.workDataOf(DriveGeoBackfillWorker.KEY_GOING_ON to goingOn)) + .setInitialDelay(delayMinutes, TimeUnit.MINUTES) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 2, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(RUN, policy, request) + }.onFailure { Timber.w("Drive places: not scheduled (%s)", it.javaClass.simpleName) } + } +} 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 6056ef83..c22f0b91 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -199,6 +199,9 @@ internal object DriveJson { review = o.obj("review")?.let { DReview(it.int("hint")?.coerceIn(0, 100)) }, work = o.bool("work") == true, labeled = o.bool("labeled"), + geo = DrivePlacesJson.geo(o.obj("geo")), + located = o.bool("located"), + people = o.arr("people")?.mapNotNull { e -> runCatching { e.asLong }.getOrNull() }?.distinct()?.take(50), ).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")) @@ -386,6 +389,7 @@ internal object DriveJson { onConflict: String, text: String? = null, takenAt: String? = null, + geo: com.gatecontrol.android.drive.work.GeoPoint? = null, ) = JsonObject().apply { addProperty("mediaId", mediaId) addProperty("parentId", parentId) @@ -396,6 +400,8 @@ internal object DriveJson { if (!text.isNullOrBlank()) addProperty("text", text.take(TEXT_MAX)) // When a photo was taken (only sent when the server has `takenAt`). if (!takenAt.isNullOrBlank()) addProperty("takenAt", takenAt.take(40)) + // Where it was taken (Drive 1.16, only sent with `photoPlaces` and `keepGeo`). + DrivePlacesJson.geoBody(geo)?.let { add("geo", it) } } /** An empty file (feature `emptyFiles`): no media, just its place, name and size 0. */ @@ -429,7 +435,7 @@ internal object DriveJson { /** `GET photos` → `{ items, next }`. */ fun photos(o: JsonObject): DPhotos? { if (o.arr("items") == null) return null - return DPhotos(nodes(o), o.cursor("next"), o.int("total")?.takeIf { it >= 0 }, terms(o)) + return DPhotos(nodes(o), o.cursor("next"), o.int("total")?.takeIf { it >= 0 }, terms(o), DrivePlacesJson.marks(o.obj("search")), DrivePlacesJson.districts(o)) } /** `GET photos/months` → `{ months: [{ month, count }] }`. */ @@ -460,6 +466,7 @@ internal object DriveJson { cls: String? = null, q: String? = null, unlabeled: Boolean = false, + filter: DPhotoFilter = DPhotoFilter(), ): Map? = buildMap { if (area != null) put("area", areaId(area) ?: return null) @@ -470,6 +477,7 @@ internal object DriveJson { if (cls != null) put("class", cls.takeIf { it in PHOTO_CLASSES } ?: return null) if (q != null) q.trim().take(PHOTO_Q_MAX).takeIf { it.isNotEmpty() }?.let { put("q", it) } if (unlabeled) put("unlabeled", "1") + putAll(DrivePlacesJson.filterQuery(filter) ?: return null) } /** The values of `class` (Drive 1.13). */ @@ -477,7 +485,7 @@ internal object DriveJson { /** `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 + if (!o.has("workFolder") && !o.has("workSubfolders") && !o.has("keepGeo") && !o.has("people")) return null val folder = o.obj("folder") val id = o.long("workFolder")?.takeIf { it > 0 } return DPhotoSettings( @@ -486,6 +494,10 @@ internal object DriveJson { workInMemories = o.bool("workInMemories") ?: false, folderPath = folder?.takeIf { id != null }?.str("path")?.take(2000), folderAreaId = folder?.takeIf { id != null }?.let { areaId(it.str("areaId")) }, + keepGeo = o.bool("keepGeo"), + stripGeoOnShare = o.bool("stripGeoOnShare"), + people = o.bool("people"), + peopleKids = o.bool("peopleKids"), ) } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt index fc6f92c2..ae6b0b19 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt @@ -245,6 +245,10 @@ internal object DriveLogic { */ fun cameraFilter(on: Boolean, me: DMe?): Boolean = on && me?.has(DMe.FEATURE_PHOTOS_FOLDER) == true + /** The Photos page's search finds photos by content (1.14), people or places (1.16): else the files' search. */ + fun photoSearchable(me: DMe?): Boolean = + me != null && (me.has(DMe.FEATURE_PHOTO_SEARCH) || me.has(DMe.FEATURE_PHOTO_PLACES) || me.has(DMe.FEATURE_PHOTO_PEOPLE)) + /** Pages of a photo list put together: each node once (a page boundary may repeat one). */ fun appendPage(have: List, page: List): List { val seen = have.mapTo(HashSet()) { it.id } 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 082c3391..1d824f4e 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -112,6 +112,10 @@ data class DMe( const val FEATURE_PHOTO_SEARCH = "photoSearch" /** Drive 1.14: a deadline into the family calendar (`POST deadlines//calendar`). */ const val FEATURE_DEADLINE_CALENDAR = "deadlineCalendar" + /** Drive 1.16: where photos were taken (`geo` on the commit, `photos/places`, `photos/map`, `place=`, `bbox=`). */ + const val FEATURE_PHOTO_PLACES = "photoPlaces" + /** Drive 1.16: people in the photos (faces found on the phones, `people`, `person=`). */ + const val FEATURE_PHOTO_PEOPLE = "photoPeople" } } @@ -214,6 +218,12 @@ data class DNode( val work: Boolean = false, /** Drive 1.14 (`photoSearch`): a phone looked at the photo and sent what is on it; null when the server says nothing. */ val labeled: Boolean? = null, + /** Drive 1.16 (`photoPlaces`): where it was taken; null without a place. */ + val geo: DGeo? = null, + /** Drive 1.16: Drive has a place for it, or knows there is none (null: the server says nothing). */ + val located: Boolean? = null, + /** Drive 1.16 (`photoPeople`, `with=people`): the named people on it (ids); null when not asked. */ + val people: List? = null, ) { /** 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 @@ -232,6 +242,14 @@ data class DPhotoSettings( val workInMemories: Boolean = false, val folderPath: String? = null, val folderAreaId: String? = null, + /** Drive 1.16 (`photoPlaces`): the apps send where photos were taken; null when the server does not say. */ + val keepGeo: Boolean? = null, + /** Drive 1.16: links and GateDrop give photos without GPS data; null: not offered (the switch is hidden). */ + val stripGeoOnShare: Boolean? = null, + /** Drive 1.16 (`photoPeople`): faces are recognised; null when the server does not say. */ + val people: Boolean? = null, + /** Drive 1.16: the children's folders too. */ + val peopleKids: Boolean? = null, ) /** `GET photos/classes`: how many photos are work, private and to be reviewed. */ @@ -308,6 +326,10 @@ data class DPhotos( val total: Int? = null, /** Drive 1.14 `photos?q=`: the most frequent terms of the hits (the chips). */ val terms: List = emptyList(), + /** Drive 1.16: the people and places the words of `q` were taken as. */ + val marks: DSearchMarks = DSearchMarks(), + /** Drive 1.16 `photos?place=&districts=1`: the place's districts. */ + val districts: List = emptyList(), ) /** `GET photos/months`: how many photos a month (`2026-10`) has. */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesJson.kt b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesJson.kt new file mode 100644 index 00000000..303f52db --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesJson.kt @@ -0,0 +1,311 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.drive.DriveJson.arr +import com.gatecontrol.android.drive.DriveJson.bool +import com.gatecontrol.android.drive.DriveJson.int +import com.gatecontrol.android.drive.DriveJson.long +import com.gatecontrol.android.drive.DriveJson.obj +import com.gatecontrol.android.drive.DriveJson.str +import com.gatecontrol.android.drive.work.GeoPoint +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import java.util.Locale + +/** + * Parsers and bodies of places and people in the photos (Drive 1.16, features `photoPlaces` and `photoPeople`): pure, + * null for an answer without the shape needed, names and lists bounded like [DriveJson]. + */ +internal object DrivePlacesJson { + + private val PLACE_KEY = Regex("^[A-Za-z0-9:._-]{1,80}$") + private val MODEL = Regex("^[a-z0-9._-]{1,40}$") + const val NAME_MAX = 80 + private const val LIST_MAX = 500 + + fun placeKey(v: String?): String? = v?.trim()?.takeIf { PLACE_KEY.matches(it) } + + fun model(v: String?): String? = v?.takeIf { MODEL.matches(it) } + + private fun JsonObject.dbl(key: String): Double? = + get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asDouble }.getOrNull() }?.takeIf { it.isFinite() } + + private fun name(v: String?): String? = v?.trim()?.take(NAME_MAX)?.ifEmpty { null } + + private fun JsonArray.objects(): List = mapNotNull { it as? JsonObject }.take(LIST_MAX) + + fun lat(v: Double?): Double? = v?.takeIf { it in -90.0..90.0 } + + fun lon(v: Double?): Double? = v?.takeIf { it in -180.0..180.0 } + + // ─── places ───────────────────────────────────────────────────── + + fun place(o: JsonObject?): DPlace? { + if (o == null) return null + return DPlace( + key = placeKey(o.str("key")) ?: return null, + name = name(o.str("name")) ?: return null, + district = name(o.str("district")), + region = name(o.str("region")), + country = o.str("country")?.trim()?.uppercase(Locale.ROOT)?.takeIf { it.length == 2 }, + ) + } + + /** `geo` of an item: `{ lat, lon, place }`; null without valid coordinates. */ + fun geo(o: JsonObject?): DGeo? { + if (o == null) return null + return DGeo(lat(o.dbl("lat")) ?: return null, lon(o.dbl("lon")) ?: return null, place(o.obj("place"))) + } + + /** `GET photos/places` → `{ places: [...] }`. */ + fun places(o: JsonObject): List? = o.arr("places")?.objects()?.mapNotNull { p -> + DPlaceSummary( + key = placeKey(p.str("key")) ?: return@mapNotNull null, + name = name(p.str("name")) ?: return@mapNotNull null, + region = name(p.str("region")), + country = p.str("country")?.uppercase(Locale.ROOT)?.takeIf { it.length == 2 }, + count = p.int("count")?.coerceAtLeast(0) ?: 0, + lat = lat(p.dbl("lat")) ?: return@mapNotNull null, + lon = lon(p.dbl("lon")) ?: return@mapNotNull null, + from = p.str("from")?.take(40), + to = p.str("to")?.take(40), + cover = DriveJson.node(p.obj("cover")), + ) + } + + /** `GET photos/map` → `{ clusters: [...] }` (at most 200 are taken). */ + fun clusters(o: JsonObject): List? = o.arr("clusters")?.objects()?.take(200)?.mapNotNull { c -> + DCluster( + lat = lat(c.dbl("lat")) ?: return@mapNotNull null, + lon = lon(c.dbl("lon")) ?: return@mapNotNull null, + count = c.int("count")?.takeIf { it > 0 } ?: return@mapNotNull null, + key = placeKey(c.str("key")), + cover = DriveJson.node(c.obj("cover")), + ) + } + + fun districts(o: JsonObject): List = o.arr("districts")?.objects()?.mapNotNull { d -> + DDistrict(name(d.str("name")) ?: return@mapNotNull null, d.int("count")?.coerceAtLeast(0) ?: 0) + }.orEmpty() + + /** `search` of `GET photos?q=`: the people and places recognised in the words. */ + fun marks(o: JsonObject?): DSearchMarks { + if (o == null) return DSearchMarks() + return DSearchMarks( + people = o.arr("people")?.objects()?.mapNotNull { p -> (p.long("id") ?: return@mapNotNull null) to (name(p.str("name")) ?: return@mapNotNull null) }.orEmpty(), + places = o.arr("places")?.objects()?.mapNotNull { p -> (placeKey(p.str("key")) ?: return@mapNotNull null) to (name(p.str("name")) ?: return@mapNotNull null) }.orEmpty(), + ) + } + + /** `{ lat, lon }` of the commit and of `PUT nodes//geo`; null without a valid point. */ + fun geoBody(at: GeoPoint?): JsonObject? { + if (at == null) return null + val lat = lat(at.lat) ?: return null + val lon = lon(at.lon) ?: return null + if (lat == 0.0 && lon == 0.0) return null + return JsonObject().apply { + addProperty("lat", lat) + addProperty("lon", lon) + } + } + + /** `PUT nodes//geo { none: true }`: looked at, there is no position. */ + fun noGeoBody() = JsonObject().apply { addProperty("none", true) } + + /** A box as Drive takes it: `w,s,e,n` with at most 6 decimals. */ + fun bbox(b: DBbox): String? { + val w = lon(b.west) ?: return null + val e = lon(b.east) ?: return null + val s = lat(b.south) ?: return null + val n = lat(b.north) ?: return null + if (s > n) return null + return listOf(w, s, e, n).joinToString(",") { String.format(Locale.ROOT, "%.6f", it).trimEnd('0').trimEnd('.') } + } + + /** The query of [DPhotoFilter]; null when a value is invalid. */ + fun filterQuery(f: DPhotoFilter): Map? = buildMap { + if (f.place != null) put("place", placeKey(f.place) ?: return null) + if (f.bbox != null) put("bbox", bbox(f.bbox) ?: return null) + if (f.people.isNotEmpty()) put("person", f.people.distinct().take(10).joinToString(",")) + if (f.folder != null) put("folder", f.folder.toString()) + if (f.districts && f.place != null) put("districts", "1") + if (f.withPeople) put("with", "people") + if (f.unlocated) put("unlocated", "1") + if (f.unfacedModel != null) { + put("unfaced", "1") + put("model", model(f.unfacedModel) ?: return null) + } + } + + // ─── people ───────────────────────────────────────────────────── + + /** A box `[x, y, w, h]` (0…1); null when it is not one. */ + fun box(a: JsonArray?): List? { + if (a == null || a.size() != 4) return null + val v = a.mapNotNull { e -> runCatching { e.asDouble }.getOrNull()?.takeIf { it.isFinite() } } + if (v.size != 4 || v[2] <= 0 || v[3] <= 0) return null + return v.map { it.coerceIn(0.0, 1.0) } + } + + fun faceRef(o: JsonObject?): DFaceRef? { + if (o == null) return null + return DFaceRef( + faceId = o.long("faceId"), + nodeId = o.long("nodeId"), + box = box(o.arr("box")) ?: return null, + item = DriveJson.node(o.obj("item")), + score = o.dbl("score"), + ) + } + + private fun contact(o: JsonObject?): DPersonContact? { + if (o == null) return null + val kind = o.str("kind")?.takeIf { it == CONTACT_FAMILY || it == CONTACT_MAIL } ?: return null + val id = o.get("id")?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() }?.take(80) ?: return null + return DPersonContact(kind, id, name(o.str("name"))) + } + + fun person(o: JsonObject?): DPerson? { + if (o == null) return null + return DPerson( + id = o.long("id") ?: return null, + name = name(o.str("name")), + count = o.int("count")?.coerceAtLeast(0) ?: 0, + hidden = o.bool("hidden") == true, + cover = faceRef(o.obj("cover")), + contact = contact(o.obj("contact")), + ) + } + + /** `GET people` → `{ people, progress: { done, total } }`. */ + fun people(o: JsonObject): DPeople? { + val list = o.arr("people") ?: return null + val progress = o.obj("progress") + return DPeople( + people = list.objects().mapNotNull(::person), + done = progress?.int("done")?.coerceAtLeast(0), + total = progress?.int("total")?.coerceAtLeast(0), + ) + } + + private fun counts(a: JsonArray?): List = a?.objects()?.mapNotNull { p -> + DPersonCount(p.long("id") ?: return@mapNotNull null, name(p.str("name")), p.int("count")?.coerceAtLeast(0) ?: 0) + }.orEmpty() + + /** `GET people/` → the person (at the top or as `person`) with `together`, `places`, `uncertain`, `years`. */ + fun personDetail(o: JsonObject): DPersonDetail? { + val p = person(o.obj("person")) ?: person(o) ?: return null + val src = if (o.obj("person") != null && !o.has("together")) o.obj("person")!! else o + val years = src.obj("years") + return DPersonDetail( + person = p, + together = counts(src.arr("together")), + places = src.arr("places")?.objects()?.mapNotNull { pl -> + DPlaceCount(placeKey(pl.str("key")) ?: return@mapNotNull null, name(pl.str("name")) ?: return@mapNotNull null, pl.int("count")?.coerceAtLeast(0) ?: 0) + }.orEmpty(), + uncertain = src.int("uncertain")?.coerceAtLeast(0) ?: 0, + yearFrom = years?.int("from"), + yearTo = years?.int("to"), + ) + } + + /** `PATCH people/` → `{ person, mergedInto }`. */ + fun patched(o: JsonObject): DPersonPatched? { + if (!o.has("person") && !o.has("mergedInto")) return null + return DPersonPatched(person(o.obj("person")), o.long("mergedInto")) + } + + fun personAnswer(o: JsonObject): DPerson? = person(o.obj("person")) + + fun similar(o: JsonObject): List? = o.arr("people")?.objects()?.mapNotNull { p -> + DSimilar(p.long("id") ?: return@mapNotNull null, name(p.str("name")), p.int("count")?.coerceAtLeast(0) ?: 0, p.dbl("score") ?: 0.0) + } + + fun uncertain(o: JsonObject): List? = o.arr("faces")?.objects()?.mapNotNull(::faceRef) + + /** `GET photos/suggest` → people, places and labels (each at most 5). */ + fun suggest(o: JsonObject): DSuggest? { + if (!o.has("people") && !o.has("places") && !o.has("labels")) return null + return DSuggest( + people = counts(o.arr("people")).filter { !it.name.isNullOrBlank() }.take(SUGGEST_MAX), + places = o.arr("places")?.objects()?.mapNotNull { p -> + DSuggestPlace(placeKey(p.str("key")) ?: return@mapNotNull null, name(p.str("name")) ?: return@mapNotNull null, name(p.str("sub")), p.int("count")?.coerceAtLeast(0) ?: 0) + }.orEmpty().take(SUGGEST_MAX), + labels = o.arr("labels")?.objects()?.mapNotNull { l -> + val label = name(l.str("label")) ?: return@mapNotNull null + DSuggestLabel(label, name(l.str("text")) ?: label, l.int("count")?.coerceAtLeast(0) ?: 0) + }.orEmpty().take(SUGGEST_MAX), + ) + } + + /** `{ deleted }` of `DELETE photos/geo` and `PUT photos/settings { people: false }`. */ + fun deleted(o: JsonObject): Int = o.int("deleted")?.coerceAtLeast(0) ?: 0 + + const val CONTACT_FAMILY = "family" + const val CONTACT_MAIL = "contact" + const val SUGGEST_MAX = 5 + /** At most this many faces per photo (`PUT nodes//faces`). */ + const val FACES_MAX = 50 + + /** `PATCH people/`: only what is given; [clearContact] sends `contact: null`, an empty [name] removes it. */ + fun personPatchBody(name: String? = null, hidden: Boolean? = null, contact: DPersonContact? = null, clearContact: Boolean = false) = JsonObject().apply { + if (name != null) addProperty("name", name.trim().take(NAME_MAX)) + if (hidden != null) addProperty("hidden", hidden) + if (clearContact) { + add("contact", JsonNull.INSTANCE) + } else if (contact != null) { + add( + "contact", + JsonObject().apply { + addProperty("kind", contact.kind) + addProperty("id", contact.id) + contact.name?.let { addProperty("name", it.take(NAME_MAX)) } + }, + ) + } + } + + fun mergeBody(into: Long) = JsonObject().apply { addProperty("into", into) } + + fun confirmBody(faceIds: List, yes: Boolean) = JsonObject().apply { + add("faceIds", JsonArray().apply { faceIds.distinct().take(LIST_MAX).forEach { add(it) } }) + addProperty("yes", yes) + } + + fun moveBody(person: Long?) = JsonObject().apply { + if (person == null) add("person", JsonNull.INSTANCE) else addProperty("person", person) + } + + /** `PUT nodes//faces { model, faces }` (at most [FACES_MAX]; `[]`: looked at, none). */ + fun facesBody(model: String, faces: List) = JsonObject().apply { + addProperty("model", model) + add( + "faces", + JsonArray().apply { + faces.take(FACES_MAX).forEach { f -> + add( + JsonObject().apply { + add("box", numbers(f.box)) + f.landmarks?.takeIf { it.size == 10 }?.let { l -> add("landmarks", JsonArray().apply { l.chunked(2).forEach { add(numbers(it)) } }) } + addProperty("score", round(f.score.toDouble())) + addProperty("embedding", f.embedding) + }, + ) + } + }, + ) + } + + private fun round(v: Double): Double = Math.round(v.coerceIn(0.0, 1.0) * 1_000_000.0) / 1_000_000.0 + + private fun numbers(v: List): JsonElement = JsonArray().apply { v.forEach { add(round(it.toDouble())) } } + + /** `PUT photos/settings`: the switches of places and people (only those given). */ + fun settingsBody(keepGeo: Boolean? = null, stripGeoOnShare: Boolean? = null, people: Boolean? = null, peopleKids: Boolean? = null) = JsonObject().apply { + if (keepGeo != null) addProperty("keepGeo", keepGeo) + if (stripGeoOnShare != null) addProperty("stripGeoOnShare", stripGeoOnShare) + if (people != null) addProperty("people", people) + if (peopleKids != null) addProperty("peopleKids", peopleKids) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesLogic.kt new file mode 100644 index 00000000..5641a3ac --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesLogic.kt @@ -0,0 +1,230 @@ +package com.gatecontrol.android.drive + +import java.text.Normalizer +import java.time.Instant +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +/** + * The pure rules of places and people in the photos (Drive 1.16): when the map loads its bundles again, how a place's + * time and days read, the suggestions while typing and the marks of a search, who is on the photos shown, the name + * suggestions from family and contacts, and the order of the people. Nothing here talks to the network or Android. + */ +object DrivePlacesLogic { + + // ─── the map ──────────────────────────────────────────────────── + + /** What the map shows: its box and its (whole) zoom. */ + data class View(val bbox: DBbox, val zoom: Int) + + /** What was loaded: the box asked for (larger than the view) at a zoom. */ + data class Loaded(val bbox: DBbox, val zoom: Int) + + /** Web Mercator stops here; osmdroid gives more at the poles. */ + const val MAX_LAT = 85.0 + + /** A box valid for Drive: latitudes within ±85, longitudes within ±180 (a box over the date line: the whole width). */ + fun clamp(b: DBbox): DBbox { + val south = b.south.coerceIn(-MAX_LAT, MAX_LAT) + val north = b.north.coerceIn(-MAX_LAT, MAX_LAT) + val wraps = b.west > b.east || b.east - b.west >= 360.0 + val west = if (wraps) -180.0 else b.west.coerceIn(-180.0, 180.0) + val east = if (wraps) 180.0 else b.east.coerceIn(-180.0, 180.0) + return DBbox(west, min(south, north), east, max(south, north)) + } + + /** [b] grown by [factor] of its size on every side (then clamped): the bundles just outside the view come along. */ + fun padded(b: DBbox, factor: Double = 0.5): DBbox { + val c = clamp(b) + val dx = (c.east - c.west) * factor + val dy = (c.north - c.south) * factor + return clamp(DBbox(c.west - dx, c.south - dy, c.east + dx, c.north + dy)) + } + + fun contains(outer: DBbox, inner: DBbox): Boolean = + inner.west >= outer.west && inner.east <= outer.east && inner.south >= outer.south && inner.north <= outer.north + + /** + * Whether the bundles have to come again for [view]: nothing loaded yet, another zoom, or the view left what was + * loaded. Moving within the padded box loads nothing (the screen throttles the rest by time). + */ + fun needsLoad(loaded: Loaded?, view: View): Boolean { + if (loaded == null) return true + if (loaded.zoom != view.zoom) return true + return !contains(loaded.bbox, clamp(view.bbox)) + } + + /** The box around [points] (lat, lon), a little larger; null for none. */ + fun around(points: List>, margin: Double = 0.15): DBbox? { + if (points.isEmpty()) return null + val s = points.minOf { it.first } + val n = points.maxOf { it.first } + val w = points.minOf { it.second } + val e = points.maxOf { it.second } + val dy = max((n - s) * margin, 0.01) + val dx = max((e - w) * margin, 0.01) + return clamp(DBbox(w - dx, s - dy, e + dx, n + dy)) + } + + // ─── time and days ────────────────────────────────────────────── + + /** The local day of a photo (taken, else changed); null without a date. */ + fun dayOf(node: DNode, zone: ZoneId): LocalDate? = (node.takenAt ?: node.mtime)?.let { iso -> + runCatching { Instant.parse(iso).atZone(zone).toLocalDate() }.getOrNull() + ?: runCatching { LocalDate.parse(iso.take(10)) }.getOrNull() + } + + /** How many different days the photos are of. */ + fun days(items: List, zone: ZoneId): Int = items.mapNotNull { dayOf(it, zone) }.distinct().size + + /** A day of a place's photos: its photos and the district most of them are in. */ + data class Day(val date: LocalDate?, val items: List, val district: String?) + + /** The photos by day (newest first, as they come), each with its most frequent district. */ + fun byDay(items: List, zone: ZoneId): List { + val out = ArrayList() + var current: LocalDate? = null + var list = ArrayList() + var started = false + fun close() { + if (!started) return + out += Day(current, list, district(list)) + } + for (n in items) { + val d = dayOf(n, zone) + if (!started || d != current) { + close() + current = d + list = ArrayList() + started = true + } + list += n + } + close() + return out + } + + /** The district most of [items] are in; null when none says. */ + fun district(items: List): String? = + items.mapNotNull { it.geo?.place?.district }.groupingBy { it }.eachCount().maxByOrNull { it.value }?.key + + /** "Juli 2025", "März – Juli 2025", "2014 – 2026" of two ISO times; null without them. */ + fun span(from: String?, to: String?, locale: Locale, zone: ZoneId = ZoneId.systemDefault()): String? { + fun ym(s: String?): YearMonth? = s?.let { iso -> + runCatching { YearMonth.from(Instant.parse(iso).atZone(zone)) }.getOrNull() ?: runCatching { YearMonth.parse(iso.take(7)) }.getOrNull() + } + val a = ym(from) ?: ym(to) ?: return null + val b = ym(to) ?: a + val first = minOf(a, b) + val last = maxOf(a, b) + val month = DateTimeFormatter.ofPattern("LLLL yyyy", locale) + return when { + first == last -> first.format(month) + first.year == last.year -> first.month.getDisplayName(TextStyle.FULL_STANDALONE, locale) + " – " + last.format(month) + else -> "${first.year} – ${last.year}" + } + } + + /** "2016 – 2026", "2026"; null without years. */ + fun years(from: Int?, to: Int?): String? = when { + from == null && to == null -> null + from == null || to == null || from == to -> (from ?: to).toString() + else -> "${min(from, to)} – ${max(from, to)}" + } + + // ─── people on the photos ─────────────────────────────────────── + + /** Who is on [items] how often (`with=people`), most first. */ + fun peopleCounts(items: List): List> = + items.flatMap { it.people.orEmpty() }.groupingBy { it }.eachCount().entries.sortedByDescending { it.value }.map { it.key to it.value } + + /** [id] added to or taken from the people filter. */ + fun toggle(list: List, id: Long): List = if (id in list) list - id else list + id + + /** The people as the "Personen" page orders them: named (most photos first), without a name, hidden. */ + data class Groups(val named: List, val unnamed: List, val hidden: List) + + fun groups(people: List): Groups = Groups( + named = people.filter { !it.hidden && it.named }.sortedWith(compareByDescending { it.count }.thenBy { it.name?.lowercase() }), + unnamed = people.filter { !it.hidden && !it.named }.sortedByDescending { it.count }, + hidden = people.filter { it.hidden }, + ) + + /** The bar on the Photos page: the named people, then "Wer ist das?" when someone is still without a name. */ + fun bar(people: List, max: Int = 12): Pair, DPerson?> { + val g = groups(people) + return g.named.take(max) to g.unnamed.firstOrNull() + } + + /** One or two initials of a name ("Oma Gerda" → "OG"). */ + fun initials(name: String?): String { + val words = name.orEmpty().trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + return words.take(2).joinToString("") { it.substring(0, it.offsetByCodePoints(0, 1)).uppercase() }.ifEmpty { "?" } + } + + // ─── search ───────────────────────────────────────────────────── + + /** Lower case without accents, for comparing names ("Köln" ~ "koln"). */ + fun fold(s: String): String = + Normalizer.normalize(s, Normalizer.Form.NFD).replace(Regex("\\p{M}+"), "").lowercase(Locale.ROOT).trim() + + /** A suggestion while typing: a person, a place or what is in the photos. */ + data class Suggestion(val kind: Kind, val id: String, val name: String, val sub: String?, val count: Int) { + enum class Kind { PERSON, PLACE, LABEL } + } + + /** Drive's suggestions in the order of the list: people, places, what is in the photos. */ + fun suggestions(s: DSuggest?): List { + if (s == null) return emptyList() + return s.people.mapNotNull { p -> p.name?.let { Suggestion(Suggestion.Kind.PERSON, p.id.toString(), it, null, p.count) } } + + s.places.map { Suggestion(Suggestion.Kind.PLACE, it.key, it.name, it.sub, it.count) } + + s.labels.map { Suggestion(Suggestion.Kind.LABEL, it.label, it.text, null, it.count) } + } + + /** The search with [word] instead of the last word typed (a picked suggestion completes it). */ + fun complete(q: String, word: String): String { + val words = q.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + val keep = if (q.endsWith(" ")) words else words.dropLast(1) + return (keep + word.trim()).joinToString(" ").take(DriveJson.PHOTO_Q_MAX) + } + + /** A mark of a search: a person or place Drive took words as (shown as a chip). */ + data class Mark(val person: Long?, val place: String?, val name: String) + + fun marks(m: DSearchMarks): List = + m.people.map { Mark(it.first, null, it.second) } + m.places.map { Mark(null, it.first, it.second) } + + /** The search without the words of a mark (its chip's ✕). */ + fun withoutMark(q: String, mark: Mark): String { + val drop = fold(mark.name).split(Regex("\\s+")).toSet() + return q.trim().split(Regex("\\s+")).filter { it.isNotEmpty() && fold(it) !in drop }.joinToString(" ") + } + + // ─── naming ───────────────────────────────────────────────────── + + /** A name to offer while naming someone: a family member or a contact, and who already has it (a named person). */ + data class NameOption(val kind: String, val id: String, val name: String, val sub: String?, val namedCount: Int? = null) + + /** + * Up to [max] names for [typed]: family first, then contacts; each name once, matched at a word's start without + * accents (all of them while nothing is typed). [named] are the names people already have (lower case → photos). + */ + fun nameOptions(typed: String, family: List, contacts: List, named: Map, max: Int = 3): List { + val q = fold(typed) + fun hits(name: String): Boolean { + if (q.isEmpty()) return true + val f = fold(name) + return f.startsWith(q) || f.split(Regex("\\s+")).any { it.startsWith(q) } + } + val seen = HashSet() + return (family + contacts).filter { it.name.isNotBlank() && hits(it.name) && seen.add(fold(it.name)) } + .take(max) + .map { o -> o.copy(namedCount = named[fold(o.name)]) } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesModels.kt new file mode 100644 index 00000000..bedd4ee6 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesModels.kt @@ -0,0 +1,116 @@ +package com.gatecontrol.android.drive + +// ─── Drive 1.16: places and people in the photos (features `photoPlaces`, `photoPeople`) ── + +/** A place as Drive names it offline (GeoNames): [key] stays the same for a town, [country] is ISO-2. */ +data class DPlace( + val key: String, + val name: String, + val district: String? = null, + val region: String? = null, + val country: String? = null, +) + +/** Where a photo was taken (`geo` of a photo item or node); [place] is null while Drive has not named it. */ +data class DGeo(val lat: Double, val lon: Double, val place: DPlace? = null) + +/** `GET photos/places`: a place with how many photos, where, from when to when, and a cover photo. */ +data class DPlaceSummary( + val key: String, + val name: String, + val region: String?, + val country: String?, + val count: Int, + val lat: Double, + val lon: Double, + /** ISO 8601 of the first and the last photo (null: not said). */ + val from: String?, + val to: String?, + val cover: DNode?, +) + +/** `GET photos/map`: a bundle of photos on the map; [key] when it is exactly one place. */ +data class DCluster(val lat: Double, val lon: Double, val count: Int, val key: String?, val cover: DNode?) + +/** `districts` of `GET photos?place=&districts=1`: the district names of a place with their counts. */ +data class DDistrict(val name: String, val count: Int) + +/** A face on a photo: its box (0…1 of the upright picture: x, y, w, h) and the photo item to cut it from. */ +data class DFaceRef(val faceId: Long?, val nodeId: Long?, val box: List, val item: DNode?, val score: Double? = null) + +/** What a person is linked to (a family member or a mail contact). */ +data class DPersonContact(val kind: String, val id: String, val name: String?) + +/** A person of `GET people` (only the viewer's own). */ +data class DPerson( + val id: Long, + val name: String?, + val count: Int, + val hidden: Boolean, + val cover: DFaceRef?, + val contact: DPersonContact?, +) { + val named: Boolean get() = !name.isNullOrBlank() +} + +/** `GET people`: the people and how far the phones have looked ([done] of [total] pictures). */ +data class DPeople(val people: List, val done: Int?, val total: Int?) + +/** Someone on photos together with a person, a person's place. */ +data class DPersonCount(val id: Long, val name: String?, val count: Int) + +data class DPlaceCount(val key: String, val name: String, val count: Int) + +/** `GET people/`: the person, who is on photos with them, where, unsure faces, the years. */ +data class DPersonDetail( + val person: DPerson, + val together: List, + val places: List, + val uncertain: Int, + val yearFrom: Int?, + val yearTo: Int?, +) + +/** `PATCH people/`: the person now and, when the name was taken, the one it went into. */ +data class DPersonPatched(val person: DPerson?, val mergedInto: Long?) + +/** `GET people//similar`: a person that may be the same, with how near. */ +data class DSimilar(val id: Long, val name: String?, val count: Int, val score: Double) + +/** `GET photos/suggest`: what the typed words may mean (people, places, what is in the photos). */ +data class DSuggest( + val people: List = emptyList(), + val places: List = emptyList(), + val labels: List = emptyList(), +) { + val empty: Boolean get() = people.isEmpty() && places.isEmpty() && labels.isEmpty() +} + +data class DSuggestPlace(val key: String, val name: String, val sub: String?, val count: Int) + +data class DSuggestLabel(val label: String, val text: String, val count: Int) + +/** `search` of `GET photos?q=`: the people and places the words were taken as (the marks). */ +data class DSearchMarks(val people: List> = emptyList(), val places: List> = emptyList()) + +/** A face found on this phone for `PUT nodes//faces`: [box] and [landmarks] 0…1 of the upright picture. */ +data class DFaceUpload(val box: List, val landmarks: List?, val score: Float, val embedding: String) + +/** A box on the map: west, south, east, north (WGS84). */ +data class DBbox(val west: Double, val south: Double, val east: Double, val north: Double) + +/** + * The new filters of `GET photos`, `photos/months`, `photos/places` and `photos/map` (Drive 1.16): a [place], a [bbox], + * photos with all of [people], a [folder]; [districts] adds a place's districts, [withPeople] the people on each item; + * [unlocated] / [unfacedModel] list what the phones still have to look at. + */ +data class DPhotoFilter( + val place: String? = null, + val bbox: DBbox? = null, + val people: List = emptyList(), + val folder: Long? = null, + val districts: Boolean = false, + val withPeople: Boolean = false, + val unlocated: Boolean = false, + val unfacedModel: String? = null, +) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesPrefs.kt b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesPrefs.kt new file mode 100644 index 00000000..d90f0c1e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DrivePlacesPrefs.kt @@ -0,0 +1,189 @@ +package com.gatecontrol.android.drive + +import android.content.Context +import androidx.core.content.edit +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.familie.PluginDirectory +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** The switches of places and people: the person's on the server, and this phone's "Nur beim Laden". */ +data class PlacesSettings( + /** The apps send where photos were taken (server, default on). */ + val keepGeo: Boolean = true, + /** Links and GateDrop without GPS data; null: the server does not offer it (the switch is hidden). */ + val stripGeoOnShare: Boolean? = null, + /** Faces are recognised (server, default off). */ + val people: Boolean = false, + /** The children's folders too (server, default off). */ + val peopleKids: Boolean = false, + /** This phone recognises faces only while charging (default on). */ + val chargingOnly: Boolean = true, + /** The server's switches were read at least once for this login. */ + val known: Boolean = false, +) + +/** + * Places and people on this phone (Drive 1.16): the person's switches as the server last said them (for the backup and + * the background jobs, which must not ask every time), the device's "Nur beim Laden", the photos the backup just sent + * whose faces are still to be found, and how far "Orte nachtragen" got. Kept per login. + */ +@Singleton +class DrivePlacesPrefs @Inject constructor( + @ApplicationContext context: Context, + private val setup: SetupRepository, +) { + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private fun owner() = PluginDirectory.ownerKey(setup.getServerUrl(), setup.getApiToken()) + + private fun mine() { + if (prefs.getString(KEY_OWNER, null) == owner()) return + prefs.edit { + clear() + putString(KEY_OWNER, owner()) + } + } + + private val _settings = MutableStateFlow(read()) + val settings: StateFlow = _settings.asStateFlow() + + /** "Orte nachtragen": [running], photos looked at, places found. */ + data class GeoProgress(val running: Boolean = false, val looked: Int = 0, val located: Int = 0, val lastRunAt: Long = 0L) + + private val _geo = MutableStateFlow(readGeo()) + val geo: StateFlow = _geo.asStateFlow() + + private fun read(): PlacesSettings { + if (prefs.getString(KEY_OWNER, null) != owner()) return PlacesSettings() + return PlacesSettings( + keepGeo = prefs.getBoolean(KEY_KEEP_GEO, true), + stripGeoOnShare = if (prefs.contains(KEY_STRIP)) prefs.getBoolean(KEY_STRIP, true) else null, + people = prefs.getBoolean(KEY_PEOPLE, false), + peopleKids = prefs.getBoolean(KEY_KIDS, false), + chargingOnly = prefs.getBoolean(KEY_CHARGING, true), + known = prefs.getBoolean(KEY_KNOWN, false), + ) + } + + private fun readGeo(): GeoProgress = if (prefs.getString(KEY_OWNER, null) != owner()) GeoProgress() else GeoProgress( + looked = prefs.getInt(KEY_GEO_LOOKED, 0), + located = prefs.getInt(KEY_GEO_LOCATED, 0), + lastRunAt = prefs.getLong(KEY_GEO_LAST, 0L), + ) + + /** Another login since: the values of this one. */ + fun reload() { + _settings.value = read() + _geo.value = readGeo() + } + + /** The server's answer of `photos/settings` (only the fields it has). */ + fun took(s: DPhotoSettings) { + mine() + prefs.edit { + s.keepGeo?.let { putBoolean(KEY_KEEP_GEO, it) } + if (s.stripGeoOnShare != null) putBoolean(KEY_STRIP, s.stripGeoOnShare) else remove(KEY_STRIP) + s.people?.let { putBoolean(KEY_PEOPLE, it) } + s.peopleKids?.let { putBoolean(KEY_KIDS, it) } + putBoolean(KEY_KNOWN, true) + if (s.people == false) remove(KEY_PENDING) + } + _settings.value = read() + } + + fun setChargingOnly(on: Boolean) { + mine() + prefs.edit { putBoolean(KEY_CHARGING, on) } + _settings.value = read() + } + + // ─── faces of photos the backup just sent ─────────────────────── + + /** A photo just backed up: Drive's node and the phone's media store id (its original is here). */ + data class Pending(val nodeId: Long, val mediaId: Long) + + fun addPending(nodeId: Long, mediaId: Long) { + mine() + val list = pending().filter { it.nodeId != nodeId } + Pending(nodeId, mediaId) + prefs.edit { putString(KEY_PENDING, list.takeLast(PENDING_MAX).joinToString(";") { "${it.nodeId}:${it.mediaId}" }) } + } + + fun pending(): List { + if (prefs.getString(KEY_OWNER, null) != owner()) return emptyList() + return prefs.getString(KEY_PENDING, null).orEmpty().split(';').mapNotNull { e -> + val (n, m) = e.split(':').takeIf { it.size == 2 } ?: return@mapNotNull null + Pending(n.toLongOrNull() ?: return@mapNotNull null, m.toLongOrNull() ?: return@mapNotNull null) + } + } + + fun donePending(nodeId: Long) { + val list = pending().filter { it.nodeId != nodeId } + prefs.edit { if (list.isEmpty()) remove(KEY_PENDING) else putString(KEY_PENDING, list.joinToString(";") { "${it.nodeId}:${it.mediaId}" }) } + } + + // ─── the backfill's place in Drive's lists ─────────────────────── + + fun cursor(key: String): String? = if (prefs.getString(KEY_OWNER, null) == owner()) prefs.getString(key, null) else null + + fun setCursor(key: String, value: String?) { + mine() + prefs.edit { if (value == null) remove(key) else putString(key, value) } + } + + fun passAt(key: String): Long = if (prefs.getString(KEY_OWNER, null) == owner()) prefs.getLong("$key.at", 0L) else 0L + + fun setPassAt(key: String, at: Long) { + mine() + prefs.edit { putLong("$key.at", at) } + } + + // ─── "Orte nachtragen" ────────────────────────────────────────── + + /** A run of "Orte nachtragen" starts or ends; [fresh]: started by the person (counts and place in the list anew). */ + fun geoRunning(on: Boolean, fresh: Boolean = false) { + if (on) { + mine() + if (fresh) { + prefs.edit { + putInt(KEY_GEO_LOOKED, 0) + putInt(KEY_GEO_LOCATED, 0) + remove(CURSOR_GEO) + } + } + } else { + prefs.edit { putLong(KEY_GEO_LAST, System.currentTimeMillis()) } + } + _geo.value = readGeo().copy(running = on) + } + + fun geoLooked(located: Boolean) { + prefs.edit { + putInt(KEY_GEO_LOOKED, prefs.getInt(KEY_GEO_LOOKED, 0) + 1) + if (located) putInt(KEY_GEO_LOCATED, prefs.getInt(KEY_GEO_LOCATED, 0) + 1) + } + _geo.value = readGeo().copy(running = _geo.value.running) + } + + companion object { + private const val PREFS = "drive_places" + private const val KEY_OWNER = "owner" + private const val KEY_KEEP_GEO = "keep_geo" + private const val KEY_STRIP = "strip_geo" + private const val KEY_PEOPLE = "people" + private const val KEY_KIDS = "people_kids" + private const val KEY_CHARGING = "charging" + private const val KEY_KNOWN = "known" + private const val KEY_PENDING = "pending" + private const val KEY_GEO_LOOKED = "geo_looked" + private const val KEY_GEO_LOCATED = "geo_located" + private const val KEY_GEO_LAST = "geo_last" + const val CURSOR_FACES = "faces_cursor" + const val CURSOR_GEO = "geo_cursor" + private const val PENDING_MAX = 500 + } +} 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 68a9b384..d8e40e82 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 @@ -56,6 +56,7 @@ class DriveBackup @Inject constructor( private val work: com.gatecontrol.android.drive.work.DriveWorkPhotos, private val wifi: com.gatecontrol.android.drive.work.DriveWorkWifi, private val labels: DrivePhotoLabels, + private val places: com.gatecontrol.android.drive.DrivePlacesPrefs, ) { /** 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) @@ -221,6 +222,10 @@ class DriveBackup @Inject constructor( work.refreshSettings() runCatching { wifi.sample() } } + // Drive 1.16: whether places go with the photos and faces are found ("Aufnahmeort mitsichern", "Personen erkennen") + if (live.supports(DMe.FEATURE_PHOTO_PLACES) || live.supports(DMe.FEATURE_PHOTO_PEOPLE)) { + api.photoSettings().valueOrNull()?.let(places::took) + } var done = 0 while (true) { val mark = mark() ?: DriveBackupMark.ALL @@ -259,6 +264,8 @@ class DriveBackup @Inject constructor( _stats.value = readStats() // work photos apart: learn from the work folder, then the marked photos again (in the background) work.afterRun() + // Drive 1.16: the faces of the photos just sent (under its own conditions: WLAN, charging) + if (places.pending().isNotEmpty()) com.gatecontrol.android.drive.faces.DrivePeopleWork.request(context) return Outcome.DONE } @@ -359,8 +366,16 @@ class DriveBackup @Inject constructor( previews.uploadOwn(media.value, item.uri, item.mime) // 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)) { + // Drive 1.16 (`photoPlaces`): where it was taken, unless the person keeps places off the server + val geo = shot.at?.takeIf { !item.video && places.settings.value.keepGeo && live.supports(DMe.FEATURE_PHOTO_PLACES) } + return when (val r = api.commit(media.value, folder, item.name, item.mtime, DriveLogic.ON_CONFLICT_RENAME, takenAt = takenAt, geo = geo)) { is DResult.Ok -> { + // Drive 1.16 (`photoPeople`): its faces are found later from the original here + // (a child's folder only with "Kinderordner einbeziehen") + if (!item.video && places.settings.value.people && live.supports(DMe.FEATURE_PHOTO_PEOPLE)) { + val child = live.current()?.areas?.firstOrNull { it.id == r.value.first.areaId }?.kind == "child" + if (!child || places.settings.value.peopleKids) places.addPending(r.value.first.id, item.id) + } placed?.let { p -> runCatching { work.afterBackup(r.value.first, p, item.id, item.video) }.onFailure { if (it is CancellationException) throw it } } // Drive 1.14 (`photoSearch`): what is in the photo, found on the phone (the thumbnail, no location data) val plain = ContentUris.withAppendedId(if (item.video) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI, item.id) diff --git a/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceEngine.kt b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceEngine.kt new file mode 100644 index 00000000..1f4b6fa9 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceEngine.kt @@ -0,0 +1,155 @@ +package com.gatecontrol.android.drive.faces + +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import androidx.core.graphics.scale +import com.gatecontrol.android.drive.DFaceUpload +import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber +import java.io.Closeable +import java.nio.FloatBuffer +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Faces in a picture, on the phone (Drive 1.16, `photoPeople`): YuNet finds them with their 5 points, SFace describes + * each as 128 numbers (model `sface-2021dec`, ONNX Runtime on the CPU; YuNet ships in the app's assets, SFace is + * loaded when switched on — [DriveFaceModel]; see assets/faces/NOTICE.txt). Only the numbers and the boxes go to the person's own server, never a picture. + * [open] loads both models for one run (about 40 MB): close it when the run is done. + */ +@Singleton +class DriveFaceEngine @Inject constructor( + @ApplicationContext private val context: Context, + private val model: DriveFaceModel, +) { + + /** Both models, loaded. Not thread safe: one picture at a time. */ + inner class Models internal constructor(private val env: OrtEnvironment, private val detector: OrtSession, private val recognizer: OrtSession) : Closeable { + + /** The faces of [bitmap] (upright), at most [DriveFacesLogic.MAX_FACES]; box and points 0…1 of it. */ + fun faces(bitmap: Bitmap): List { + val w = bitmap.width + val h = bitmap.height + if (w < 16 || h < 16) return emptyList() + val pixels = IntArray(w * h) + bitmap.getPixels(pixels, 0, w, 0, 0, w, h) + val scale = DriveFacesLogic.detectScale(w, h) + val sw = (w * scale).roundToInt().coerceIn(1, DriveFacesLogic.DETECT_SIZE) + val sh = (h * scale).roundToInt().coerceIn(1, DriveFacesLogic.DETECT_SIZE) + val small = if (sw == w && sh == h) pixels else { + val b = bitmap.scale(sw, sh) + IntArray(sw * sh).also { b.getPixels(it, 0, sw, 0, 0, sw, sh); if (b !== bitmap) b.recycle() } + } + val input = DriveFacesLogic.detectInput(small, sw, sh) + val n = DriveFacesLogic.DETECT_SIZE.toLong() + val found = OnnxTensor.createTensor(env, FloatBuffer.wrap(input), longArrayOf(1, 3, n, n)).use { t -> + detector.run(mapOf(detector.inputNames.first() to t)).use { r -> + val outputs = HashMap() + for (name in OUTPUTS) { + val v = r.get(name).orElse(null) as? OnnxTensor ?: continue + val fb = v.floatBuffer + outputs[name] = FloatArray(fb.remaining()).also { fb.get(it) } + } + DriveFacesLogic.decode({ outputs[it] }) + } + } + // back to the picture's pixels (sx and sy differ a little by rounding) + val fx = w.toFloat() / sw + val fy = h.toFloat() / sh + return found.asSequence() + .map { f -> DriveFacesLogic.Face(f.x * fx, f.y * fy, f.w * fx, f.h * fy, FloatArray(10) { i -> f.points[i] * if (i % 2 == 0) fx else fy }, f.score) } + .filter { DriveFacesLogic.usable(it, w, h) } + .take(DriveFacesLogic.MAX_FACES) + .mapNotNull { f -> + val vector = runCatching { feature(pixels, w, h, f.points) }.onFailure { Timber.d("Drive faces: no feature (%s)", it.javaClass.simpleName) }.getOrNull() + ?: return@mapNotNull null + val (box, points) = DriveFacesLogic.normalized(f, w, h) + DFaceUpload(box, points, f.score.coerceIn(0f, 1f), DriveFacesLogic.encode(vector)) + } + .toList() + } + + /** SFace's 128 numbers of the face at [points] in [pixels], normalised. */ + fun feature(pixels: IntArray, w: Int, h: Int, points: FloatArray): FloatArray { + val aligned = DriveFacesLogic.warp(pixels, w, h, DriveFacesLogic.similarity(points)) + val input = DriveFacesLogic.featureInput(aligned) + val s = DriveFacesLogic.ALIGNED.toLong() + return OnnxTensor.createTensor(env, FloatBuffer.wrap(input), longArrayOf(1, 3, s, s)).use { t -> + recognizer.run(mapOf(INPUT_FEATURE to t)).use { r -> + val fb = (r.get(0) as OnnxTensor).floatBuffer + val v = FloatArray(fb.remaining()).also { fb.get(it) } + require(v.size == DriveFacesLogic.FEATURES) { "feature size ${v.size}" } + DriveFacesLogic.l2(v) + } + } + } + + override fun close() { + runCatching { detector.close() } + runCatching { recognizer.close() } + } + } + + /** Both models for a run; null when they cannot be loaded (SFace not loaded yet, too little memory). */ + fun open(): Models? = if (!model.ready()) null else try { + val env = OrtEnvironment.getEnvironment() + val options = OrtSession.SessionOptions().apply { + setIntraOpNumThreads(THREADS) + setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) + } + val detector = env.createSession(asset(DETECTOR), options) + val recognizer = try { + env.createSession(model.file.absolutePath, options) + } catch (e: Exception) { + detector.close() + throw e + } + Models(env, detector, recognizer) + } catch (e: Exception) { + Timber.w("Drive faces: models not loaded (%s)", e.javaClass.simpleName) + null + } catch (e: OutOfMemoryError) { + Timber.w("Drive faces: too little memory for the models") + null + } catch (e: UnsatisfiedLinkError) { + Timber.w("Drive faces: no runtime on this phone") + null + } + + private fun asset(name: String): ByteArray = context.assets.open("faces/$name").use { it.readBytes() } + + /** A photo on the phone, upright, at most [MAX_SIDE] px on its long side; null when it cannot be read. */ + fun decode(uri: Uri): Bitmap? = try { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { decoder, info, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = false + val long = max(info.size.width, info.size.height) + if (long > MAX_SIDE) { + val f = MAX_SIDE.toFloat() / long + decoder.setTargetSize((info.size.width * f).roundToInt().coerceAtLeast(1), (info.size.height * f).roundToInt().coerceAtLeast(1)) + } + }.let { b -> if (b.config == Bitmap.Config.ARGB_8888) b else b.copy(Bitmap.Config.ARGB_8888, false).also { b.recycle() } } + } catch (e: Exception) { + Timber.d("Drive faces: picture not read (%s)", e.javaClass.simpleName) + null + } catch (e: OutOfMemoryError) { + Timber.w("Drive faces: picture too large") + null + } + + companion object { + const val DETECTOR = "face_detection_yunet_2023mar.onnx" + private const val INPUT_FEATURE = "data" + private const val THREADS = 2 + /** Originals are read at most this large (long side). */ + const val MAX_SIDE = 2048 + private val OUTPUTS = listOf(8, 16, 32).flatMap { s -> listOf("cls_$s", "obj_$s", "bbox_$s", "kps_$s") } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceModel.kt b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceModel.kt new file mode 100644 index 00000000..fb0318a5 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceModel.kt @@ -0,0 +1,189 @@ +package com.gatecontrol.android.drive.faces + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.gatecontrol.android.BuildConfig +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.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.io.File +import java.io.InputStream +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * SFace, the face feature model (39 MB), is not in the app: it is loaded once when the person switches on "Personen + * erkennen" — from this app version's GitHub release, else from OpenCV Zoo on GitHub, else from Hugging Face; only the + * file is fetched (the server sees the phone's address and the usual connection data, no photo). Checked against its + * SHA-256, kept in `noBackupFilesDir/faces/`, resumed after an interruption; in WLAN unless the person says otherwise. + * Switching off deletes it ([delete]). + */ +@Singleton +class DriveFaceModel @Inject constructor(@ApplicationContext private val context: Context) { + + private val fetcher = DriveFaceModelFetcher(File(context.noBackupFilesDir, "faces"), FILE, SHA256, SIZE, ::open) + + private val client by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + } + + val file: File get() = fetcher.file + + fun ready(): Boolean = fetcher.ready() + + fun delete() = fetcher.delete() + + /** Loads the model (blocking IO in the caller's job); [progress] hears bytes so far of [SIZE]. */ + suspend fun fetch(progress: (Long, Long) -> Unit, cancelled: () -> Boolean): DriveFaceModelFetcher.Result = + withContext(Dispatchers.IO) { fetcher.fetch(sources(BuildConfig.VERSION_NAME), progress, cancelled) } + + private fun open(url: String, offset: Long): DriveFaceModelFetcher.Response? { + val request = Request.Builder().url(url).apply { if (offset > 0) header("Range", "bytes=$offset-") }.build() + val response = client.newCall(request).execute() + val body = response.body + return object : DriveFaceModelFetcher.Response { + override val code: Int = response.code + override val body: InputStream = body.byteStream() + override fun close() = response.close() + } + } + + /** Where the model is, in this order (the app's own release first). */ + companion object { + const val FILE = "face_recognition_sface_2021dec.onnx" + const val SHA256 = "0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79" + const val SIZE = 38_696_353L + + fun sources(version: String): List = listOf( + "https://github.com/CallMeTechie/GateControl-Android-Client/releases/download/v$version/$FILE", + "https://media.githubusercontent.com/media/opencv/opencv_zoo/main/models/face_recognition_sface/$FILE", + "https://huggingface.co/opencv/face_recognition_sface/resolve/main/$FILE", + ) + } +} + +/** How far the model is, for the settings and the people page. */ +data class FaceModelState(val phase: Phase, val done: Long = 0L, val total: Long = DriveFaceModel.SIZE, val error: String? = null) { + enum class Phase { + /** Not here, nothing planned. */ + MISSING, + /** Planned, waiting for WLAN (or another condition). */ + WAITING, + LOADING, + READY, + FAILED, + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DriveFaceModelEntryPoint { + fun driveFaceModel(): DriveFaceModel +} + +/** Loads the model; then the faces' job starts. */ +class DriveFaceModelWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val model = EntryPointAccessors.fromApplication(applicationContext, DriveFaceModelEntryPoint::class.java).driveFaceModel() + var shown = -1L + val r = model.fetch( + progress = { done, total -> + // about every megabyte + if (done - shown >= STEP || done == total) { + shown = done + setProgressAsync(workDataOf(KEY_DONE to done, KEY_TOTAL to total)) + } + }, + cancelled = { isStopped }, + ) + return when (r) { + is DriveFaceModelFetcher.Result.Done -> Result.success().also { DrivePeopleWork.restart(applicationContext) } + is DriveFaceModelFetcher.Result.Failed -> when (r.code) { + DriveFaceModelFetcher.OFFLINE, DriveFaceModelFetcher.CANCELLED -> if (runAttemptCount < 8) Result.retry() else Result.failure(workDataOf(KEY_ERROR to r.code)) + else -> { + Timber.w("Drive faces: model not loaded (%s)", r.code) + Result.failure(workDataOf(KEY_ERROR to r.code)) + } + } + } + } + + companion object { + const val KEY_DONE = "done" + const val KEY_TOTAL = "total" + const val KEY_ERROR = "error" + private const val STEP = 1_000_000L + } +} + +/** Scheduling of the model's download: in WLAN, or — after the person agreed — on any network. */ +object DriveFaceModelWork { + private const val RUN = "drive-face-model" + + private fun model(context: Context) = EntryPointAccessors.fromApplication(context, DriveFaceModelEntryPoint::class.java).driveFaceModel() + + /** Load it unless it is here; [mobile]: also over mobile data (the person said so). */ + fun start(context: Context, mobile: Boolean = false) { + runCatching { + if (model(context).ready()) return + val request = OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiredNetworkType(if (mobile) NetworkType.CONNECTED else NetworkType.UNMETERED).setRequiresStorageNotLow(true).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .build() + // a retry or "über mobile Daten" replaces what waits; a running download is kept + WorkManager.getInstance(context).enqueueUniqueWork(RUN, if (mobile) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, request) + }.onFailure { Timber.w("Drive faces: model not scheduled (%s)", it.javaClass.simpleName) } + } + + /** "Erneut versuchen": anew, in WLAN (a failed job is done, so a new one starts). */ + fun retry(context: Context) = start(context) + + /** Switched off: no download, and the file goes. */ + fun cancelAndDelete(context: Context) { + runCatching { WorkManager.getInstance(context).cancelUniqueWork(RUN) } + runCatching { model(context).delete() } + } + + /** The model's state from the file and the download's job. */ + fun state(context: Context): Flow = + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(RUN).map { infos -> + stateOf(model(context).ready(), infos.lastOrNull()) + } + + internal fun stateOf(ready: Boolean, info: WorkInfo?): FaceModelState = when { + ready -> FaceModelState(FaceModelState.Phase.READY, DriveFaceModel.SIZE) + info == null -> FaceModelState(FaceModelState.Phase.MISSING) + info.state == WorkInfo.State.RUNNING -> FaceModelState( + FaceModelState.Phase.LOADING, + info.progress.getLong(DriveFaceModelWorker.KEY_DONE, 0L), + info.progress.getLong(DriveFaceModelWorker.KEY_TOTAL, DriveFaceModel.SIZE).takeIf { it > 0 } ?: DriveFaceModel.SIZE, + ) + info.state == WorkInfo.State.ENQUEUED || info.state == WorkInfo.State.BLOCKED -> FaceModelState(FaceModelState.Phase.WAITING) + info.state == WorkInfo.State.FAILED -> FaceModelState(FaceModelState.Phase.FAILED, error = info.outputData.getString(DriveFaceModelWorker.KEY_ERROR)) + else -> FaceModelState(FaceModelState.Phase.MISSING) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceModelFetcher.kt b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceModelFetcher.kt new file mode 100644 index 00000000..eb1e2205 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFaceModelFetcher.kt @@ -0,0 +1,136 @@ +package com.gatecontrol.android.drive.faces + +import java.io.Closeable +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest + +/** + * Fetches one model file into [dir] (Drive 1.16, SFace): the [sources] in order, each answer checked against [sha256] + * and [size] — a wrong one is thrown away and the next source tried. The bytes go into `.part` (an interrupted + * download goes on from there with a `Range` request to the same source, else starts anew) and become `` by a + * rename only once complete and right. No Android here (unit tests); [open] makes the request. + */ +class DriveFaceModelFetcher( + private val dir: File, + private val name: String, + private val sha256: String, + private val size: Long, + /** A request of [url] from byte [offset] on (0: all); null when the server cannot be reached. */ + private val open: (url: String, offset: Long) -> Response?, +) { + /** An answer: its HTTP [code] (200, 206 for a range), the body. */ + interface Response : Closeable { + val code: Int + val body: InputStream + } + + sealed class Result { + data class Done(val file: File) : Result() + /** `offline` (try again later, the part stays), `hash` (every source gave wrong bytes), `http_`, `cancelled`. */ + data class Failed(val code: String) : Result() + } + + val file: File get() = File(dir, name) + private val part: File get() = File(dir, "$name.part") + private val source: File get() = File(dir, "$name.src") + + /** The finished file is there (it only appears complete and checked). */ + fun ready(): Boolean = file.isFile && file.length() == size + + /** Everything of it goes (switched off). */ + fun delete() { + file.delete() + part.delete() + source.delete() + } + + /** [progress] hears bytes so far and the whole size; [cancelled] stops between blocks. */ + fun fetch(sources: List, progress: (Long, Long) -> Unit = { _, _ -> }, cancelled: () -> Boolean = { false }): Result { + dir.mkdirs() + if (ready()) return Result.Done(file) + var failure = "offline" + for (url in sources) { + if (cancelled()) return Result.Failed(CANCELLED) + // go on only with what the same source sent before + val had = if (part.isFile && runCatching { source.readText() }.getOrNull() == url) part.length() else 0L + val offset = if (had in 1 until size) had else 0L + if (offset == 0L) part.delete() + source.writeText(url) + val response = try { + open(url, offset) + } catch (e: IOException) { + null + } + if (response == null) { + failure = OFFLINE + continue + } + response.use { r -> + val append = when { + r.code == 206 && offset > 0 -> true + r.code == 200 -> false + else -> { + failure = "http_${r.code}" + part.delete() + return@use + } + } + var done = if (append) offset else 0L + progress(done, size) + try { + FileOutputStream(part, append).use { out -> + val buf = ByteArray(BLOCK) + while (true) { + if (cancelled()) return Result.Failed(CANCELLED) + val n = r.body.read(buf) + if (n < 0) break + done += n + if (done > size) break + out.write(buf, 0, n) + progress(done, size) + } + } + } catch (e: IOException) { + // interrupted: the part stays for the next try + return Result.Failed(OFFLINE) + } + if (part.length() == size && hash(part) == sha256) { + if (!part.renameTo(file)) { + file.delete() + if (!part.renameTo(file)) return Result.Failed(OFFLINE) + } + source.delete() + return Result.Done(file) + } + // wrong bytes (or too many): away with them, the next source + part.delete() + failure = HASH + } + } + source.delete() + return Result.Failed(failure) + } + + companion object { + const val OFFLINE = "offline" + const val HASH = "hash" + const val CANCELLED = "cancelled" + private const val BLOCK = 64 * 1024 + + fun hash(f: File): String { + val md = MessageDigest.getInstance("SHA-256") + f.inputStream().use { input -> + val buf = ByteArray(BLOCK) + while (true) { + val n = input.read(buf) + if (n < 0) break + md.update(buf, 0, n) + } + } + return md.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFacesLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFacesLogic.kt new file mode 100644 index 00000000..a205b264 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/faces/DriveFacesLogic.kt @@ -0,0 +1,275 @@ +package com.gatecontrol.android.drive.faces + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Base64 +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt + +/** + * The pure arithmetic of the face features (Drive 1.16, model `sface-2021dec`; see the scratch file drive-face-model.md): + * YuNet's output decoded as OpenCV's `FaceDetectorYN` does, the 5 points aligned to SFace's 112×112 template as + * `FaceRecognizerSF::alignCrop` does (similarity transform, bilinear), the feature normalised and sent as base64 of + * float32 little endian. No Android here: pictures are ARGB ints, so it runs in unit tests. + */ +object DriveFacesLogic { + + /** The model id Drive knows these vectors by. */ + const val MODEL = "sface-2021dec" + const val FEATURES = 128 + const val ALIGNED = 112 + /** YuNet's input (the 2023mar model has a fixed 640×640 input). */ + const val DETECT_SIZE = 640 + const val SCORE_MIN = 0.8f + const val NMS_IOU = 0.3f + /** A face smaller than this (px of the picture the points are in) gives no usable feature. */ + const val MIN_FACE_PX = 32f + const val MAX_FACES = 50 + + /** SFace's five points in 112×112 (eyes, nose, mouth corners) and their mean. */ + private val TEMPLATE = doubleArrayOf(38.2946, 51.6963, 73.5318, 51.5014, 56.0252, 71.7366, 41.5493, 92.3655, 70.7299, 92.2041) + private const val TEMPLATE_MX = 56.0262 + private const val TEMPLATE_MY = 71.9008 + + /** A face YuNet found: box (x, y, w, h), the 5 points (x, y each), score — in pixels of its input. */ + data class Face(val x: Float, val y: Float, val w: Float, val h: Float, val points: FloatArray, val score: Float) { + fun scaled(f: Float) = Face(x * f, y * f, w * f, h * f, FloatArray(points.size) { points[it] * f }, score) + + override fun equals(other: Any?): Boolean = + other is Face && x == other.x && y == other.y && w == other.w && h == other.h && score == other.score && points.contentEquals(other.points) + + override fun hashCode(): Int = ((x.hashCode() * 31 + y.hashCode()) * 31 + w.hashCode()) * 31 + points.contentHashCode() + } + + // ─── detection (YuNet) ────────────────────────────────────────── + + /** The factor a picture of [w]×[h] is scaled by for YuNet (long side 640, never larger). */ + fun detectScale(w: Int, h: Int): Float = min(1f, DETECT_SIZE.toFloat() / max(w, h).coerceAtLeast(1)) + + /** + * YuNet's input: the (scaled) picture top left in a 640×640 field, the rest 0; NCHW float, **BGR**, 0…255. + * [pixels] ARGB of [w]×[h] (at most 640 each). + */ + fun detectInput(pixels: IntArray, w: Int, h: Int): FloatArray { + val n = DETECT_SIZE * DETECT_SIZE + val out = FloatArray(3 * n) + for (y in 0 until min(h, DETECT_SIZE)) { + for (x in 0 until min(w, DETECT_SIZE)) { + val p = pixels[y * w + x] + val i = y * DETECT_SIZE + x + out[i] = (p and 0xFF).toFloat() + out[n + i] = (p shr 8 and 0xFF).toFloat() + out[2 * n + i] = (p shr 16 and 0xFF).toFloat() + } + } + return out + } + + /** + * The faces of YuNet's outputs `cls_s`, `obj_s`, `bbox_s`, `kps_s` (s = 8, 16, 32) for a [size]×[size] input, as + * OpenCV decodes them, then non-maximum suppression; best first, at most [MAX_FACES]. + */ + fun decode(out: (String) -> FloatArray?, size: Int = DETECT_SIZE, threshold: Float = SCORE_MIN, iou: Float = NMS_IOU): List { + val found = ArrayList() + for (s in intArrayOf(8, 16, 32)) { + val cls = out("cls_$s") ?: continue + val obj = out("obj_$s") ?: continue + val bbox = out("bbox_$s") ?: continue + val kps = out("kps_$s") ?: continue + val cols = size / s + val rows = size / s + val count = min(rows * cols, min(cls.size, obj.size)) + for (idx in 0 until count) { + val score = sqrt(cls[idx].coerceIn(0f, 1f) * obj[idx].coerceIn(0f, 1f)) + if (score < threshold) continue + if (bbox.size < idx * 4 + 4 || kps.size < idx * 10 + 10) continue + val r = idx / cols + val c = idx % cols + val cx = (c + bbox[idx * 4]) * s + val cy = (r + bbox[idx * 4 + 1]) * s + val w = exp(bbox[idx * 4 + 2]) * s + val h = exp(bbox[idx * 4 + 3]) * s + val pts = FloatArray(10) { k -> (kps[idx * 10 + k] + if (k % 2 == 0) c else r) * s } + found += Face(cx - w / 2f, cy - h / 2f, w, h, pts, score) + } + } + return nms(found, iou).take(MAX_FACES) + } + + /** OpenCV's `NMSBoxes` on boxes cut to whole pixels: best first, a box overlapping a kept one by more than [iou] goes. */ + fun nms(faces: List, iou: Float): List { + val sorted = faces.sortedByDescending { it.score } + val kept = ArrayList() + for (f in sorted) { + if (kept.none { overlap(it, f) > iou }) kept += f + } + return kept + } + + private fun overlap(a: Face, b: Face): Float { + val ax = a.x.toInt(); val ay = a.y.toInt(); val aw = a.w.toInt(); val ah = a.h.toInt() + val bx = b.x.toInt(); val by = b.y.toInt(); val bw = b.w.toInt(); val bh = b.h.toInt() + val iw = min(ax + aw, bx + bw) - max(ax, bx) + val ih = min(ay + ah, by + bh) - max(ay, by) + if (iw <= 0 || ih <= 0) return 0f + val inter = iw.toFloat() * ih + val union = aw.toFloat() * ah + bw.toFloat() * bh - inter + return if (union <= 0f) 0f else inter / union + } + + /** Whether a face (pixels of the picture its points are in) is big enough and lies mostly in the picture. */ + fun usable(f: Face, w: Int, h: Int): Boolean { + if (f.w < MIN_FACE_PX || f.h < MIN_FACE_PX) return false + val cx = f.x + f.w / 2f + val cy = f.y + f.h / 2f + return cx in 0f..w.toFloat() && cy in 0f..h.toFloat() + } + + /** The box `[x, y, w, h]` and the points as 0…1 of a [w]×[h] picture (what Drive takes). */ + fun normalized(f: Face, w: Int, h: Int): Pair, List> { + val fw = w.toFloat() + val fh = h.toFloat() + val x0 = f.x.coerceIn(0f, fw) + val y0 = f.y.coerceIn(0f, fh) + val x1 = (f.x + f.w).coerceIn(0f, fw) + val y1 = (f.y + f.h).coerceIn(0f, fh) + val box = listOf(x0 / fw, y0 / fh, (x1 - x0) / fw, (y1 - y0) / fh) + val pts = f.points.mapIndexed { i, v -> (v / if (i % 2 == 0) fw else fh).coerceIn(0f, 1f) } + return box to pts + } + + // ─── alignment ────────────────────────────────────────────────── + + /** + * The similarity transform (rotation, uniform scale, shift; no mirroring) taking the 5 [points] (x, y each) onto + * SFace's template, least squares — the same as OpenCV's Umeyama: `[a, -b, tx, b, a, ty]` (row-major 2×3). + */ + fun similarity(points: FloatArray): DoubleArray { + require(points.size == 10) { "5 points" } + var mx = 0.0 + var my = 0.0 + for (i in 0 until 5) { + mx += points[2 * i] + my += points[2 * i + 1] + } + mx /= 5 + my /= 5 + var variance = 0.0 + var sa = 0.0 + var sb = 0.0 + for (i in 0 until 5) { + val sx = points[2 * i] - mx + val sy = points[2 * i + 1] - my + val dx = TEMPLATE[2 * i] - TEMPLATE_MX + val dy = TEMPLATE[2 * i + 1] - TEMPLATE_MY + variance += sx * sx + sy * sy + sa += sx * dx + sy * dy + sb += sx * dy - sy * dx + } + if (variance <= 1e-12) return doubleArrayOf(1.0, 0.0, TEMPLATE_MX - mx, 0.0, 1.0, TEMPLATE_MY - my) + val a = sa / variance + val b = sb / variance + return doubleArrayOf(a, -b, TEMPLATE_MX - (a * mx - b * my), b, a, TEMPLATE_MY - (b * mx + a * my)) + } + + /** The inverse of a 2×3 affine matrix (row-major). */ + fun invert(m: DoubleArray): DoubleArray { + val det = m[0] * m[4] - m[1] * m[3] + require(det != 0.0) { "singular" } + val ia = m[4] / det + val ib = -m[1] / det + val id = -m[3] / det + val ie = m[0] / det + return doubleArrayOf(ia, ib, -(ia * m[2] + ib * m[5]), id, ie, -(id * m[2] + ie * m[5])) + } + + /** + * The 112×112 face of [pixels] (ARGB, [w]×[h]) under [m] (picture → 112×112), as `warpAffine(INTER_LINEAR, + * BORDER_CONSTANT 0)`: each target pixel samples the picture at `m⁻¹·(x, y)` bilinearly (pixel centres at whole + * numbers, outside is 0), rounded to 0…255. Out: RGB bytes (0…255 as Int), row by row. + */ + fun warp(pixels: IntArray, w: Int, h: Int, m: DoubleArray, size: Int = ALIGNED): IntArray { + val inv = invert(m) + val out = IntArray(size * size * 3) + fun px(xx: Int, yy: Int): Int = if (xx < 0 || yy < 0 || xx >= w || yy >= h) 0 else pixels[yy * w + xx] + for (y in 0 until size) { + for (x in 0 until size) { + val sx = inv[0] * x + inv[1] * y + inv[2] + val sy = inv[3] * x + inv[4] * y + inv[5] + val x0 = floor(sx).toInt() + val y0 = floor(sy).toInt() + val fx = sx - x0 + val fy = sy - y0 + val p00 = px(x0, y0) + val p01 = px(x0 + 1, y0) + val p10 = px(x0, y0 + 1) + val p11 = px(x0 + 1, y0 + 1) + val w00 = (1 - fx) * (1 - fy) + val w01 = fx * (1 - fy) + val w10 = (1 - fx) * fy + val w11 = fx * fy + val o = (y * size + x) * 3 + for (ch in 0 until 3) { + val shift = 16 - 8 * ch // R, G, B + val v = (p00 shr shift and 0xFF) * w00 + (p01 shr shift and 0xFF) * w01 + (p10 shr shift and 0xFF) * w10 + (p11 shr shift and 0xFF) * w11 + out[o + ch] = floor(v + 0.5).toInt().coerceIn(0, 255) + } + } + } + return out + } + + /** SFace's input of an aligned face (RGB bytes): NCHW float, **RGB**, 0…255 (the model normalises itself). */ + fun featureInput(rgb: IntArray, size: Int = ALIGNED): FloatArray { + val n = size * size + val out = FloatArray(3 * n) + for (i in 0 until n) { + out[i] = rgb[i * 3].toFloat() + out[n + i] = rgb[i * 3 + 1].toFloat() + out[2 * n + i] = rgb[i * 3 + 2].toFloat() + } + return out + } + + // ─── the feature ──────────────────────────────────────────────── + + /** [v] with length 1 (a zero vector stays as it is). */ + fun l2(v: FloatArray): FloatArray { + var sum = 0.0 + for (x in v) sum += x.toDouble() * x + val n = sqrt(sum) + if (n <= 0.0) return v.copyOf() + return FloatArray(v.size) { (v[it] / n).toFloat() } + } + + fun cosine(a: FloatArray, b: FloatArray): Double { + require(a.size == b.size) + var dot = 0.0 + var na = 0.0 + var nb = 0.0 + for (i in a.indices) { + dot += a[i].toDouble() * b[i] + na += a[i].toDouble() * a[i] + nb += b[i].toDouble() * b[i] + } + return if (na == 0.0 || nb == 0.0) 0.0 else dot / sqrt(na * nb) + } + + /** base64 (standard alphabet, padded, one line) of [v] as float32 little endian — Drive's `embedding`. */ + fun encode(v: FloatArray): String { + val buf = ByteBuffer.allocate(v.size * 4).order(ByteOrder.LITTLE_ENDIAN) + v.forEach { buf.putFloat(it) } + return Base64.getEncoder().encodeToString(buf.array()) + } + + /** The other way round; null when it is no whole number of floats. */ + fun decodeVector(s: String): FloatArray? { + val bytes = runCatching { Base64.getDecoder().decode(s) }.getOrNull() ?: return null + if (bytes.isEmpty() || bytes.size % 4 != 0) return null + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + return FloatArray(bytes.size / 4) { buf.getFloat() } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/faces/DrivePeople.kt b/app/src/main/java/com/gatecontrol/android/drive/faces/DrivePeople.kt new file mode 100644 index 00000000..a743120a --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/faces/DrivePeople.kt @@ -0,0 +1,280 @@ +package com.gatecontrol.android.drive.faces + +import android.content.ContentUris +import android.content.Context +import android.provider.MediaStore +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DPhotoFilter +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.DrivePlacesPrefs +import com.gatecontrol.android.drive.DrivePreviews +import com.gatecontrol.android.drive.DriveNodeResolver +import com.gatecontrol.android.drive.work.DriveWorkGeo +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.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Personen erkennen" (Drive 1.16, feature `photoPeople`, only while the person switched it on): this phone finds the + * faces of photos and sends their features to the own server (`PUT nodes//faces`), which groups them into people. + * New photos right after the backup sent them (the original on the phone, [DrivePlacesPrefs.pending]); older ones from + * Drive's list of photos no phone has looked at for this person and model (`photos?unfaced=1`) — the original when it + * is still on the phone, else the large preview from the own server. Runs as a WorkManager job, in WLAN and, by + * default, only while charging; a few hundred pictures a run. + */ +@Singleton +class DrivePeople @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + private val live: DriveLive, + private val prefs: DrivePlacesPrefs, + private val engine: DriveFaceEngine, + private val previews: DrivePreviews, + private val resolver: DriveNodeResolver, + private val geo: DriveWorkGeo, + private val model: DriveFaceModel, +) { + private val lock = Mutex() + + enum class Outcome { DONE, MORE, RETRY } + + /** Whether faces are to be found on this phone now (the feature, the person's switch). */ + suspend fun active(): Boolean = live.supports(DMe.FEATURE_PHOTO_PEOPLE) && prefs.settings.value.people + + /** The switches as the server has them now (the job must not work on an old "on"). */ + private suspend fun refresh(): Boolean { + if (!live.supports(DMe.FEATURE_PHOTO_PEOPLE)) return false + api.photoSettings().valueOrNull()?.let(prefs::took) + return prefs.settings.value.people + } + + /** One run: the photos just backed up, then up to [budget] older ones. */ + suspend fun run(budget: Int = BUDGET): Outcome = lock.withLock { + withContext(Dispatchers.Default) { + if (!refresh()) return@withContext Outcome.DONE + // the feature model first (loaded once; its job starts this one again when it is here) + if (!model.ready()) return@withContext Outcome.DONE.also { DriveFaceModelWork.start(context) } + val models = engine.open() ?: return@withContext Outcome.DONE + try { + pass(models, budget) + } finally { + models.close() + } + } + } + + private suspend fun pass(models: DriveFaceEngine.Models, budget: Int): Outcome { + var done = 0 + // 1. what the backup just sent: the original is on the phone + for (p in prefs.pending()) { + currentCoroutineContext().ensureActive() + if (done >= budget) return Outcome.MORE + val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, p.mediaId) + val bitmap = engine.decode(uri) + if (bitmap == null) { + prefs.donePending(p.nodeId) + continue + } + val faces = try { + models.faces(bitmap) + } catch (e: Exception) { + Timber.d("Drive faces: not looked at (%s)", e.javaClass.simpleName) + null + } finally { + bitmap.recycle() + } + if (faces == null) { + prefs.donePending(p.nodeId) + continue + } + when (val r = api.putFaces(p.nodeId, DriveFacesLogic.MODEL, faces)) { + is DResult.Ok -> prefs.donePending(p.nodeId) + DResult.Offline -> return Outcome.RETRY + is DResult.Failed -> { + // switched off meanwhile (not just a child's folder, which is left out) + if (off(r)) return Outcome.DONE.also { switchedOff() } + prefs.donePending(p.nodeId) + } + else -> return Outcome.DONE + } + done++ + } + // 2. what no phone has looked at yet (one pass at most once a day) + val pass = DrivePlacesPrefs.CURSOR_FACES + var cursor = prefs.cursor(pass) + if (cursor == null && System.currentTimeMillis() - prefs.passAt(pass) < PASS_GAP_MS && prefs.passAt(pass) > 0) return Outcome.DONE + var pages = 0 + while (done < budget && pages < MAX_PAGES) { + val page = when (val r = api.photos(before = cursor, limit = PAGE, filter = DPhotoFilter(unfacedModel = DriveFacesLogic.MODEL))) { + is DResult.Ok -> r.value + DResult.Offline -> return Outcome.RETRY + else -> return Outcome.DONE + } + pages++ + for (n in page.items) { + currentCoroutineContext().ensureActive() + if (done >= budget) return Outcome.MORE + if (n.encrypted || DriveLogic.kind(n) != DriveLogic.Kind.IMAGE) continue + val faces = facesOf(models, n) ?: continue + when (val r = api.putFaces(n.id, DriveFacesLogic.MODEL, faces)) { + is DResult.Ok -> Unit + DResult.Offline -> return Outcome.RETRY + is DResult.Failed -> if (off(r)) return Outcome.DONE.also { switchedOff() } + else -> return Outcome.DONE + } + done++ + } + cursor = page.next + prefs.setCursor(pass, cursor) + if (cursor == null) { + prefs.setPassAt(pass, System.currentTimeMillis()) + Timber.i("Drive faces: a pass through the photos is done (%d now)", done) + return Outcome.DONE + } + } + return Outcome.MORE + } + + /** Switched off elsewhere (another device, the portal): no faces any more, the model goes too. */ + private fun switchedOff() { + prefs.took(com.gatecontrol.android.drive.DPhotoSettings(people = false)) + model.delete() + } + + /** `403 feature_off`: "Personen erkennen" is off — but not for a picture in a child's folder (`reason: kids`). */ + private fun off(r: DResult.Failed): Boolean { + if (r.code != FEATURE_OFF) return false + val reason = r.body?.get("reason")?.takeIf { it.isJsonPrimitive }?.asString + val field = r.body?.get("field")?.takeIf { it.isJsonPrimitive }?.asString + return reason != "kids" && field != "peopleKids" + } + + /** The faces of a photo in Drive: from its original when it is on this phone, else from the large preview. */ + private suspend fun facesOf(models: DriveFaceEngine.Models, n: DNode): List? { + val local = try { + withContext(Dispatchers.IO) { geo.original(n) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + val bitmap = local?.let { engine.decode(it) } ?: run { + val full = if (n.mediaId != null) n else resolver.resolve(n.id) ?: return null + if (!full.preview) return null + previews.load(full, large = true) + } ?: return null + return try { + models.faces(bitmap) + } catch (e: Exception) { + Timber.d("Drive faces: not looked at (%s)", e.javaClass.simpleName) + null + } finally { + // previews stay in their cache; only own decodes are freed + if (local != null) bitmap.recycle() + } + } + + /** Whether there is something to do (photos just sent, or a pass due). */ + fun due(): Boolean { + if (!prefs.settings.value.people) return false + val pass = DrivePlacesPrefs.CURSOR_FACES + return prefs.pending().isNotEmpty() || prefs.cursor(pass) != null || System.currentTimeMillis() - prefs.passAt(pass) > PASS_GAP_MS + } + + companion object { + const val BUDGET = 150 + private const val PAGE = 100 + private const val MAX_PAGES = 20 + private const val PASS_GAP_MS = 24 * 3_600_000L + private const val FEATURE_OFF = "feature_off" + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DrivePeopleEntryPoint { + fun drivePeople(): DrivePeople + fun drivePlacesPrefs(): DrivePlacesPrefs +} + +/** One run of [DrivePeople.run]; a run with more to do asks for the next one. */ +class DrivePeopleWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val people = EntryPointAccessors.fromApplication(applicationContext, DrivePeopleEntryPoint::class.java).drivePeople() + return when (people.run()) { + DrivePeople.Outcome.DONE -> Result.success() + DrivePeople.Outcome.MORE -> Result.success().also { DrivePeopleWork.next(applicationContext) } + DrivePeople.Outcome.RETRY -> if (runAttemptCount < 5) Result.retry() else Result.success() + } + } +} + +/** Scheduling of "Personen erkennen": WLAN, battery not low and — with "Nur beim Laden" (default) — charging. */ +object DrivePeopleWork { + private const val RUN = "drive-people" + + private fun constraints(chargingOnly: Boolean): Constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresCharging(chargingOnly) + .setRequiresBatteryNotLow(true) + .build() + + private fun entry(context: Context) = EntryPointAccessors.fromApplication(context, DrivePeopleEntryPoint::class.java) + + /** A run when one is due (an existing one is kept): the Photos page opened, a backup finished. */ + fun request(context: Context) { + runCatching { + if (!entry(context).drivePeople().due()) return + enqueue(context, ExistingWorkPolicy.KEEP, 0) + }.onFailure { Timber.w("Drive faces: not scheduled (%s)", it.javaClass.simpleName) } + } + + /** Switched on, or "Nur beim Laden" changed: a run with the conditions as they are now. */ + fun restart(context: Context) { + runCatching { enqueue(context, ExistingWorkPolicy.REPLACE, 0) } + } + + /** Switched off: no more runs. */ + fun cancel(context: Context) { + runCatching { WorkManager.getInstance(context).cancelUniqueWork(RUN) } + } + + fun next(context: Context) { + runCatching { enqueue(context, ExistingWorkPolicy.APPEND_OR_REPLACE, 1) } + } + + private fun enqueue(context: Context, policy: ExistingWorkPolicy, delayMinutes: Long) { + val charging = runCatching { entry(context).drivePlacesPrefs().settings.value.chargingOnly }.getOrDefault(true) + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints(charging)) + .setInitialDelay(delayMinutes, TimeUnit.MINUTES) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(RUN, policy, request) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkGeo.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkGeo.kt index 1fe5e4ce..9411ed8c 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkGeo.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkGeo.kt @@ -38,23 +38,46 @@ class DriveWorkGeo @Inject constructor(@ApplicationContext private val context: } /** Where [node] was taken, if its original is on this phone. */ - suspend fun of(node: DNode): Look = withContext(Dispatchers.IO) { - if (node.isDir || node.encrypted || node.size <= 0 || node.mime?.startsWith("image/") != true) return@withContext Look(true, null) - if (!canLook()) return@withContext Look(false, null) + suspend fun of(node: DNode): Look = when (val f = find(node)) { + Found.Unknown -> Look(false, null) + Found.NotHere, Found.NoPosition -> Look(true, null) + is Found.At -> Look(true, f.at) + } + + /** What a look at the phone found for a photo in Drive (Drive 1.16 "Orte nachtragen" tells these apart). */ + sealed class Found { + /** The phone could not look (no permission, an error): ask again later. */ + data object Unknown : Found() + /** The original is not on this phone (or no picture): another device may know. */ + data object NotHere : Found() + /** The original is here and has no position. */ + data object NoPosition : Found() + data class At(val at: GeoPoint) : Found() + } + + /** The original of [node] in the gallery (same name and size); null when it is not on this phone. Throws on errors. */ + fun original(node: DNode): android.net.Uri? { + if (node.isDir || node.encrypted || node.size <= 0 || node.mime?.startsWith("image/") != true) return null + return context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.SIZE} = ?", + arrayOf(node.name, node.size.toString()), + null, + )?.use { c -> if (c.moveToFirst()) ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, c.getLong(0)) else null } + } + + suspend fun find(node: DNode): Found = withContext(Dispatchers.IO) { + if (node.isDir || node.encrypted || node.size <= 0 || node.mime?.startsWith("image/") != true) return@withContext Found.NotHere + if (!canLook()) return@withContext Found.Unknown try { - val id = context.contentResolver.query( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - arrayOf(MediaStore.MediaColumns._ID), - "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.SIZE} = ?", - arrayOf(node.name, node.size.toString()), - null, - )?.use { c -> if (c.moveToFirst()) c.getLong(0) else null } ?: return@withContext Look(true, null) - val uri = MediaStore.setRequireOriginal(ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)) + val plain = original(node) ?: return@withContext Found.NotHere + val uri = MediaStore.setRequireOriginal(plain) val at = context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> DriveWorkPlaces.of(ExifInterface(pfd.fileDescriptor).latLong) } - Look(true, at) + if (at == null) Found.NoPosition else Found.At(at) } catch (e: Exception) { Timber.d("Drive work: no location (%s)", e.javaClass.simpleName) - Look(false, null) + Found.Unknown } } } 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 a2732525..adb9e018 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1886,6 +1886,10 @@ private fun DriveHome(navController: NavHostController, onShown: (Long?) -> Unit file = nav.file, deadlines = { nav.go(Screen.DriveDeadlines.route) }, photoSearch = { nav.go(Screen.DrivePhotoSearch.route) }, + photoMap = { nav.go(Screen.DrivePhotoMap.route) }, + place = { key -> nav.go(Screen.DrivePhotoPlace.create(key)) }, + people = { nav.go(Screen.DrivePeople.route) }, + person = { id -> nav.go(Screen.DrivePerson.create(id)) }, vault = nav.vault, settings = { nav.go(Screen.DriveSettings.route) }, cleanup = { nav.go(Screen.DriveCleanup.route) }, @@ -1921,7 +1925,45 @@ private fun NavGraphBuilder.driveRoutes( GcContentWidth { com.gatecontrol.android.ui.drive.DriveDeadlinesScreen(onBack = back, onFile = file) } } composable(Screen.DrivePhotoSearch.route) { - GcContentWidth { com.gatecontrol.android.ui.drive.DrivePhotoSearchScreen(onBack = back) } + GcContentWidth { + com.gatecontrol.android.ui.drive.DrivePhotoSearchScreen( + onBack = back, + onPerson = { go(Screen.DrivePerson.create(it)) }, + onPlace = { go(Screen.DrivePhotoPlace.create(it)) }, + ) + } + } + composable(Screen.DrivePhotoMap.route) { + com.gatecontrol.android.ui.drive.DrivePhotoMapScreen( + onBack = back, + onPlace = { key, bbox -> go(Screen.DrivePhotoPlace.create(key, bbox?.let { b -> listOf(b.west, b.south, b.east, b.north).joinToString(",") })) }, + onPerson = { go(Screen.DrivePerson.create(it)) }, + ) + } + composable( + Screen.DrivePhotoPlace.route, + arguments = listOf( + navArgument(Screen.DrivePhotoPlace.ARG_KEY) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(Screen.DrivePhotoPlace.ARG_BBOX) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(Screen.DrivePhotoPlace.ARG_PERSON) { type = NavType.StringType; nullable = true; defaultValue = null }, + ), + ) { + GcContentWidth { com.gatecontrol.android.ui.drive.DrivePlaceScreen(onBack = back) } + } + composable(Screen.DrivePeople.route) { + GcContentWidth { + com.gatecontrol.android.ui.drive.DrivePeopleScreen( + onBack = back, + onPerson = { go(Screen.DrivePerson.create(it)) }, + onSettings = { go(Screen.DrivePlacesSettings.route) }, + ) + } + } + composable(Screen.DrivePerson.route, arguments = listOf(navArgument(Screen.DrivePerson.ARG_ID) { type = NavType.StringType })) { + GcContentWidth { com.gatecontrol.android.ui.drive.DrivePersonScreen(onBack = back, onPlace = { key, person -> go(Screen.DrivePhotoPlace.create(key, person = person)) }) } + } + composable(Screen.DrivePlacesSettings.route) { + GcContentWidth { com.gatecontrol.android.ui.drive.DrivePlacesSettingsScreen(onBack = back) } } composable( Screen.DriveVault.route, @@ -1945,6 +1987,7 @@ private fun NavGraphBuilder.driveRoutes( onBack = back, onBackup = { go(Screen.DriveBackup.route) }, onFolders = { go(Screen.DriveFolders.route) }, + onPlaces = { go(Screen.DrivePlacesSettings.route) }, ) } } 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 b51aca74..f7e1ac04 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -281,6 +281,32 @@ sealed class Screen(val route: String) { data object DriveDeadlines : Screen("drive/deadlines") /** Photos by what is in them (Drive 1.14, feature `photoSearch`). */ data object DrivePhotoSearch : Screen("drive/photos/search") + /** "Karte": the photos on OpenStreetMap (Drive 1.16, feature `photoPlaces`). */ + data object DrivePhotoMap : Screen("drive/photos/map") + /** "Ort": a place's photos by day ([ARG_KEY]; or a box `w,s,e,n` without a place), optionally with people. */ + data object DrivePhotoPlace : Screen("drive/photos/place?key={key}&bbox={bbox}&person={person}") { + const val ARG_KEY = "key" + const val ARG_BBOX = "bbox" + const val ARG_PERSON = "person" + fun create(key: String?, bbox: String? = null, person: Long? = null): String = buildString { + append("drive/photos/place") + val args = listOfNotNull( + key?.let { "key=" + android.net.Uri.encode(it) }, + bbox?.let { "bbox=" + android.net.Uri.encode(it) }, + person?.let { "person=$it" }, + ) + if (args.isNotEmpty()) append("?").append(args.joinToString("&")) + } + } + /** "Personen" (Drive 1.16, feature `photoPeople`). */ + data object DrivePeople : Screen("drive/photos/people") + /** One person of the photos. */ + data object DrivePerson : Screen("drive/photos/person/{id}") { + const val ARG_ID = "id" + fun create(id: Long) = "drive/photos/person/$id" + } + /** Drive › Einstellungen › "Orte und Personen". */ + data object DrivePlacesSettings : Screen("drive/settings/places") /** A folder of a vault (Drive 1.9); `requests=1` shows the approval requests at once (a request's push). */ data object DriveVault : Screen("drive/vault/{vault}/{folder}?requests={requests}") { const val ARG_VAULT = "vault" diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveAlbumScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveAlbumScreen.kt index 996aa4ce..74dd9d78 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveAlbumScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveAlbumScreen.kt @@ -67,11 +67,12 @@ fun DriveAlbumScreen(onBack: () -> Unit, viewModel: DriveAlbumViewModel = hiltVi // back ends the selection first BackHandler(enabled = selecting) { viewModel.clearSelection() } Column(Modifier.fillMaxSize()) { - if (selecting) { - SelectionHead(count = selected.size, line = null, allSelected = false, onClose = viewModel::clearSelection, onAll = null) - } else { - AlbumBar(a?.name ?: stringResource(R.string.drive_photos_albums), canEdit, onBack, onAdd = viewModel::openPicker, onRename = { renaming = true }, onDelete = { deleting = true }) - } + // the title row only, as high either way: the photos do not move when ticking starts + SelectionSwap( + selecting, + bar = { AlbumBar(a?.name ?: stringResource(R.string.drive_photos_albums), canEdit, onBack, onAdd = viewModel::openPicker, onRename = { renaming = true }, onDelete = { deleting = true }) }, + selection = { SelectionHead(count = selected.size, line = null, allSelected = false, onClose = viewModel::clearSelection, onAll = null) }, + ) when (val s = album) { DriveLoad.Loading -> if (!photos.loaded) DriveLoading() is DriveLoad.Error -> DriveErrorBox(s.code, onRetry = viewModel::refresh) 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 bc991c8d..e2afe781 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 @@ -192,7 +192,8 @@ internal fun SelectionHead(count: Int, line: String?, allSelected: Boolean, onCl // TalkBack says the new count when it changes modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite; heading() }, ) - if (!line.isNullOrBlank()) Text(line, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) + // one line: the head is as high whatever it says (SelectionSwap keeps the page below in place) + if (!line.isNullOrBlank()) Text(line, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) } if (onAll != null) { val desc = stringResource(if (allSelected) R.string.drive_sel_none_desc else R.string.drive_sel_all_desc) 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 cd1562ee..5e3c1a7b 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 @@ -117,6 +117,22 @@ fun DriveFolderScreen( ) } +/** "N ausgewählt" of a folder: the summary of what is ticked, "Alle" / "Keine". */ +@Composable +internal fun FolderSelectionHead(viewModel: DriveFolderViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + val selected by viewModel.bulk.selected.collectAsStateWithLifecycle() + val items = (state as? DriveLoad.Ready)?.value?.items.orEmpty() + val chosen = remember(selected, items) { items.filter { it.id in selected } } + SelectionHead( + count = chosen.size, + line = summaryText(DriveBulk.summary(chosen)), + allSelected = DriveBulk.allSelected(selected, items.map { it.id }), + onClose = viewModel.bulk::clear, + onAll = viewModel.bulk::allOrNone, + ) +} + /** * A folder: its content, upload, new folder, and per item open, send, link, rename, move, versions, trash. [topBar] * gets the title and the sort button; [header] goes above the items (the Drive tab: "Zuletzt"). @@ -136,6 +152,8 @@ internal fun DriveFolderContent( gridState: androidx.compose.runtime.MutableState = rememberDriveGrid(), /** A tap on the path above (the Drive tab: the folder shows in the tab itself); else [onFolder]. */ onCrumb: ((Long) -> Unit)? = null, + /** The page shows its own selection head in place of [topBar] (false: the Drive tab shows it, [FolderSelectionHead]). */ + ownHead: Boolean = true, ) { val state by viewModel.state.collectAsStateWithLifecycle() val area by viewModel.area.collectAsStateWithLifecycle() @@ -214,14 +232,8 @@ internal fun DriveFolderContent( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { - if (selecting) { - SelectionHead( - count = chosen.size, - line = summaryText(DriveBulk.summary(chosen)), - allSelected = DriveBulk.allSelected(selected, items.map { it.id }), - onClose = viewModel.bulk::clear, - onAll = viewModel.bulk::allOrNone, - ) + if (ownHead) { + SelectionSwap(selecting, bar = { topBar(title) { FolderViewActions(viewModel, gridState) } }, selection = { FolderSelectionHead(viewModel) }) } else { topBar(title) { FolderViewActions(viewModel, gridState) } } 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 46ae7215..a9f98730 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 @@ -101,6 +101,12 @@ data class DriveNav( val deadlines: () -> Unit = {}, /** Photos by what is in them (Drive 1.14, `photoSearch`): the search of the Photos page. */ val photoSearch: () -> Unit = {}, + /** Drive 1.16 (`photoPlaces`): the map of the photos, a place. */ + val photoMap: () -> Unit = {}, + val place: (String) -> Unit = {}, + /** Drive 1.16 (`photoPeople`): the people, one person. */ + val people: () -> Unit = {}, + val person: (Long) -> Unit = {}, /** A vault folder (Drive 1.9). */ val vault: (String, Long) -> Unit = { _, _ -> }, /** "Drive-Einstellungen" (Drive 1.11, the gear of the menu). */ @@ -270,7 +276,12 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel if (home != null && current != null) { Column(Modifier.fillMaxSize()) { // while ticking, the page's own bar ("N ausgewählt") is on top - if (!selecting) { + // while ticking, the page's head ("N ausgewählt") takes the title row only, as high as it; the switch + // below stays (dimmed), so the page does not move under the finger of the long press + SelectionSwap( + selecting, + selection = { if (ticked.isNotEmpty()) PhotosSelectionHead(photosViewModel) else FolderSelectionHead(folderViewModel) }, + bar = { TabBar( when { onPhotosPage -> stringResource(R.string.drive_mode_photos) @@ -278,11 +289,19 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel else -> current.label }, onMenu = openMenu, - onSearch = if (onPhotosPage && me?.has(DMe.FEATURE_PHOTO_SEARCH) == true) nav.photoSearch else nav.search, - actions = { if (!onPhotosPage) FolderViewActions(folderViewModel, grid) }, + onSearch = if (onPhotosPage && DriveLogic.photoSearchable(me)) nav.photoSearch else nav.search, + actions = { + if (!onPhotosPage) { + FolderViewActions(folderViewModel, grid) + } else if (me?.has(DMe.FEATURE_PHOTO_PLACES) == true) { + // Drive 1.16: the photos on the map + GcIconButton(GcIcons.Map, contentDescription = stringResource(R.string.drive_pp_map), onClick = nav.photoMap, tint = MaterialTheme.colorScheme.onBackground) + } + }, ) - if (hasPhotos) DriveModeSwitch(photos = onPhotosPage, onPhotos = showPage) - } + }, + ) + if (hasPhotos) Box(Modifier.inert(selecting)) { DriveModeSwitch(photos = onPhotosPage, onPhotos = showPage) } HorizontalPager( state = pager, modifier = Modifier.weight(1f), @@ -292,7 +311,15 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel key = { it }, ) { page -> if (DriveLogic.isPhotosTab(page, hasPhotos)) { - DrivePhotosContent(viewModel = photosViewModel, topBar = {}, onAlbum = nav.album, onCleanup = nav.cleanup, onFile = nav.file) + DrivePhotosContent( + viewModel = photosViewModel, + topBar = {}, + onAlbum = nav.album, + onCleanup = nav.cleanup, + onFile = nav.file, + places = PhotosPlacesNav(search = nav.photoSearch, map = nav.photoMap, place = nav.place, people = nav.people, person = nav.person), + ownHead = false, + ) } else { DriveFilesPage( folderViewModel = folderViewModel, @@ -378,6 +405,8 @@ private fun DriveFilesPage( topBar = { _, _ -> }, header = header, onCrumb = onCrumb, + // the tab shows the selection head in its own title row + ownHead = false, ) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt index ebb7c3cc..c4f329f9 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt @@ -189,22 +189,25 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D } Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { - if (selecting) { - SelectionHead( - count = chosen.size, - line = summaryText(DriveBulk.summary(chosen)), - allSelected = DriveBulk.allSelected(selected, hits.map { it.id }), - onClose = viewModel.bulk::clear, - onAll = viewModel.bulk::allOrNone, - ) - } else { - GcSubpageBar(stringResource(R.string.drive_search), onBack = onBack, backDescription = stringResource(R.string.drive_back)) - } + // ticking changes the title row only (as high either way); the search field stays, dimmed + SelectionSwap( + selecting, + bar = { GcSubpageBar(stringResource(R.string.drive_search), onBack = onBack, backDescription = stringResource(R.string.drive_back)) }, + selection = { + SelectionHead( + count = chosen.size, + line = summaryText(DriveBulk.summary(chosen)), + allSelected = DriveBulk.allSelected(selected, hits.map { it.id }), + onClose = viewModel.bulk::clear, + onAll = viewModel.bulk::allOrNone, + ) + }, + ) Column( Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - if (!selecting) { + Column(Modifier.inert(selecting), verticalArrangement = Arrangement.spacedBy(12.dp)) { GcTextField( value = q, onValueChange = { diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePeopleScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePeopleScreens.kt new file mode 100644 index 00000000..549d9b0a --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePeopleScreens.kt @@ -0,0 +1,655 @@ +package com.gatecontrol.android.ui.drive + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.lazy.items +import androidx.compose.foundation.rememberScrollState +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.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.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.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DFaceRef +import com.gatecontrol.android.drive.DPerson +import com.gatecontrol.android.drive.DPersonContact +import com.gatecontrol.android.drive.DrivePlacesJson +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.drive.faces.FaceModelState +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.theme.GateControlTheme +import java.time.ZoneId + +/** A heading of the people pages with a muted note on the right. */ +@Composable +private fun PeopleHead(title: String, note: String? = null) { + Row(Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp, start = 4.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).semantics { heading() }) + if (note != null) Text(note, style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + } +} + +/** How far this phone (and the others) got: "Gesichter werden erkannt · 4.120 von 6.212 Fotos". */ +@Composable +internal fun FacesProgress(done: Int?, total: Int?, chargingOnly: Boolean) { + val x = GateControlTheme.extraColors + GcCard(color = x.panel2, contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(GcIcons.Refresh, contentDescription = null, tint = x.accentText, modifier = Modifier.size(16.dp)) + val t = total ?: 0 + val d = (done ?: 0).coerceAtMost(t) + Text( + if (t > 0 && d >= t) stringResource(R.string.drive_pp_progress_done, countText(t)) else stringResource(R.string.drive_pp_progress, countText(d), countText(t)), + style = MaterialTheme.typography.titleSmall, + ) + } + val t = total ?: 0 + if (t > 0) LinearProgressIndicator(progress = { ((done ?: 0).toFloat() / t).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) + Text(stringResource(if (chargingOnly) R.string.drive_pp_progress_charging else R.string.drive_pp_progress_wifi), style = MaterialTheme.typography.bodySmall, color = x.muted) + } +} + +/** "12 von 39 MB". */ +private fun mb(bytes: Long): String = java.text.NumberFormat.getIntegerInstance().format((bytes + 500_000) / 1_000_000) + +/** + * The feature model while it is not on the phone yet: its download ("Erkennungsmodell wird geladen · 12 von 39 MB"), + * waiting for WLAN (with "Jetzt über mobile Daten laden"), or what went wrong with "Erneut versuchen". + */ +@Composable +internal fun FaceModelCard(state: FaceModelState, onMobile: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier) { + val x = GateControlTheme.extraColors + val failed = state.phase == FaceModelState.Phase.FAILED + GcCard(modifier = modifier, color = if (failed) x.errorBg else x.panel2, borderColor = androidx.compose.ui.graphics.Color.Transparent, contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + when (state.phase) { + FaceModelState.Phase.LOADING -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(GcIcons.Download, contentDescription = null, tint = x.accentText, modifier = Modifier.size(16.dp)) + Text(stringResource(R.string.drive_pp_model_loading, mb(state.done), mb(state.total)), style = MaterialTheme.typography.titleSmall) + } + LinearProgressIndicator(progress = { (state.done.toFloat() / state.total.coerceAtLeast(1)).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) + } + FaceModelState.Phase.FAILED -> { + Text( + stringResource(if (state.error == "hash") R.string.drive_pp_model_failed_hash else R.string.drive_pp_model_failed), + style = MaterialTheme.typography.bodyMedium, + ) + TextButton(onClick = onRetry) { Text(stringResource(R.string.drive_retry), color = x.accentText, fontWeight = FontWeight.SemiBold) } + } + else -> { + Text(stringResource(R.string.drive_pp_model_waiting, mb(state.total)), style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onMobile) { Text(stringResource(R.string.drive_pp_model_mobile), color = x.accentText, fontWeight = FontWeight.SemiBold) } + } + } + } +} + +/** A person in the grid: the face, the name (or "Benennen"), the count. */ +@Composable +private fun PersonTile(p: DPerson, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).clickable(role = Role.Button, onClick = onClick).padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (p.named || p.cover != null) PersonFace(p, 84.dp) else Box(Modifier.size(84.dp).clip(CircleShape).background(x.panel2), contentAlignment = Alignment.Center) { + Icon(GcIcons.User, contentDescription = null, tint = x.faint, modifier = Modifier.size(34.dp)) + } + Text( + p.name ?: stringResource(R.string.drive_pp_name_it), + style = MaterialTheme.typography.titleSmall, + color = if (p.named) MaterialTheme.colorScheme.onBackground else x.warn, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text(pluralStringResource(R.plurals.drive_photos_count, p.count, p.count), style = MaterialTheme.typography.bodySmall, color = x.muted) + } +} + +/** + * "Personen" (Drive 1.16, `photoPeople`): how far the faces are, the named people, those without a name ("Wer ist + * das?"), the hidden ones. The gear leads to "Orte und Personen". + */ +@Composable +fun DrivePeopleScreen(onBack: () -> Unit, onPerson: (Long) -> Unit, onSettings: () -> Unit, viewModel: DrivePeopleViewModel = hiltViewModel()) { + val x = GateControlTheme.extraColors + val people by viewModel.people.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val model by viewModel.model.collectAsStateWithLifecycle(FaceModelState(FaceModelState.Phase.READY)) + var naming by remember { mutableStateOf(null) } + var allUnnamed by rememberSaveable { mutableStateOf(false) } + var showHidden by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(viewModel) { + viewModel.load() + onPauseOrDispose { } + } + val ready = (people as? DriveLoad.Ready)?.value + val groups = remember(ready) { DrivePlacesLogic.groups(ready?.people.orEmpty()) } + Column(Modifier.fillMaxSize()) { + val sub = if (ready == null) null else listOf( + pluralStringResource(R.plurals.drive_pp_people_named, groups.named.size, groups.named.size), + pluralStringResource(R.plurals.drive_pp_people_unnamed, groups.unnamed.size, groups.unnamed.size), + ).joinToString(" · ") + GcSubpageBar(stringResource(R.string.drive_pp_people), onBack = onBack, backDescription = stringResource(R.string.drive_back)) { + GcIconButton(GcIcons.Settings, contentDescription = stringResource(R.string.drive_pp_people_settings), onClick = onSettings, tint = MaterialTheme.colorScheme.onBackground) + } + if (sub != null) Text(sub, style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(start = 64.dp)) + when (val p = people) { + DriveLoad.Loading -> DriveLoading() + is DriveLoad.Error -> DriveErrorBox(p.code, onRetry = viewModel::load) + is DriveLoad.Ready -> LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 104.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 32.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(Modifier.padding(horizontal = 4.dp)) { + if (!settings.people) { + GcCard(color = x.panel2) { + Text(stringResource(R.string.drive_pp_people_off), style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onSettings) { Text(stringResource(R.string.drive_pp_turn_on), color = x.accentText, fontWeight = FontWeight.SemiBold) } + } + } else if (model.phase != FaceModelState.Phase.READY) { + // first the feature model, then the faces + FaceModelCard(model, onMobile = { viewModel.loadModel(true) }, onRetry = { viewModel.loadModel(false) }) + } else { + FacesProgress(p.value.done, p.value.total, settings.chargingOnly) + } + } + } + if (groups.named.isEmpty() && groups.unnamed.isEmpty() && settings.people) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text(stringResource(R.string.drive_pp_people_empty), color = x.muted, modifier = Modifier.padding(16.dp)) + } + } + if (groups.named.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { PeopleHead(stringResource(R.string.drive_pp_named)) } + items(groups.named, key = { "n-${it.id}" }) { person -> PersonTile(person) { onPerson(person.id) } } + } + if (groups.unnamed.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { PeopleHead(stringResource(R.string.drive_pp_who), stringResource(R.string.drive_pp_who_hint)) } + val shown = if (allUnnamed) groups.unnamed else groups.unnamed.take(UNNAMED_FIRST) + items(shown, key = { "u-${it.id}" }) { person -> PersonTile(person) { naming = person.id } } + val more = groups.unnamed.size - shown.size + if (more > 0) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp).fillMaxWidth().heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)) + .border(1.dp, x.border2, RoundedCornerShape(12.dp)).clickable(role = Role.Button) { allUnnamed = true }, + contentAlignment = Alignment.Center, + ) { Text(pluralStringResource(R.plurals.drive_pp_more_unnamed, more, more), style = MaterialTheme.typography.titleSmall) } + } + } + } + if (groups.hidden.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(Modifier.clickable(role = Role.Button) { showHidden = !showHidden }) { + PeopleHead(stringResource(R.string.drive_pp_hidden), groups.hidden.size.toString()) + Text(stringResource(R.string.drive_pp_hidden_hint), style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(horizontal = 4.dp)) + } + } + if (showHidden) { + items(groups.hidden, key = { "h-${it.id}" }) { person -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PersonTile(person) { onPerson(person.id) } + TextButton(onClick = { viewModel.unhide(person) }) { Text(stringResource(R.string.drive_pp_unhide), color = x.accentText) } + } + } + } + } + } + } + } + naming?.let { id -> + NamePersonSheet( + personId = id, + onDismiss = { naming = null; viewModel.load() }, + onNext = { + val next = groups.unnamed.firstOrNull { it.id != id } + naming = next?.id + if (next == null) viewModel.load() + }, + ) + } +} + +private const val UNNAMED_FIRST = 6 + +/** + * One person (Drive 1.16): the face, name, photos and years, the link to a family member or contact; the unsure faces + * ("Ist das auch …?"), who is often on the photos too ("Zusammen mit", a tap shows only photos with both), the places, + * the photos by year. The menu: rename, link, merge with another person, hide. + */ +@Composable +fun DrivePersonScreen(onBack: () -> Unit, onPlace: (String, Long) -> Unit, viewModel: DrivePersonViewModel = hiltViewModel()) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val detail by viewModel.detail.collectAsStateWithLifecycle() + val uncertain by viewModel.uncertain.collectAsStateWithLifecycle() + val photos by viewModel.photos.collectAsStateWithLifecycle() + val with by viewModel.with.collectAsStateWithLifecycle() + val id by viewModel.id.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val all by viewModel.all.collectAsStateWithLifecycle() + var menu by remember { mutableStateOf(false) } + var naming by remember { mutableStateOf(false) } + var merging by remember { mutableStateOf(false) } + var linking by remember { mutableStateOf(false) } + var checking by remember { mutableStateOf(false) } + var viewing by remember { mutableStateOf(null) } + LaunchedEffect(message) { + message?.let { + Toast.makeText(context, peopleText(context, it), Toast.LENGTH_SHORT).show() + viewModel.clearMessage() + } + } + val d = (detail as? DriveLoad.Ready)?.value + val zone = ZoneId.systemDefault() + val sections = remember(photos.items) { + photos.items.groupBy { n -> DrivePlacesLogic.dayOf(n, zone)?.year }.entries.map { (year, list) -> + PhotoSection(key = year?.toString() ?: "none", title = year?.toString() ?: "–", trailing = null, items = list) + } + } + Column(Modifier.fillMaxSize()) { + GcSubpageBar("", onBack = onBack, backDescription = stringResource(R.string.drive_back)) { + Box { + GcIconButton(GcIcons.MoreVertical, contentDescription = stringResource(R.string.drive_pp_more_actions), onClick = { menu = true }, tint = MaterialTheme.colorScheme.onBackground) + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.drive_pp_rename)) }, onClick = { menu = false; naming = true }) + DropdownMenuItem(text = { Text(stringResource(R.string.drive_pp_link)) }, onClick = { menu = false; linking = true }) + if (d?.person?.contact != null) DropdownMenuItem(text = { Text(stringResource(R.string.drive_pp_unlink)) }, onClick = { menu = false; viewModel.link(null) }) + DropdownMenuItem(text = { Text(stringResource(R.string.drive_pp_merge)) }, onClick = { menu = false; merging = true }) + val hidden = d?.person?.hidden == true + DropdownMenuItem(text = { Text(stringResource(if (hidden) R.string.drive_pp_show else R.string.drive_pp_hide)) }, onClick = { menu = false; viewModel.setHidden(!hidden) }) + } + } + } + when (val s = detail) { + DriveLoad.Loading -> DriveLoading() + is DriveLoad.Error -> DriveErrorBox(s.code, onRetry = viewModel::load) + is DriveLoad.Ready -> PhotoGrid( + pages = photos, + selected = emptySet(), + onOpen = { viewing = it }, + onToggle = {}, + onMore = viewModel::more, + onRetry = viewModel::retry, + empty = stringResource(R.string.drive_photos_empty), + sections = sections, + header = { + item(span = { GridItemSpan(maxLineSpan) }) { + val p = s.value.person + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(bottom = 8.dp)) { + PersonFace(p, 88.dp) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(p.name ?: stringResource(R.string.drive_pp_no_name), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + listOfNotNull(pluralStringResource(R.plurals.drive_photos_count, p.count, p.count), DrivePlacesLogic.years(s.value.yearFrom, s.value.yearTo)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + p.contact?.let { c -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(GcIcons.Link, contentDescription = null, tint = x.muted, modifier = Modifier.size(14.dp)) + Text( + stringResource(if (c.kind == DrivePlacesJson.CONTACT_FAMILY) R.string.drive_pp_linked_family else R.string.drive_pp_linked_contact, c.name ?: p.name.orEmpty()), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + } + } + if (!p.named) { + TextButton(onClick = { naming = true }, modifier = Modifier.heightIn(min = 44.dp)) { Text(stringResource(R.string.drive_pp_name_it), color = x.warn, fontWeight = FontWeight.SemiBold) } + } + } + } + if (uncertain.isNotEmpty()) UncertainCard(p, uncertain, s.value.uncertain, onAll = { viewModel.confirm(uncertain.mapNotNull { it.faceId }, true) }, onCheck = { checking = true }) + if (s.value.together.isNotEmpty()) { + PeopleHead(stringResource(R.string.drive_pp_together), stringResource(R.string.drive_pp_together_hint)) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + s.value.together.forEach { t -> + val n = t.name ?: stringResource(R.string.drive_pp_no_name) + PersonChip(t.id, t.name, "$n · ${countText(t.count)}", selected = with == t.id, onClick = { viewModel.setWith(t.id) }) + } + } + } + if (s.value.places.isNotEmpty()) { + PeopleHead(stringResource(R.string.drive_pp_places)) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + s.value.places.forEach { pl -> TextChip("${pl.name} · ${countText(pl.count)}") { onPlace(pl.key, id) } } + } + } + Spacer(Modifier.height(4.dp)) + } + } + }, + ) + } + } + if (naming) { + NamePersonSheet(personId = id, onDismiss = { naming = false; viewModel.load() }, onNext = null, onDone = { into -> viewModel.renamed(into) }) + } + if (merging) { + PersonPicker( + title = stringResource(R.string.drive_pp_merge_pick), + people = all.filter { it.named }, + onPick = { other -> merging = false; viewModel.mergeInto(other.id) }, + onDismiss = { merging = false }, + ) + } + if (linking) { + LinkSheet(personId = id, onPick = { c -> linking = false; viewModel.link(c) }, onDismiss = { linking = false }) + } + if (checking) { + CheckFacesDialog( + name = d?.person?.name, + faces = uncertain, + onAnswer = { face, yes -> face.faceId?.let { viewModel.confirm(listOf(it), yes) } }, + onDismiss = { checking = false }, + ) + } + viewing?.let { start -> + PhotoViewer(items = photos.items, start = start, fetch = viewModel::fetch, onMore = viewModel::more, onClose = { viewing = null }) + } +} + +/** "Ist das auch Lea?": the unsure faces, "Ja, alle" or one by one. */ +@Composable +private fun UncertainCard(p: DPerson, faces: List, total: Int, onAll: () -> Unit, onCheck: () -> Unit) { + val x = GateControlTheme.extraColors + val n = maxOf(total, faces.size) + GcCard(color = x.warnBg, borderColor = androidx.compose.ui.graphics.Color.Transparent, contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp)) { + Text(if (p.named) stringResource(R.string.drive_pp_is_also, p.name!!) else stringResource(R.string.drive_pp_is_same), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + faces.take(4).forEach { f -> FaceCrop(f.item, f.box, 52.dp, "?", x.panel2) } + val more = n - faces.take(4).size + if (more > 0) Box(Modifier.size(52.dp).clip(CircleShape).background(x.panel2), contentAlignment = Alignment.Center) { + Text("+$more", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = x.muted) + } + } + Text(pluralStringResource(R.plurals.drive_pp_uncertain_text, n, n), style = MaterialTheme.typography.bodySmall, color = x.muted) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcPrimaryButton(stringResource(R.string.drive_pp_yes_all), onClick = onAll, modifier = Modifier.weight(1f)) + GcOutlineButton(stringResource(R.string.drive_pp_one_by_one), onClick = onCheck, modifier = Modifier.weight(1f)) + } + } +} + +/** The unsure faces one by one: "Ja" assigns it, "Nein" never suggests it again. */ +@Composable +private fun CheckFacesDialog(name: String?, faces: List, onAnswer: (DFaceRef, Boolean) -> Unit, onDismiss: () -> Unit) { + val face = faces.firstOrNull() + if (face == null) { + LaunchedEffect(Unit) { onDismiss() } + return + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (name != null) stringResource(R.string.drive_pp_check_title, name) else stringResource(R.string.drive_pp_is_same)) }, + text = { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + FaceCrop(face.item, face.box, 160.dp, "?", GateControlTheme.extraColors.panel2) + } + }, + confirmButton = { TextButton(onClick = { onAnswer(face, true) }) { Text(stringResource(R.string.drive_pp_yes)) } }, + dismissButton = { + Row { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.drive_cancel)) } + TextButton(onClick = { onAnswer(face, false) }) { Text(stringResource(R.string.drive_pp_no)) } + } + }, + ) +} + +/** Picks a person (to merge with). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PersonPicker(title: String, people: List, onPick: (DPerson) -> Unit, onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Text(title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + LazyColumn(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) { + items(people, key = { it.id }) { p -> + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button) { onPick(p) }.padding(horizontal = 20.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PersonFace(p, 40.dp) + Text(p.name.orEmpty(), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(countText(p.count), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + } + } + } + } +} + +/** "Mit Kontakt verknüpfen": a family member or a mail contact the app already has. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LinkSheet(personId: Long, onPick: (DPersonContact) -> Unit, onDismiss: () -> Unit) { + val vm: DriveNameViewModel = hiltViewModel(key = "link-$personId") + val state by vm.state.collectAsStateWithLifecycle() + var q by remember { mutableStateOf("") } + LaunchedEffect(personId) { vm.load(personId) } + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.drive_pp_link_pick), style = MaterialTheme.typography.titleLarge) + GcTextField(value = q, onValueChange = { q = it.take(60) }, label = stringResource(R.string.drive_pp_name_label)) + val list = remember(q, state.family, state.contacts) { DrivePlacesLogic.nameOptions(q, state.family, state.contacts, emptyMap(), max = 30) } + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + list.forEach { o -> NameOptionRow(o) { onPick(DPersonContact(o.kind, o.id, o.name)) } } + } + } + } +} + +/** A name of the family or the contacts. */ +@Composable +private fun NameOptionRow(o: DrivePlacesLogic.NameOption, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val kind = stringResource(if (o.kind == DrivePlacesJson.CONTACT_FAMILY) R.string.drive_pp_sub_family else R.string.drive_pp_sub_contact) + val sub = listOfNotNull( + kind, + o.sub, + o.namedCount?.let { pluralStringResource(R.plurals.drive_pp_named_already, it, it) }, + ).joinToString(" · ") + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PersonDot(o.id.hashCode().toLong(), o.name, 36.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(o.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(sub, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +/** + * "Wer ist das?": the person's faces, a name typed or picked from family and contacts, "Sieht aus wie …" to merge, + * "Ausblenden"; then a short note with "Rückgängig" and — on the list of people — "Nächste Person". + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NamePersonSheet(personId: Long, onDismiss: () -> Unit, onNext: (() -> Unit)?, onDone: (Long?) -> Unit = {}) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val vm: DriveNameViewModel = hiltViewModel(key = "name-$personId") + val state by vm.state.collectAsStateWithLifecycle() + var name by rememberSaveable(personId) { mutableStateOf("") } + var picked by remember(personId) { mutableStateOf(null) } + LaunchedEffect(personId) { vm.load(personId) } + LaunchedEffect(state.person?.person?.name) { if (name.isEmpty()) state.person?.person?.name?.let { name = it } } + LaunchedEffect(state.error) { + state.error?.let { + Toast.makeText(context, peopleText(context, it), Toast.LENGTH_SHORT).show() + vm.clearError() + } + } + LaunchedEffect(state.done) { state.done?.let { onDone(it.into) } } + val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + val done = state.done + val detail = state.person + if (done == null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val p = detail?.person + if (p != null) PersonFace(p, 56.dp) else Box(Modifier.size(56.dp).clip(CircleShape).background(x.panel2)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.drive_pp_who), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + if (detail != null) { + val often = detail.together.firstOrNull { !it.name.isNullOrBlank() }?.name + Text( + listOfNotNull( + pluralStringResource(R.plurals.drive_photos_count, detail.person.count, detail.person.count), + DrivePlacesLogic.years(detail.yearFrom, detail.yearTo), + often?.let { stringResource(R.string.drive_pp_often_with, it) }, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + } + } + } + GcTextField( + value = name, + onValueChange = { name = it.take(DrivePlacesJson.NAME_MAX); picked = null }, + label = stringResource(R.string.drive_pp_name_label), + placeholder = stringResource(R.string.drive_pp_name_example), + ) + val options = remember(name, state.family, state.contacts, state.named) { vm.options(name) } + if (options.isNotEmpty()) { + Column { + Text(stringResource(R.string.drive_pp_name_sugs).uppercase(), style = MaterialTheme.typography.labelSmall, color = x.muted, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)) + options.forEach { o -> NameOptionRow(o) { name = o.name; picked = o } } + } + } + state.similar?.let { sim -> + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(x.panel2).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PersonDot(sim.id, sim.name, 44.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.drive_pp_looks_like, sim.name.orEmpty()), style = MaterialTheme.typography.titleSmall) + Text(stringResource(R.string.drive_pp_looks_like_hint), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + TextButton(onClick = { vm.merge(personId, sim) }, enabled = !state.busy) { Text(stringResource(R.string.drive_pp_merge_short)) } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + GcOutlineButton(stringResource(R.string.drive_pp_hide), onClick = { vm.hide(personId) }, modifier = Modifier.weight(1f), enabled = !state.busy) + GcPrimaryButton(stringResource(R.string.drive_save), onClick = { vm.save(personId, name, picked) }, modifier = Modifier.weight(1f), enabled = !state.busy && name.isNotBlank()) + } + if (state.busy) Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp)) } + } else { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(56.dp).clip(CircleShape).background(x.accentBg), contentAlignment = Alignment.Center) { + Icon(GcIcons.Check, contentDescription = null, tint = x.accentText, modifier = Modifier.size(26.dp)) + } + val title = when (done.what) { + "merge" -> stringResource(R.string.drive_pp_merged_title, done.name.orEmpty()) + "hide" -> stringResource(R.string.drive_pp_hidden_title) + else -> stringResource(R.string.drive_pp_saved_title, done.name.orEmpty()) + } + val text = when (done.what) { + "merge" -> pluralStringResource(R.plurals.drive_pp_merged_text, done.count, done.name.orEmpty(), done.count) + "hide" -> pluralStringResource(R.plurals.drive_pp_hidden_text, done.count, done.count) + else -> pluralStringResource(R.plurals.drive_pp_saved_text, done.count, done.count, done.name.orEmpty()) + } + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(text, style = MaterialTheme.typography.bodySmall, color = x.muted, textAlign = TextAlign.Center, modifier = Modifier.width(300.dp)) + Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + val undo = done.undo + if (undo != null) GcOutlineButton(stringResource(R.string.drive_snack_undo), onClick = { undo(); name = "" }, modifier = Modifier.weight(1f)) + GcPrimaryButton( + stringResource(if (onNext != null) R.string.drive_pp_next else R.string.drive_pp_close), + onClick = { if (onNext != null) onNext() else onDismiss() }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +/** A message of the people pages as a sentence. */ +internal fun peopleText(context: android.content.Context, code: String): String = when (code) { + "people_merged" -> context.getString(R.string.drive_pp_merged) + "people_deleted" -> context.getString(R.string.drive_pp_faces_deleted) + "places_filling" -> context.getString(R.string.drive_pp_fill_started) + "feature_off" -> context.getString(R.string.drive_pp_people_off) + else -> if (code.startsWith("places_deleted:")) { + val n = code.substringAfter(':').toIntOrNull() ?: 0 + context.resources.getQuantityString(R.plurals.drive_pp_places_deleted, n, n) + } else { + photosText(context, code) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt index dcdc6f9e..aff182fd 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt @@ -1,12 +1,25 @@ package com.gatecontrol.android.ui.drive import android.content.Context +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIconSquare import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow @@ -70,8 +83,8 @@ class DrivePhotoSearchViewModel @Inject constructor( val live: DriveLive, ) : ViewModel() { - /** How many in all and the hits' terms (from the first page). */ - data class Meta(val total: Int?, val terms: List) + /** How many in all, the hits' terms and the people and places the words were taken as (from the first page). */ + data class Meta(val total: Int?, val terms: List, val marks: com.gatecontrol.android.drive.DSearchMarks = com.gatecontrol.android.drive.DSearchMarks()) private var term = "" private val _meta = MutableStateFlow(Meta(null, emptyList())) @@ -79,11 +92,15 @@ class DrivePhotoSearchViewModel @Inject constructor( private val pager = PhotoPager(viewModelScope) { before -> api.photos(before = before, q = term).also { r -> - if (before == null && r is DResult.Ok) _meta.value = Meta(r.value.total, r.value.terms) + if (before == null && r is DResult.Ok) _meta.value = Meta(r.value.total, r.value.terms, r.value.marks) } } val photos: StateFlow = pager.state + private val _suggest = MutableStateFlow(null) + /** Drive 1.16: what the words typed may mean (people, places, what is in the photos). */ + val suggest: StateFlow = _suggest.asStateFlow() + private val _active = MutableStateFlow(false) /** A search is typed (else nothing shows). */ val active: StateFlow = _active.asStateFlow() @@ -96,16 +113,23 @@ class DrivePhotoSearchViewModel @Inject constructor( fun search(q: String) { job?.cancel() val t = q.trim().take(com.gatecontrol.android.drive.DriveJson.PHOTO_Q_MAX) + val suggesting = live.me.value.let { it?.has(com.gatecontrol.android.drive.DMe.FEATURE_PHOTO_PLACES) == true || it?.has(com.gatecontrol.android.drive.DMe.FEATURE_PHOTO_PEOPLE) == true } if (t.length < 2) { term = "" _active.value = false _meta.value = Meta(null, emptyList()) + _suggest.value = null + if (suggesting && t.isNotEmpty()) job = viewModelScope.launch { + delay(250) + _suggest.value = api.photoSuggest(t).valueOrNull() + } return } job = viewModelScope.launch { delay(300) term = t _active.value = true + if (suggesting) launch { _suggest.value = api.photoSuggest(t).valueOrNull() } pager.reload() } } @@ -118,27 +142,52 @@ class DrivePhotoSearchViewModel @Inject constructor( } @Composable -fun DrivePhotoSearchScreen(onBack: () -> Unit, viewModel: DrivePhotoSearchViewModel = hiltViewModel()) { +fun DrivePhotoSearchScreen( + onBack: () -> Unit, + onPerson: (Long) -> Unit = {}, + onPlace: (String) -> Unit = {}, + viewModel: DrivePhotoSearchViewModel = hiltViewModel(), +) { val photos by viewModel.photos.collectAsStateWithLifecycle() val meta by viewModel.meta.collectAsStateWithLifecycle() val active by viewModel.active.collectAsStateWithLifecycle() + val suggest by viewModel.suggest.collectAsStateWithLifecycle() + val me by viewModel.live.me.collectAsStateWithLifecycle() val x = GateControlTheme.extraColors var q by rememberSaveable { mutableStateOf("") } var viewing by remember { mutableStateOf(null) } LaunchedEffect(Unit) { if (q.isNotBlank()) viewModel.search(q) } val set: (String) -> Unit = { q = it; viewModel.search(it) } + // Drive 1.16: people and places too (suggestions while typing, the marks of a search) + val marked = me?.has(DMe.FEATURE_PHOTO_PLACES) == true || me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true + val labels = me?.has(DMe.FEATURE_PHOTO_SEARCH) == true + val suggestions = remember(suggest) { DrivePlacesLogic.suggestions(suggest) } Column(Modifier.fillMaxSize()) { - GcSubpageBar(stringResource(R.string.drive_ki_photo_search), onBack = onBack, backDescription = stringResource(R.string.drive_back)) + GcSubpageBar(stringResource(if (marked) R.string.drive_pp_search_title else R.string.drive_ki_photo_search), onBack = onBack, backDescription = stringResource(R.string.drive_back)) Column(Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { GcTextField( value = q, onValueChange = { set(it.take(com.gatecontrol.android.drive.DriveJson.PHOTO_Q_MAX)) }, - label = stringResource(R.string.drive_ki_photo_search_hint), - placeholder = stringResource(R.string.drive_ki_photo_search_example), + label = stringResource(if (marked) R.string.drive_pp_search_title else R.string.drive_ki_photo_search_hint), + placeholder = stringResource(if (marked) R.string.drive_pp_search_hint else R.string.drive_ki_photo_search_example), + trailingIcon = if (q.isEmpty()) null else ({ GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.drive_pp_clear), onClick = { set("") }, size = 40.dp, iconSize = 18.dp) }), ) + val marks = if (active) DrivePlacesLogic.marks(meta.marks) else emptyList() val chips = DriveKi.chips(q, if (active) meta.terms else emptyList()) - if (chips.isNotEmpty()) { + if (marks.isNotEmpty() || chips.isNotEmpty()) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // the people and places the words were taken as: a tap opens them, ✕ takes them out + items(marks, key = { "m-" + (it.person?.toString() ?: it.place.orEmpty()) }) { m -> + val remove = stringResource(R.string.drive_pp_mark_remove, m.name) + Row(verticalAlignment = Alignment.CenterVertically) { + if (m.person != null) { + PersonChip(m.person, m.name, m.name, selected = true, onClick = { onPerson(m.person) }) + } else { + GcFilterChip("⌖ " + m.name, selected = true, onClick = { m.place?.let(onPlace) }) + } + GcIconButton(GcIcons.Close, contentDescription = remove, onClick = { set(DrivePlacesLogic.withoutMark(q, m)) }, size = 36.dp, iconSize = 14.dp) + } + } items(chips, key = { it.term.lowercase() + it.on }) { c -> GcFilterChip( if (c.on) c.term else "+ " + c.term, @@ -156,28 +205,86 @@ fun DrivePhotoSearchScreen(onBack: () -> Unit, viewModel: DrivePhotoSearchViewMo style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) - Text(stringResource(R.string.drive_ki_photo_labels_device), style = MaterialTheme.typography.bodySmall, color = x.muted) + if (labels) Text(stringResource(R.string.drive_ki_photo_labels_device), style = MaterialTheme.typography.bodySmall, color = x.muted) } } } Box(Modifier.weight(1f).padding(top = 8.dp)) { - if (active) { + if (active || (marked && q.isNotBlank())) { PhotoGrid( - pages = photos, + pages = if (active) photos else PhotoPages(), selected = emptySet(), onOpen = { viewing = it }, onToggle = {}, onMore = viewModel::more, onRetry = viewModel::retry, empty = stringResource(R.string.drive_ki_photo_search_none), + header = if (!marked) null else ({ + item(span = { GridItemSpan(maxLineSpan) }) { + SuggestionList( + q = q, + suggestions = suggestions, + loaded = suggest != null, + onPick = { sg -> + when (sg.kind) { + DrivePlacesLogic.Suggestion.Kind.PERSON -> sg.id.toLongOrNull()?.let(onPerson) + DrivePlacesLogic.Suggestion.Kind.PLACE -> onPlace(sg.id) + DrivePlacesLogic.Suggestion.Kind.LABEL -> set(DrivePlacesLogic.complete(q, sg.name)) + } + }, + ) + } + }), ) } } - GcBanner(tone = GcTone.Info, icon = GcIcons.Info, modifier = Modifier.navigationBarsPadding().padding(12.dp)) { - Text(stringResource(R.string.drive_ki_photo_search_note), style = MaterialTheme.typography.bodySmall) + if (labels) { + GcBanner(tone = GcTone.Info, icon = GcIcons.Info, modifier = Modifier.navigationBarsPadding().padding(12.dp)) { + Text(stringResource(R.string.drive_ki_photo_search_note), style = MaterialTheme.typography.bodySmall) + } } } viewing?.let { start -> PhotoViewer(items = photos.items, start = start, fetch = viewModel::fetch, onMore = viewModel::more, onClose = { viewing = null }) } } + +/** "Vorschläge": people, places and what is in the photos that fit the words; else "Kein Ort …"; then the tip. */ +@Composable +private fun SuggestionList(q: String, suggestions: List, loaded: Boolean, onPick: (DrivePlacesLogic.Suggestion) -> Unit) { + val x = GateControlTheme.extraColors + Column(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + if (suggestions.isNotEmpty()) { + Text(stringResource(R.string.drive_pp_suggestions).uppercase(), style = MaterialTheme.typography.labelSmall, color = x.muted, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)) + suggestions.forEach { sg -> + val (icon, tint, bg) = when (sg.kind) { + DrivePlacesLogic.Suggestion.Kind.PERSON -> Triple(GcIcons.User, x.warn, x.warnBg) + DrivePlacesLogic.Suggestion.Kind.PLACE -> Triple(GcIcons.Pin, x.blue, x.blueBg) + DrivePlacesLogic.Suggestion.Kind.LABEL -> Triple(GcIcons.Photo, x.accentText, x.accentBg) + } + val sub = sg.sub ?: stringResource( + when (sg.kind) { + DrivePlacesLogic.Suggestion.Kind.PERSON -> R.string.drive_pp_kind_person + DrivePlacesLogic.Suggestion.Kind.PLACE -> R.string.drive_pp_kind_place + DrivePlacesLogic.Suggestion.Kind.LABEL -> R.string.drive_pp_kind_label + }, + ) + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button) { onPick(sg) }.padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GcIconSquare(size = 40.dp, background = bg) { Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(sg.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(sub, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(countText(sg.count), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold, color = x.muted) + } + } + } else if (loaded && q.isNotBlank()) { + Text(stringResource(R.string.drive_pp_none, q.trim()), style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(4.dp)) + } + Text(stringResource(R.string.drive_pp_tip), style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)) + } +} 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 4dc47b8f..5bcf0949 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 @@ -97,6 +97,7 @@ 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.DrivePlacesLogic import com.gatecontrol.android.drive.work.DriveWorkLogic import com.gatecontrol.android.drive.work.DriveWorkPhotos import androidx.compose.material3.SnackbarDuration @@ -160,6 +161,23 @@ internal fun DriveModeSwitch(photos: Boolean, onPhotos: (Boolean) -> Unit) { ) } +/** "N ausgewählt" of the photos, with the hint of what can be done (work photos apart: "Arbeit" / "Privat"). */ +@Composable +internal fun PhotosSelectionHead(viewModel: DrivePhotosViewModel) { + val me by viewModel.live.me.collectAsStateWithLifecycle() + val selected by viewModel.selected.collectAsStateWithLifecycle() + val timeline by viewModel.timeline.collectAsStateWithLifecycle() + val workSettings by viewModel.workSettings.collectAsStateWithLifecycle() + val localReview by viewModel.localReview.collectAsStateWithLifecycle() + val chosen = remember(selected, timeline.items) { timeline.items.filter { it.id in selected } } + val line = when { + DriveWorkLogic.reviewBar(chosen, localReview) -> stringResource(R.string.drive_work_sel_review) + workSettings.active || viewModel.work.serverClasses(me) -> 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) +} + /** * The photos of Drive (Drive 1.7): the timeline by month with "Vor einem Jahr" on top, loading on while scrolling, and * the albums. Long press ticks photos for an album; a tap shows them full screen. @@ -172,6 +190,10 @@ internal fun DrivePhotosContent( onAlbum: (String) -> Unit, onCleanup: () -> Unit = {}, onFile: ((Long) -> Unit)? = null, + /** Drive 1.16: where the search, the map, a place and the people lead (null: not shown). */ + places: PhotosPlacesNav? = null, + /** The page shows its own selection head in place of [topBar] (false: the Drive tab shows it, [PhotosSelectionHead]). */ + ownHead: Boolean = true, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -206,6 +228,16 @@ internal fun DrivePhotosContent( val workOn = workSettings.active || serverClasses var moreOpen by remember { mutableStateOf(false) } + // Drive 1.16: the people and the places above the timeline + val header: DrivePlacesHeaderViewModel = androidx.hilt.navigation.compose.hiltViewModel(key = "drive-places") + val barPeople by header.people.collectAsStateWithLifecycle() + val topPlaces by header.places.collectAsStateWithLifecycle() + val placeClusters by header.clusters.collectAsStateWithLifecycle() + val peopleOn = places != null && me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true + val placesOn = places != null && me?.has(DMe.FEATURE_PHOTO_PLACES) == true + var naming by remember { mutableStateOf(null) } + LaunchedEffect(me?.features) { if (places != null) header.load(me) } + LaunchedEffect(Unit) { viewModel.start() val cm = context.getSystemService(android.net.ConnectivityManager::class.java) @@ -263,20 +295,14 @@ internal fun DrivePhotosContent( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { - if (selecting) { - 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 && workOn) { + // ticking changes the title row only (as high either way); everything below stays, dimmed — the grid must not + // move under the finger of the long press + if (ownHead) SelectionSwap(selecting, bar = topBar, selection = { PhotosSelectionHead(viewModel) }) else topBar() + if (places != null && (placesOn || peopleOn)) PhotosSearchField(onClick = places.search, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp).inert(selecting)) + if (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) { + Row(Modifier.inert(selecting).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") }) @@ -293,8 +319,8 @@ internal fun DrivePhotosContent( 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)) { + } else if (hasAlbums || hasCamera) { + Row(Modifier.inert(selecting).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" }) GcFilterChip(stringResource(R.string.drive_photos_albums), selected = tab == "albums", onClick = { tab = "albums" }) @@ -314,7 +340,7 @@ internal fun DrivePhotosContent( BulkBusyCard(busy, viewModel.bulk::cancelBusy) } } - if (!selecting && workOn && tab != "albums") { + if (workOn && tab != "albums") Column(Modifier.inert(selecting)) { if (reviewCount > 0) { WorkBand( pluralStringResource(R.plurals.drive_work_banner, reviewCount, reviewCount), @@ -362,9 +388,32 @@ internal fun DrivePhotosContent( onMore = viewModel::more, onRetry = viewModel::refresh, empty = stringResource(R.string.drive_photos_empty), - header = if (memories.isEmpty() || selecting) null else ({ - item(span = { GridItemSpan(maxLineSpan) }) { - MemoriesRow(memories, onOpen = { m -> viewing = m.yearsAgo to 0 }) + // the header stays while ticking (else the photos below it would move up) + header = ({ + // Drive 1.16: "Personen" with "Wer ist das?", "Orte" with the map — on "Alle" only + if (places != null && cls == null) { + val (named, unnamed) = DrivePlacesLogic.bar(barPeople?.people.orEmpty()) + if (peopleOn && (named.isNotEmpty() || unnamed != null)) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column { + PhotosSectionHead(stringResource(R.string.drive_pp_people), stringResource(R.string.drive_pp_all), places.people) + PeopleBar(named, unnamed, onPerson = { places.person(it.id) }, onWho = { naming = it.id }) + } + } + } + if (placesOn && topPlaces.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(Modifier.padding(bottom = 4.dp)) { + PhotosSectionHead(stringResource(R.string.drive_pp_places), stringResource(R.string.drive_pp_map), places.map) + PlacesCard(topPlaces, placeClusters, onMap = places.map, onPlace = { places.place(it.key) }) + } + } + } + } + if (memories.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + MemoriesRow(memories, onOpen = { m -> viewing = m.yearsAgo to 0 }) + } } }), onSelect = viewModel.bulk::set, @@ -439,6 +488,17 @@ internal fun DrivePhotosContent( ) } } + naming?.let { id -> + NamePersonSheet( + personId = id, + onDismiss = { naming = null; header.load(me) }, + onNext = { + val next = DrivePlacesLogic.groups(barPeople?.people.orEmpty()).unnamed.firstOrNull { it.id != id } + naming = next?.id + if (next == null) header.load(me) + }, + ) + } creating?.let { withSelected -> AlbumNameDialog( title = stringResource(R.string.drive_album_new), @@ -503,6 +563,8 @@ internal fun PhotoGrid( onSelect: ((Set) -> Unit)? = null, /** Drive 1.13: the review mark and the work bag of a tile. */ badge: ((DNode) -> TileBadge)? = null, + /** Drive 1.16: own headings (a place's days, a person's years) instead of the months. */ + sections: List? = null, ) { val state = rememberLazyGridState() val nearEnd by remember { @@ -572,12 +634,14 @@ internal fun PhotoGrid( modifier = Modifier.fillMaxSize().then(drag), ) { header?.invoke(this) - val groups = if (months != null) DriveLogic.byMonth(pages.items) else listOf(null to pages.items) + val groups: List>> = sections?.map { it.key to it.items } + ?: if (months != null) DriveLogic.byMonth(pages.items) else listOf(null to pages.items) groups.forEachIndexed { g, (month, list) -> - if (months != null) { + if (months != null || sections != null) { item(key = "m-$g-$month", span = { GridItemSpan(maxLineSpan) }) { - val count = month?.let { months[it] } - val title = monthTitle(month) ?: stringResource(R.string.drive_photos_undated) + val section = sections?.getOrNull(g) + val count = month?.let { months?.get(it) } + val title = section?.title ?: monthTitle(month) ?: stringResource(R.string.drive_photos_undated) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { Text( if (count != null && count > 0) "$title · ${pluralStringResource(R.plurals.drive_photos_count, count, count)}" else title, @@ -585,6 +649,7 @@ internal fun PhotoGrid( color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f).padding(top = 8.dp, bottom = 4.dp, start = 4.dp).semantics { heading() }, ) + section?.trailing?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(top = 8.dp, bottom = 4.dp, end = 4.dp)) } if (selecting && onSelect != null) { val monthIds = list.map { it.id } val all = DriveBulk.allSelected(selected, monthIds) @@ -623,6 +688,9 @@ internal fun PhotoGrid( } } +/** A heading of [PhotoGrid] with its photos (Drive 1.16: a place's day with its district, a person's year). */ +internal data class PhotoSection(val key: String, val title: String, val trailing: String?, val items: List) + /** * 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". diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesScreens.kt new file mode 100644 index 00000000..e54dcfc5 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesScreens.kt @@ -0,0 +1,380 @@ +package com.gatecontrol.android.ui.drive + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.draw.shadow +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +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.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DBbox +import com.gatecontrol.android.drive.DCluster +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.theme.GateControlTheme +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +/** A box around one point (a place picked by name): about a town. */ +private fun boxAround(lat: Double, lon: Double): DBbox = DrivePlacesLogic.clamp(DBbox(lon - 0.12, lat - 0.08, lon + 0.12, lat + 0.08)) + +/** "214 Fotos · Juli 2025 – März 2026" of a place (its count and time). */ +@Composable +private fun placeLine(count: Int, from: String?, to: String?): String { + val span = DrivePlacesLogic.span(from, to, LocalConfiguration.current.locales[0] ?: Locale.ROOT) + val photos = pluralStringResource(R.plurals.drive_photos_count, count, count) + return listOfNotNull(photos, span).joinToString(" · ") +} + +/** + * "Karte" (Drive 1.16, `photoPlaces`): the photos as bundles on OpenStreetMap; moving and zooming load the bundles of + * the view (throttled). A bundle tapped shows its photos below with the time and who is on them; "Alle ansehen" opens + * the place. + */ +@Composable +fun DrivePhotoMapScreen(onBack: () -> Unit, onPlace: (String?, DBbox?) -> Unit, onPerson: (Long) -> Unit, viewModel: DriveMapViewModel = hiltViewModel()) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val clusters by viewModel.clusters.collectAsStateWithLifecycle() + val places by viewModel.places.collectAsStateWithLifecycle() + val people by viewModel.people.collectAsStateWithLifecycle() + val selected by viewModel.selected.collectAsStateWithLifecycle() + val cls by viewModel.cls.collectAsStateWithLifecycle() + val me by viewModel.live.me.collectAsStateWithLifecycle() + var q by rememberSaveable { mutableStateOf("") } + var center by remember { mutableStateOf(null) } + var fit by remember { mutableStateOf(null) } + var viewing by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { viewModel.start() } + // the first view: all places + LaunchedEffect(places.isNotEmpty()) { + if (fit == null && center == null) DrivePlacesLogic.around(places.map { it.lat to it.lon })?.let { fit = it } + } + val mapDesc = stringResource(R.string.drive_pp_map_desc) + + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + PhotoMap( + clusters = clusters, + selected = selected?.cluster, + fit = fit ?: DBbox(-20.0, 30.0, 40.0, 65.0), + center = center, + interactive = true, + onCluster = viewModel::select, + onMoved = viewModel::moved, + contentDescription = mapDesc, + modifier = Modifier.fillMaxSize(), + ) + Column(Modifier.statusBarsPadding().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.shadow(6.dp, RoundedCornerShape(14.dp)).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surface)) { + GcIconButton(GcIcons.Back, contentDescription = stringResource(R.string.drive_back), onClick = onBack, size = 44.dp, tint = MaterialTheme.colorScheme.onSurface) + } + MapSearchField(q, onChange = { q = it }, modifier = Modifier.weight(1f)) + } + // "Ort suchen": the places whose names fit, a tap shows one + val hits = remember(q, places) { + val f = DrivePlacesLogic.fold(q) + if (f.isEmpty()) emptyList() else places.filter { p -> DrivePlacesLogic.fold(p.name).let { it.startsWith(f) || it.contains(" $f") } || DrivePlacesLogic.fold(p.region.orEmpty()).startsWith(f) }.take(5) + } + if (hits.isNotEmpty()) { + Surface(shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 6.dp) { + Column(Modifier.padding(vertical = 4.dp)) { + hits.forEach { p -> + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable(role = Role.Button) { + q = "" + center = null + fit = boxAround(p.lat, p.lon) + viewModel.select(DCluster(p.lat, p.lon, p.count, p.key, p.cover)) + }.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Pin, contentDescription = null, tint = x.blue, modifier = Modifier.size(18.dp)) + Text(p.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(countText(p.count), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } + } + } + } + // Drive 1.13: private and work photos apart + if (me?.has(DMe.FEATURE_PHOTO_CLASSES) == true) { + val label = when (cls) { + "private" -> stringResource(R.string.drive_work_private) + "work" -> stringResource(R.string.drive_work_work) + else -> stringResource(R.string.drive_pp_cls_all) + } + Box(Modifier.shadow(6.dp, RoundedCornerShape(10.dp)).clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surface)) { + GcFilterChip(label, selected = cls != null, onClick = { viewModel.setClass(when (cls) { null -> "private"; "private" -> "work"; else -> null }) }) + } + } + } + val here = remember { lastLocation(context) } + if (here != null) { + Box( + Modifier.align(Alignment.CenterEnd).padding(end = 12.dp).shadow(6.dp, RoundedCornerShape(14.dp)).clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surface), + ) { + GcIconButton(GcIcons.Crosshair, contentDescription = stringResource(R.string.drive_pp_my_location), onClick = { fit = null; center = lastLocation(context) ?: here }, size = 44.dp, tint = MaterialTheme.colorScheme.onSurface) + } + } + // the sheet: the chosen bundle, else a hint + Surface( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = MaterialTheme.colorScheme.background, + shadowElevation = 12.dp, + ) { + Column(Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.align(Alignment.CenterHorizontally).width(40.dp).height(4.dp).clip(RoundedCornerShape(2.dp)).background(x.border2)) + val s = selected + if (s == null) { + Text( + stringResource(if (places.isEmpty()) R.string.drive_pp_map_empty else R.string.drive_pp_map_hint), + style = MaterialTheme.typography.bodyMedium, + color = x.muted, + ) + } else { + val summary = places.firstOrNull { it.key != null && it.key == s.cluster.key } + val name = summary?.name ?: s.items.mapNotNull { it.geo?.place?.name }.groupingBy { it }.eachCount().maxByOrNull { it.value }?.key + ?: stringResource(R.string.drive_pp_map_area) + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(placeLine(summary?.count ?: s.cluster.count, summary?.from, summary?.to), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + Box( + Modifier.heightIn(min = 40.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.primary) + .clickable(role = Role.Button) { onPlace(s.cluster.key, if (s.cluster.key == null) viewModel.filterOf(s.cluster).bbox else null) } + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { Text(stringResource(R.string.drive_pp_see_all), color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold) } + } + if (s.loading) { + Box(Modifier.fillMaxWidth().height(64.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } + } else { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + s.items.take(8).forEachIndexed { i, n -> Thumb(n, 64) { viewing = i } } + } + val who = DrivePlacesLogic.peopleCounts(s.items).mapNotNull { (id, _) -> people[id]?.let { id to it } } + if (who.isNotEmpty()) { + Row(Modifier.horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.drive_pp_on_photos), style = MaterialTheme.typography.bodySmall, color = x.muted) + who.forEach { (id, n) -> PersonChip(id, n, n, selected = false, onClick = { onPerson(id) }) } + } + } + } + } + } + } + } + viewing?.let { start -> + PhotoViewer(items = selected?.items.orEmpty(), start = start, fetch = viewModel::fetch, onMore = null, onClose = { viewing = null }) + } +} + +/** "Ort suchen" on top of the map. */ +@Composable +private fun MapSearchField(value: String, onChange: (String) -> Unit, modifier: Modifier = Modifier) { + val x = GateControlTheme.extraColors + val label = stringResource(R.string.drive_pp_map_search) + Row( + modifier.heightIn(min = 44.dp).shadow(6.dp, RoundedCornerShape(14.dp)).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surface).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Search, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) + Box(Modifier.weight(1f)) { + if (value.isEmpty()) Text(label, color = x.muted, style = MaterialTheme.typography.bodyLarge) + BasicTextField( + value = value, + onValueChange = { onChange(it.take(60)) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().semantics { contentDescription = label }, + ) + } + if (value.isNotEmpty()) { + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.drive_pp_clear), onClick = { onChange("") }, size = 32.dp, iconSize = 16.dp) + } + } +} + +/** A small square preview of a photo; a tap opens it. */ +@Composable +internal fun Thumb(node: DNode, size: Int, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val picture = rememberDrivePreview(node) + Box( + Modifier.size(size.dp).clip(RoundedCornerShape(10.dp)).background(x.panel2).clickable(role = Role.Button, onClick = onClick) + .semantics { contentDescription = node.name }, + ) { + if (picture != null) Image(picture, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } +} + +/** + * "Ort" (Drive 1.16): a small map on top, the place's name with its photos, days and districts, who is on the photos + * (a tap filters), the photos by day with the district they were taken in. + */ +@Composable +fun DrivePlaceScreen(onBack: () -> Unit, viewModel: DrivePlaceViewModel = hiltViewModel()) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val photos by viewModel.photos.collectAsStateWithLifecycle() + val summary by viewModel.summary.collectAsStateWithLifecycle() + val districts by viewModel.districts.collectAsStateWithLifecycle() + val names by viewModel.names.collectAsStateWithLifecycle() + val onPhotos by viewModel.onPhotos.collectAsStateWithLifecycle() + val filter by viewModel.people.collectAsStateWithLifecycle() + val clusters by viewModel.clusters.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + var viewing by remember { mutableStateOf(null) } + LaunchedEffect(message) { + message?.let { + Toast.makeText(context, photosText(context, it), Toast.LENGTH_SHORT).show() + viewModel.clearMessage() + } + } + val zone = ZoneId.systemDefault() + val locale = LocalConfiguration.current.locales[0] ?: Locale.ROOT + val dayFormat = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale) } + val undated = stringResource(R.string.drive_photos_undated) + val sections = remember(photos.items) { + DrivePlacesLogic.byDay(photos.items, zone).mapIndexed { i, d -> + PhotoSection( + key = "${d.date ?: "none"}-$i", + title = d.date?.format(dayFormat) ?: undated, + trailing = listOfNotNull(d.district, d.items.size.toString()).joinToString(" · "), + items = d.items, + ) + } + } + // a town's key gives its summary; a district's key (from the suggestions) the district all its photos share + val title = summary?.name + ?: photos.items.mapNotNull { it.geo?.place }.takeIf { it.isNotEmpty() }?.let { pl -> pl.map { it.district }.distinct().singleOrNull() ?: pl.first().name } + ?: stringResource(R.string.drive_pp_map_area) + val count = summary?.count ?: photos.items.size + val days = remember(photos.items) { DrivePlacesLogic.days(photos.items, zone) } + val line = listOfNotNull( + pluralStringResource(R.plurals.drive_photos_count, count, count), + if (days > 0) pluralStringResource(R.plurals.drive_pp_days, days, days) else null, + districts.sortedByDescending { it.count }.take(3).joinToString(", ") { it.name }.ifEmpty { null }, + ).joinToString(" · ") + val people = (if (filter.isEmpty()) onPhotos else onPhotos.filter { it.first in filter }.ifEmpty { filter.map { it to 0 } }) + .mapNotNull { (id, n) -> names[id]?.let { Triple(id, it, n) } } + + Column(Modifier.fillMaxSize()) { + PhotoGrid( + pages = photos, + selected = emptySet(), + onOpen = { viewing = it }, + onToggle = {}, + onMore = viewModel::more, + onRetry = viewModel::retry, + empty = stringResource(R.string.drive_photos_empty), + sections = sections, + header = { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.fillMaxWidth().height(150.dp).clip(RoundedCornerShape(16.dp)).background(x.panel2)) { + val s = summary + PhotoMap( + clusters = clusters, + selected = null, + fit = if (s != null) boxAround(s.lat, s.lon) else DrivePlacesLogic.around(photos.items.mapNotNull { n -> n.geo?.let { it.lat to it.lon } }.take(200)), + interactive = false, + onCluster = {}, + onMoved = { _, _ -> }, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + ) + Box(Modifier.padding(8.dp).clip(RoundedCornerShape(14.dp)).background(MaterialTheme.colorScheme.surface)) { + GcIconButton(GcIcons.Back, contentDescription = stringResource(R.string.drive_pp_back_map), onClick = onBack, size = 44.dp, tint = MaterialTheme.colorScheme.onSurface) + } + } + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(line, style = MaterialTheme.typography.bodySmall, color = x.muted) + } + if (busy) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp)) } + } else { + GcIconButton(GcIcons.Photo, contentDescription = stringResource(R.string.drive_pp_save_album), onClick = { viewModel.saveAsAlbum(title) }, tint = MaterialTheme.colorScheme.onBackground) + } + } + if (people.isNotEmpty()) { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + people.forEach { (id, n, c) -> + PersonChip(id, n, if (c > 0) "$n · ${countText(c)}" else n, selected = id in filter, onClick = { viewModel.togglePerson(id) }) + } + } + } + Spacer(Modifier.height(2.dp)) + } + } + }, + ) + } + viewing?.let { start -> + PhotoViewer(items = photos.items, start = start, fetch = viewModel::fetch, onMore = viewModel::more, onClose = { viewing = null }) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesSettingsScreen.kt new file mode 100644 index 00000000..433d3290 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesSettingsScreen.kt @@ -0,0 +1,214 @@ +package com.gatecontrol.android.ui.drive + +import android.Manifest +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.faces.FaceModelState +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcListRow +import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.settings.SettingsGroup +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** + * Drive › Einstellungen › "Orte und Personen" (Drive 1.16): whether places go with the photos, "Orte nachtragen", + * deleting the places; "Personen erkennen" (off until switched on, switching off deletes every face datum), "Nur beim + * Laden" of this phone, the children's folders. Only what the server offers shows. + */ +@Composable +fun DrivePlacesSettingsScreen(onBack: () -> Unit, viewModel: DrivePlacesSettingsViewModel = hiltViewModel()) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val me by viewModel.live.me.collectAsStateWithLifecycle() + val s by viewModel.settings.collectAsStateWithLifecycle() + val geo by viewModel.geo.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + val model by viewModel.model.collectAsStateWithLifecycle(FaceModelState(FaceModelState.Phase.READY)) + val message by viewModel.message.collectAsStateWithLifecycle() + var askPeopleOff by remember { mutableStateOf(false) } + var askPlacesOff by remember { mutableStateOf(false) } + var canLook by remember { mutableStateOf(viewModel.canLook()) } + val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + canLook = viewModel.canLook() + if (canLook) viewModel.fillPlaces() + } + LifecycleResumeEffect(viewModel) { + canLook = viewModel.canLook() + viewModel.load() + onPauseOrDispose { } + } + LaunchedEffect(message) { + message?.let { + Toast.makeText(context, peopleText(context, it), Toast.LENGTH_SHORT).show() + viewModel.clearMessage() + } + } + val places = me?.has(DMe.FEATURE_PHOTO_PLACES) == true + val people = me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true + + Column(Modifier.fillMaxSize()) { + GcSubpageBar(stringResource(R.string.drive_pp_settings_title), onBack = onBack, backDescription = stringResource(R.string.drive_back)) + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (places) { + SettingsGroup(stringResource(R.string.drive_pp_places)) { + GcSwitchRow( + label = stringResource(R.string.drive_pp_keep_geo), + description = stringResource(R.string.drive_pp_keep_geo_desc), + checked = s.keepGeo, + onCheckedChange = viewModel::setKeepGeo, + enabled = !busy, + ) + // only when the server can give photos without GPS data (else it does not report the switch) + s.stripGeoOnShare?.let { strip -> + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_pp_strip_geo), + description = stringResource(R.string.drive_pp_strip_geo_desc), + checked = strip, + onCheckedChange = viewModel::setStripGeo, + enabled = !busy, + ) + } + if (s.keepGeo) { + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_pp_fill), + description = when { + !canLook -> stringResource(R.string.drive_pp_fill_permission) + geo.running -> stringResource(R.string.drive_pp_fill_running, geo.looked, geo.located) + geo.lastRunAt > 0 -> stringResource(R.string.drive_pp_fill_last, geo.looked, geo.located) + else -> stringResource(R.string.drive_pp_fill_desc) + }, + onClick = { + if (canLook) { + viewModel.fillPlaces() + } else { + val read = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES else Manifest.permission.READ_EXTERNAL_STORAGE + permissions.launch(arrayOf(read, Manifest.permission.ACCESS_MEDIA_LOCATION)) + } + }, + leading = { Icon(GcIcons.Pin, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + ) + } + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_pp_delete_places), + description = stringResource(R.string.drive_pp_delete_places_desc), + titleColor = MaterialTheme.colorScheme.error, + onClick = { askPlacesOff = true }, + leading = { Icon(GcIcons.Trash, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp)) }, + ) + } + } + if (people) { + SettingsGroup(stringResource(R.string.drive_pp_people)) { + GcSwitchRow( + label = stringResource(R.string.drive_pp_people_switch), + description = stringResource(R.string.drive_pp_people_switch_desc), + checked = s.people, + onCheckedChange = { on -> if (on) viewModel.setPeople(true) else askPeopleOff = true }, + enabled = !busy, + ) + if (s.people) { + // the feature model is loaded once after switching on + if (model.phase != FaceModelState.Phase.READY) { + FaceModelCard( + model, + onMobile = { viewModel.loadModel(true) }, + onRetry = { viewModel.loadModel(false) }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_pp_charging), + description = stringResource(R.string.drive_pp_charging_desc), + checked = s.chargingOnly, + onCheckedChange = viewModel::setChargingOnly, + ) + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_pp_kids), + description = stringResource(R.string.drive_pp_kids_desc), + checked = s.peopleKids, + onCheckedChange = viewModel::setKids, + enabled = !busy, + ) + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_pp_delete_faces), + description = stringResource(R.string.drive_pp_delete_faces_desc), + titleColor = MaterialTheme.colorScheme.error, + onClick = { askPeopleOff = true }, + leading = { Icon(GcIcons.Trash, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp)) }, + ) + } + } + if (!s.people) { + GcCard(color = x.panel2, contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.drive_pp_how), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + listOf(R.string.drive_pp_how_1, R.string.drive_pp_how_5, R.string.drive_pp_how_2, R.string.drive_pp_how_3, R.string.drive_pp_how_4).forEach { + Text("• " + stringResource(it), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } + } + Text(stringResource(R.string.drive_pp_licenses), style = MaterialTheme.typography.bodySmall, color = x.faint, modifier = Modifier.padding(horizontal = 4.dp)) + } + } + } + if (askPeopleOff) { + AlertDialog( + onDismissRequest = { askPeopleOff = false }, + title = { Text(stringResource(R.string.drive_pp_off_title)) }, + text = { Text(stringResource(R.string.drive_pp_off_text)) }, + confirmButton = { TextButton(onClick = { askPeopleOff = false; viewModel.setPeople(false) }) { Text(stringResource(R.string.drive_pp_off_go), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { askPeopleOff = false }) { Text(stringResource(R.string.drive_cancel)) } }, + ) + } + if (askPlacesOff) { + AlertDialog( + onDismissRequest = { askPlacesOff = false }, + title = { Text(stringResource(R.string.drive_pp_delete_places_title)) }, + text = { Text(stringResource(R.string.drive_pp_delete_places_text)) }, + confirmButton = { TextButton(onClick = { askPlacesOff = false; viewModel.deletePlaces() }) { Text(stringResource(R.string.drive_pp_delete_places_go), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { askPlacesOff = false }) { Text(stringResource(R.string.drive_cancel)) } }, + ) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt new file mode 100644 index 00000000..8d36e348 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesUi.kt @@ -0,0 +1,457 @@ +package com.gatecontrol.android.ui.drive + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import android.location.LocationManager +import android.view.MotionEvent +import androidx.compose.foundation.Canvas as ComposeCanvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +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.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DBbox +import com.gatecontrol.android.drive.DCluster +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DPerson +import com.gatecontrol.android.drive.DPlaceSummary +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.familie.OsmAttribution +import com.gatecontrol.android.ui.familie.configureOsm +import com.gatecontrol.android.ui.theme.GateControlTheme +import org.osmdroid.events.DelayedMapListener +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Overlay +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.roundToInt + +/** Where the Photos page's places and people lead (Drive 1.16). */ +internal data class PhotosPlacesNav( + val search: () -> Unit, + val map: () -> Unit, + val place: (String) -> Unit, + val people: () -> Unit, + val person: (Long) -> Unit, +) + +// ─── faces ───────────────────────────────────────────────────────── + +/** A person's colour while no face is shown (from the id, the avatar palette). */ +internal fun personColor(id: Long): Color { + val palette = listOf(0xFFFF8080, 0xFFF5B544, 0xFF79B8FF, 0xFF46E0A0, 0xFFB79CFF, 0xFF5FD4E8, 0xFFF29DC8, 0xFFB5D96B) + return Color(palette[((id % palette.size).toInt() + palette.size) % palette.size]) +} + +/** + * A face cut from a photo's preview by its [box] (0…1, x y w h), a little wider and square, in a circle; the initials + * on the person's colour while the preview loads or when there is none. + */ +@Composable +internal fun FaceCrop(item: DNode?, box: List?, size: Dp, initials: String, color: Color, modifier: Modifier = Modifier) { + val picture = item?.let { rememberDrivePreview(it, large = true) } + Box(modifier.size(size).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { + if (picture != null && box != null && box.size == 4) { + ComposeCanvas(Modifier.size(size)) { + val iw = picture.width.toFloat() + val ih = picture.height.toFloat() + // the face with room around it, square, inside the picture + val cx = ((box[0] + box[2] / 2) * iw).toFloat() + val cy = ((box[1] + box[3] / 2) * ih).toFloat() + val side = (max(box[2] * iw, box[3] * ih) * 1.5).toFloat().coerceAtMost(minOf(iw, ih)).coerceAtLeast(8f) + val left = (cx - side / 2).coerceIn(0f, iw - side) + val top = (cy - side / 2).coerceIn(0f, ih - side) + drawImage( + picture, + srcOffset = IntOffset(left.roundToInt(), top.roundToInt()), + srcSize = IntSize(side.roundToInt(), side.roundToInt()), + dstSize = IntSize(this.size.width.roundToInt(), this.size.height.roundToInt()), + ) + } + } else { + val sp = (size.value * 0.32f).coerceIn(11f, 30f) + Text(initials, color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = sp.sp, maxLines = 1) + } + } +} + +/** The face of [p] (its cover), or its initials. */ +@Composable +internal fun PersonFace(p: DPerson, size: Dp, modifier: Modifier = Modifier) { + FaceCrop(p.cover?.item, p.cover?.box, size, DrivePlacesLogic.initials(p.name), personColor(p.id), modifier) +} + +/** "Wer ist das?" as a dashed circle with a question mark. */ +@Composable +private fun WhoCircle(size: Dp) { + val x = GateControlTheme.extraColors + Box( + Modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.surface).border(1.dp, x.warn, CircleShape), + contentAlignment = Alignment.Center, + ) { Text("?", color = x.warn, fontWeight = FontWeight.Bold, fontSize = 20.sp) } +} + +// ─── the Photos page: people and places ───────────────────────────── + +/** A heading of the Photos page with a link on the right ("Alle", "Karte"). */ +@Composable +internal fun PhotosSectionHead(title: String, link: String?, onLink: (() -> Unit)?) { + Row(Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp, start = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).semantics { heading() }) + if (link != null && onLink != null) { + TextButton(onClick = onLink, modifier = Modifier.heightIn(min = 44.dp)) { + Text(link, color = GateControlTheme.extraColors.accentText, fontWeight = FontWeight.SemiBold) + } + } + } +} + +/** "Personen": the named people as round faces, then "Wer ist das?" for someone without a name. */ +@Composable +internal fun PeopleBar(named: List, unnamed: DPerson?, onPerson: (DPerson) -> Unit, onWho: (DPerson) -> Unit) { + val x = GateControlTheme.extraColors + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + named.forEach { p -> + Column( + Modifier.width(66.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button) { onPerson(p) }.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + PersonFace(p, 58.dp) + Text(p.name.orEmpty(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (unnamed != null) { + Column( + Modifier.width(72.dp).clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button) { onWho(unnamed) }.padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + WhoCircle(58.dp) + Text(stringResource(R.string.drive_pp_who), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = x.warn, maxLines = 1) + } + } + } +} + +/** "Orte": a small map of the places (a tap opens the map), the places with most photos as chips below. */ +@Composable +internal fun PlacesCard(places: List, clusters: List, onMap: () -> Unit, onPlace: (DPlaceSummary) -> Unit) { + val x = GateControlTheme.extraColors + val desc = stringResource(R.string.drive_pp_map_open) + Box(Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(16.dp)).background(x.panel2)) { + PhotoMap( + clusters = clusters, + selected = null, + fit = DrivePlacesLogic.around(places.take(12).map { it.lat to it.lon }), + interactive = false, + onCluster = { onMap() }, + onMoved = { _, _ -> }, + contentDescription = desc, + modifier = Modifier.fillMaxWidth().height(120.dp), + ) + // a still picture: a tap anywhere opens the map + Box(Modifier.matchParentSize().clickable(role = Role.Button, onClickLabel = desc, onClick = onMap).semantics { contentDescription = desc }) + } + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + places.take(8).forEach { p -> + Box( + Modifier.height(36.dp).clip(RoundedCornerShape(10.dp)).border(1.dp, x.border2, RoundedCornerShape(10.dp)) + .clickable(role = Role.Button) { onPlace(p) }.padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, + ) { Text("${p.name} · ${countText(p.count)}", style = MaterialTheme.typography.titleSmall, maxLines = 1) } + } + } +} + +/** 1.204 in the phone's language. */ +internal fun countText(n: Int): String = java.text.NumberFormat.getIntegerInstance().format(n) + +/** The search field of the Photos page: a tap opens the search with suggestions. */ +@Composable +internal fun PhotosSearchField(onClick: () -> Unit, modifier: Modifier = Modifier) { + val x = GateControlTheme.extraColors + val hint = stringResource(R.string.drive_pp_search_hint) + Row( + modifier.fillMaxWidth().heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(x.panel2) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.drive_pp_search_title), onClick = onClick) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Search, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) + Text(hint, color = x.muted, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +// ─── the map ─────────────────────────────────────────────────────── + +/** + * OpenStreetMap with the photos' bundles (osmdroid, the same tiles, User-Agent and cache as Familie's maps; + * the attribution always shows). [fit] is shown first; [onMoved] hears the box and zoom after moving (osmdroid's + * delay, the view model throttles further); a tap on a bundle picks it. Not [interactive]: a still picture. + */ +@Composable +internal fun PhotoMap( + clusters: List, + selected: DCluster?, + fit: DBbox?, + interactive: Boolean, + onCluster: (DCluster?) -> Unit, + onMoved: (DBbox, Int) -> Unit, + contentDescription: String, + modifier: Modifier = Modifier, + center: GeoPoint? = null, +) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val density = LocalDensity.current.density + val scheme = MaterialTheme.colorScheme + val tap = rememberUpdatedState(onCluster) + val moved = rememberUpdatedState(onMoved) + val overlay = remember { ClusterOverlay(density) } + val map = remember { + configureOsm(context) + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(interactive) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + isTilesScaledToDpi = true + minZoomLevel = 2.0 + maxZoomLevel = 19.0 + controller.setZoom(3.0) + overlays.add(overlay) + // a still picture: touches end here (a tap still counts as a click for accessibility) + if (!interactive) setOnTouchListener { v, e -> if (e.action == MotionEvent.ACTION_UP) v.performClick(); true } + addMapListener( + DelayedMapListener( + object : MapListener { + override fun onScroll(event: ScrollEvent?): Boolean = report() + override fun onZoom(event: ZoomEvent?): Boolean = report() + private fun report(): Boolean { + val b = boundingBox ?: return false + moved.value(DBbox(b.lonWest, b.latSouth, b.lonEast, b.latNorth), zoomLevelDouble.roundToInt()) + return false + } + }, + 250, + ), + ) + } + } + overlay.onTap = { c -> tap.value(c) } + overlay.colors(scheme.primary.toArgb(), scheme.onPrimary.toArgb(), scheme.onSurface.toArgb(), scheme.surface.toArgb(), scheme.background.toArgb()) + + DisposableEffect(lifecycle, map) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> map.onResume() + Lifecycle.Event.ON_PAUSE -> map.onPause() + else -> Unit + } + } + lifecycle.addObserver(observer) + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) map.onResume() + onDispose { + lifecycle.removeObserver(observer) + map.onPause() + map.onDetach() + } + } + + Box(modifier.semantics { this.contentDescription = contentDescription }) { + AndroidView( + factory = { map }, + modifier = Modifier.matchParentSize(), + update = { view -> + overlay.clusters = clusters + overlay.selected = selected + // the first box (and a new one) is shown once; moving by hand is not undone + val target = center?.let { "c:${it.latitude},${it.longitude}" } ?: fit?.let { "b:${it.west},${it.south},${it.east},${it.north}" } + if (target != null && view.tag != target) { + view.tag = target + val go = { + if (center != null) { + view.controller.setZoom(14.0) + view.controller.setCenter(center) + } else if (fit != null) { + view.zoomToBoundingBox(BoundingBox(fit.north, fit.east, fit.south, fit.west), false, (24 * density).roundToInt()) + } + } + // the view's box once shown (moving reports it from then on) + val shown = { + go() + view.post { view.boundingBox?.let { b -> moved.value(DBbox(b.lonWest, b.latSouth, b.lonEast, b.latNorth), view.zoomLevelDouble.roundToInt()) } } + } + if (view.width > 0 && view.height > 0) shown() else view.addOnFirstLayoutListener { _, _, _, _, _ -> shown() } + } + view.invalidate() + }, + ) + OsmAttribution(Modifier.align(Alignment.BottomEnd)) + } +} + +/** The bundles as round labels with their count; the chosen one light. A tap on one picks it, elsewhere none. */ +private class ClusterOverlay(private val density: Float) : Overlay() { + var clusters: List = emptyList() + var selected: DCluster? = null + var onTap: (DCluster?) -> Unit = {} + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.DEFAULT_BOLD } + private var accent = 0 + private var onAccent = 0 + private var light = 0 + private var dark = 0 + private var edge = 0 + private val drawn = ArrayList>() + + fun colors(accent: Int, onAccent: Int, light: Int, dark: Int, edge: Int) { + this.accent = accent + this.onAccent = onAccent + this.light = light + this.dark = dark + this.edge = edge + } + + override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { + if (shadow) return + drawn.clear() + val p = android.graphics.Point() + text.textSize = 13 * density + ring.strokeWidth = 3 * density + ring.color = edge + for (c in clusters.sortedBy { it.count }) { + mapView.projection.toPixels(GeoPoint(c.lat, c.lon), p) + val label = countText(c.count) + val w = max(text.measureText(label) + 20 * density, 44 * density) + val h = 44 * density + val r = android.graphics.RectF(p.x - w / 2, p.y - h / 2, p.x + w / 2, p.y + h / 2) + val on = same(c, selected) + fill.color = if (on) light else accent + canvas.drawRoundRect(r, h / 2, h / 2, fill) + canvas.drawRoundRect(r, h / 2, h / 2, ring) + text.color = if (on) dark else onAccent + canvas.drawText(label, p.x.toFloat(), p.y - (text.descent() + text.ascent()) / 2, text) + drawn += c to r + } + } + + override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean { + val hit = drawn.lastOrNull { it.second.contains(e.x, e.y) }?.first + ?: drawn.minByOrNull { hypot(it.second.centerX() - e.x, it.second.centerY() - e.y) }?.takeIf { hypot(it.second.centerX() - e.x, it.second.centerY() - e.y) < 32 * density }?.first + onTap(hit) + return hit != null + } + + private fun same(a: DCluster, b: DCluster?): Boolean = + b != null && (if (a.key != null) a.key == b.key else abs(a.lat - b.lat) < 1e-9 && abs(a.lon - b.lon) < 1e-9) +} + +/** The phone's last known position (only with the location permission); null otherwise. */ +internal fun lastLocation(context: Context): GeoPoint? { + fun granted(p: String) = ContextCompat.checkSelfPermission(context, p) == PackageManager.PERMISSION_GRANTED + if (!granted(Manifest.permission.ACCESS_COARSE_LOCATION) && !granted(Manifest.permission.ACCESS_FINE_LOCATION)) return null + val lm = context.getSystemService(LocationManager::class.java) ?: return null + return try { + lm.getProviders(true).mapNotNull { lm.getLastKnownLocation(it) }.maxByOrNull { it.time }?.let { GeoPoint(it.latitude, it.longitude) } + } catch (e: SecurityException) { + null + } +} + +/** A dot for a person in a chip (their face). */ +@Composable +internal fun PersonDot(id: Long, name: String?, size: Dp = 24.dp) { + Box(Modifier.size(size).clip(CircleShape).background(personColor(id)), contentAlignment = Alignment.Center) { + Text(DrivePlacesLogic.initials(name), color = Color(0xFF0D1015), fontSize = (size.value * 0.4f).sp, fontWeight = FontWeight.Bold, maxLines = 1) + } +} + +/** A round chip with a person's dot and a text (selected: accent). */ +@Composable +internal fun PersonChip(id: Long, name: String?, text: String, selected: Boolean, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + Row( + Modifier.heightIn(min = 36.dp).clip(RoundedCornerShape(99.dp)) + .background(if (selected) x.accentBg else MaterialTheme.colorScheme.surface) + .border(1.dp, if (selected) Color.Transparent else x.border2, RoundedCornerShape(99.dp)) + .clickable(role = Role.Button, onClick = onClick) + .padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PersonDot(id, name, 28.dp) + Text(text, style = MaterialTheme.typography.titleSmall, color = if (selected) x.accentText else MaterialTheme.colorScheme.onSurface, maxLines = 1) + } +} + +/** A plain round chip (a place). */ +@Composable +internal fun TextChip(text: String, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + Box( + Modifier.height(36.dp).clip(RoundedCornerShape(99.dp)).border(1.dp, x.border2, RoundedCornerShape(99.dp)) + .clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, + ) { Text(text, style = MaterialTheme.typography.titleSmall, maxLines = 1) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt new file mode 100644 index 00000000..c3139d8e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePlacesViewModels.kt @@ -0,0 +1,642 @@ +package com.gatecontrol.android.ui.drive + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.drive.DBbox +import com.gatecontrol.android.drive.DCluster +import com.gatecontrol.android.drive.DDistrict +import com.gatecontrol.android.drive.DFaceRef +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DPeople +import com.gatecontrol.android.drive.DPerson +import com.gatecontrol.android.drive.DPersonContact +import com.gatecontrol.android.drive.DPersonDetail +import com.gatecontrol.android.drive.DPhotoFilter +import com.gatecontrol.android.drive.DPlaceSummary +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DSimilar +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveGeoBackfill +import com.gatecontrol.android.drive.DriveGeoBackfillWork +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DrivePlacesJson +import com.gatecontrol.android.drive.DrivePlacesLogic +import com.gatecontrol.android.drive.DrivePlacesPrefs +import com.gatecontrol.android.drive.DriveTransfers +import com.gatecontrol.android.drive.PlacesSettings +import com.gatecontrol.android.drive.faces.DriveFaceModel +import com.gatecontrol.android.drive.faces.DriveFaceModelWork +import com.gatecontrol.android.drive.faces.DrivePeopleWork +import com.gatecontrol.android.drive.faces.FaceModelState +import com.gatecontrol.android.familie.FamilieRepository +import com.gatecontrol.android.mail.MailPimRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +/** The people's names by id (for chips of who is on photos). */ +private fun names(people: DPeople?): Map = + people?.people.orEmpty().filter { !it.hidden && it.named }.associate { it.id to it.name!! } + +/** + * The places and people on the Photos page (Drive 1.16): the bar of people with "Wer ist das?", the places card with + * its chips. Only loaded while Drive has the features. + */ +@HiltViewModel +class DrivePlacesHeaderViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + val live: DriveLive, + private val prefs: DrivePlacesPrefs, +) : ViewModel() { + private val _people = MutableStateFlow(null) + val people: StateFlow = _people.asStateFlow() + private val _places = MutableStateFlow>(emptyList()) + val places: StateFlow> = _places.asStateFlow() + private val _clusters = MutableStateFlow>(emptyList()) + val clusters: StateFlow> = _clusters.asStateFlow() + val settings: StateFlow = prefs.settings + + private var job: Job? = null + + fun load(me: DMe?) { + job?.cancel() + val placesOn = me?.has(DMe.FEATURE_PHOTO_PLACES) == true + val peopleOn = me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true + if (!placesOn) { _places.value = emptyList(); _clusters.value = emptyList() } + if (!peopleOn) _people.value = null + if (!placesOn && !peopleOn) return + job = viewModelScope.launch { + api.photoSettings().valueOrNull()?.let(prefs::took) + if (peopleOn) { + _people.value = api.people().valueOrNull() + // new photos' faces, old ones' (in the background, WLAN and — by default — charging) + DrivePeopleWork.request(context) + } + if (placesOn) { + val list = api.photoPlaces().valueOrNull().orEmpty() + _places.value = list + val box = DrivePlacesLogic.around(list.take(12).map { it.lat to it.lon }) + _clusters.value = if (box == null) emptyList() else api.photoMap(box, zoomFor(box)).valueOrNull().orEmpty() + } + } + } + + /** A zoom that shows [b] in about 360 dp. */ + private fun zoomFor(b: DBbox): Int { + val span = maxOf(b.east - b.west, (b.north - b.south) * 1.5).coerceAtLeast(0.0001) + return (kotlin.math.ln(360.0 / span) / kotlin.math.ln(2.0) + 1).toInt().coerceIn(2, 14) + } +} + +/** "Karte" (Drive 1.16, `photoPlaces`): bundles of photos on OpenStreetMap, one chosen with its photos below. */ +@HiltViewModel +class DriveMapViewModel @Inject constructor( + private val api: DriveApi, + val live: DriveLive, + private val transfers: DriveTransfers, +) : ViewModel() { + private val _clusters = MutableStateFlow>(emptyList()) + val clusters: StateFlow> = _clusters.asStateFlow() + private val _places = MutableStateFlow>(emptyList()) + /** Every place (for "Ort suchen" and the sheet's time and count). */ + val places: StateFlow> = _places.asStateFlow() + private val _people = MutableStateFlow>(emptyMap()) + val people: StateFlow> = _people.asStateFlow() + private val _cls = MutableStateFlow(null) + /** "Privat und Arbeit" (null), "Privat", "Arbeit" (Drive 1.13 classes). */ + val cls: StateFlow = _cls.asStateFlow() + + /** The chosen bundle and its first photos (with the people on them). */ + data class Selected(val cluster: DCluster, val items: List, val loading: Boolean, val total: Int?) + + private val _selected = MutableStateFlow(null) + val selected: StateFlow = _selected.asStateFlow() + + private var loaded: DrivePlacesLogic.Loaded? = null + private var view: DrivePlacesLogic.View? = null + private var mapJob: Job? = null + private var selJob: Job? = null + private var started = false + + fun start() { + if (started) return + started = true + viewModelScope.launch { _places.value = api.photoPlaces(cls = _cls.value).valueOrNull().orEmpty() } + viewModelScope.launch { _people.value = names(api.people().valueOrNull()) } + } + + /** The map moved or zoomed: the bundles again when the view left what was loaded (throttled). */ + fun moved(bbox: DBbox, zoom: Int) { + val v = DrivePlacesLogic.View(bbox, zoom) + view = v + if (!DrivePlacesLogic.needsLoad(loaded, v)) return + mapJob?.cancel() + mapJob = viewModelScope.launch { + delay(THROTTLE_MS) + val want = DrivePlacesLogic.padded(v.bbox) + val r = api.photoMap(want, v.zoom, cls = _cls.value) + if (r is DResult.Ok) { + loaded = DrivePlacesLogic.Loaded(want, v.zoom) + _clusters.value = r.value + } + } + } + + fun setClass(c: String?) { + if (c == _cls.value) return + _cls.value = c + loaded = null + _selected.value = null + view?.let { moved(it.bbox, it.zoom) } + viewModelScope.launch { _places.value = api.photoPlaces(cls = c).valueOrNull().orEmpty() } + } + + /** A bundle tapped: its photos (the place's, else those in a small box around it). */ + fun select(c: DCluster?) { + selJob?.cancel() + if (c == null) { + _selected.value = null + return + } + _selected.value = Selected(c, emptyList(), loading = true, total = c.count) + selJob = viewModelScope.launch { + val r = api.photos(limit = 12, cls = _cls.value, filter = filterOf(c).copy(withPeople = true)) + _selected.value = Selected(c, r.valueOrNull()?.items.orEmpty(), loading = false, total = c.count) + } + } + + /** The filter of a bundle's photos: its place, else a box around it as large as a bundle at this zoom. */ + fun filterOf(c: DCluster): DPhotoFilter { + if (c.key != null) return DPhotoFilter(place = c.key) + val v = view + val half = if (v == null) 0.05 else maxOf(v.bbox.east - v.bbox.west, v.bbox.north - v.bbox.south) / 12.0 + return DPhotoFilter(bbox = DrivePlacesLogic.clamp(DBbox(c.lon - half, c.lat - half, c.lon + half, c.lat + half))) + } + + suspend fun fetch(node: DNode): DResult = transfers.fetch(node) + + companion object { + /** Moving and zooming load at most this often. */ + const val THROTTLE_MS = 450L + } +} + +/** One place ("Ort"): its photos by day with their district, who is on them (a tap filters), saved as an album. */ +@HiltViewModel +class DrivePlaceViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val api: DriveApi, + val live: DriveLive, + private val transfers: DriveTransfers, +) : ViewModel() { + val key: String = savedStateHandle.get("key").orEmpty() + private val bbox: DBbox? = savedStateHandle.get("bbox")?.split(',')?.mapNotNull { it.toDoubleOrNull() }?.takeIf { it.size == 4 } + ?.let { DBbox(it[0], it[1], it[2], it[3]) } + + private val _people = MutableStateFlow>(savedStateHandle.get("person")?.split(',')?.mapNotNull { it.toLongOrNull() }.orEmpty()) + /** The people the photos are filtered by (all of them on a photo). */ + val people: StateFlow> = _people.asStateFlow() + + private val _summary = MutableStateFlow(null) + val summary: StateFlow = _summary.asStateFlow() + private val _districts = MutableStateFlow>(emptyList()) + val districts: StateFlow> = _districts.asStateFlow() + private val _names = MutableStateFlow>(emptyMap()) + val names: StateFlow> = _names.asStateFlow() + /** Who is on the place's photos how often (from the photos loaded without the people filter). */ + private val _onPhotos = MutableStateFlow>>(emptyList()) + val onPhotos: StateFlow>> = _onPhotos.asStateFlow() + private val _clusters = MutableStateFlow>(emptyList()) + val clusters: StateFlow> = _clusters.asStateFlow() + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private fun filter() = DPhotoFilter(place = key.ifEmpty { null }, bbox = bbox.takeIf { key.isEmpty() }, people = _people.value, districts = key.isNotEmpty(), withPeople = true) + + private val pager = PhotoPager(viewModelScope) { before -> + api.photos(before = before, filter = filter()).also { r -> + if (before == null && r is DResult.Ok) { + if (r.value.districts.isNotEmpty() || _districts.value.isEmpty()) _districts.value = r.value.districts + if (_people.value.isEmpty()) _onPhotos.value = DrivePlacesLogic.peopleCounts(r.value.items) + } + } + } + val photos: StateFlow = pager.state + + init { + pager.reload() + viewModelScope.launch { + if (key.isNotEmpty()) _summary.value = api.photoPlaces().valueOrNull()?.firstOrNull { it.key == key } + _names.value = names(api.people().valueOrNull()) + val s = _summary.value + val box = when { + bbox != null -> bbox + s != null -> DrivePlacesLogic.around(listOf(s.lat to s.lon), margin = 0.0)?.let { b -> DrivePlacesLogic.clamp(DBbox(b.west - 0.08, b.south - 0.05, b.east + 0.08, b.north + 0.05)) } + else -> null + } + if (box != null) _clusters.value = api.photoMap(box, 12, filter = DPhotoFilter(place = key.ifEmpty { null })).valueOrNull().orEmpty() + } + } + + fun togglePerson(id: Long) { + _people.value = DrivePlacesLogic.toggle(_people.value, id) + pager.reload() + } + + fun more() = pager.more() + + fun retry() = pager.retry() + + fun clearMessage() { _message.value = null } + + /** "Als Album speichern": an album named after the place with its photos (as far as they are listed, at most 500). */ + fun saveAsAlbum(name: String) { + if (_busy.value) return + viewModelScope.launch { + _busy.value = true + try { + val ids = ArrayList() + var cursor: String? = null + do { + val page = api.photos(before = cursor, limit = 200, filter = filter().copy(withPeople = false, districts = false)).valueOrNull() ?: break + ids += page.items.map { it.id } + cursor = page.next + } while (cursor != null && ids.size < ALBUM_MAX) + when (val a = api.createAlbum(name.take(DrivePhotosViewModel.NAME_MAX))) { + is DResult.Ok -> { + val add = if (ids.isEmpty()) DResult.Ok(a.value) else api.addToAlbum(a.value.id, ids.take(ALBUM_MAX)) + _message.value = if (add is DResult.Ok) "photos_added:${ids.size.coerceAtMost(ALBUM_MAX)}" else add.errorCode() + } + else -> _message.value = a.errorCode() + } + } finally { + _busy.value = false + } + } + } + + suspend fun fetch(node: DNode): DResult = transfers.fetch(node) + + companion object { + private const val ALBUM_MAX = 500 + } +} + +/** "Personen": how far the faces are, the named people, those without a name, the hidden ones. */ +@HiltViewModel +class DrivePeopleViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + val live: DriveLive, + private val prefs: DrivePlacesPrefs, +) : ViewModel() { + /** The feature model's download (the banner instead of the progress while it is not here). */ + val model: kotlinx.coroutines.flow.Flow = DriveFaceModelWork.state(context) + + fun loadModel(mobile: Boolean) = if (mobile) DriveFaceModelWork.start(context, mobile = true) else DriveFaceModelWork.retry(context) + + private val _people = MutableStateFlow>(DriveLoad.Loading) + val people: StateFlow> = _people.asStateFlow() + val settings: StateFlow = prefs.settings + + fun load() { + viewModelScope.launch { + api.photoSettings().valueOrNull()?.let(prefs::took) + val r = api.people() + if (r is DResult.Ok || _people.value !is DriveLoad.Ready) _people.value = r.toLoad { it } + } + } + + /** A hidden person shown again. */ + fun unhide(p: DPerson) { + viewModelScope.launch { + api.patchPerson(p.id, hidden = false) + load() + } + } +} + +/** One person: the link, the unsure faces, who is often with them, their places, their photos by year. */ +@HiltViewModel +class DrivePersonViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val api: DriveApi, + val live: DriveLive, + private val transfers: DriveTransfers, +) : ViewModel() { + private val _id = MutableStateFlow(savedStateHandle.get("id")?.toLongOrNull() ?: 0L) + val id: StateFlow = _id.asStateFlow() + + private val _detail = MutableStateFlow>(DriveLoad.Loading) + val detail: StateFlow> = _detail.asStateFlow() + private val _uncertain = MutableStateFlow>(emptyList()) + val uncertain: StateFlow> = _uncertain.asStateFlow() + private val _with = MutableStateFlow(null) + /** "Zusammen mit": only photos with this person too. */ + val with: StateFlow = _with.asStateFlow() + private val _all = MutableStateFlow>(emptyList()) + /** Everyone (for "Mit anderer Person zusammenführen"). */ + val all: StateFlow> = _all.asStateFlow() + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + private val _gone = MutableStateFlow(false) + /** Merged into another: the page closes. */ + val gone: StateFlow = _gone.asStateFlow() + + private val pager = PhotoPager(viewModelScope) { before -> api.photos(before = before, filter = DPhotoFilter(people = listOfNotNull(_id.value, _with.value))) } + val photos: StateFlow = pager.state + + init { + load() + } + + fun load() { + viewModelScope.launch { + val r = api.person(_id.value) + if (r is DResult.Ok || _detail.value !is DriveLoad.Ready) _detail.value = r.toLoad { it } + val n = r.valueOrNull()?.uncertain ?: 0 + _uncertain.value = if (n > 0) api.uncertainFaces(_id.value, limit = UNCERTAIN_MAX).valueOrNull().orEmpty() else emptyList() + } + pager.reload(quiet = true) + viewModelScope.launch { _all.value = api.people().valueOrNull()?.people.orEmpty().filter { !it.hidden && it.id != _id.value } } + } + + fun setWith(other: Long?) { + _with.value = if (_with.value == other) null else other + pager.reload() + } + + fun more() = pager.more() + + fun retry() = pager.retry() + + fun clearMessage() { _message.value = null } + + /** "Ja, alle" / the unsure faces one by one: [yes] they are this person, else never suggested again. */ + fun confirm(faceIds: List, yes: Boolean) { + if (faceIds.isEmpty()) return + _uncertain.value = _uncertain.value.filter { it.faceId !in faceIds } + viewModelScope.launch { + val r = api.confirmFaces(_id.value, faceIds, yes) + if (r !is DResult.Ok) _message.value = r.errorCode() + load() + } + } + + fun setHidden(hidden: Boolean) = patch { api.patchPerson(_id.value, hidden = hidden) } + + fun link(contact: DPersonContact?) = patch { api.patchPerson(_id.value, contact = contact, clearContact = contact == null) } + + private fun patch(call: suspend () -> DResult<*>) { + viewModelScope.launch { + val r = call() + if (r !is DResult.Ok) _message.value = r.errorCode() + load() + } + } + + /** "Mit anderer Person zusammenführen": this one goes into [into]. */ + fun mergeInto(into: Long) { + viewModelScope.launch { + when (val r = api.mergePerson(_id.value, into)) { + is DResult.Ok -> { + _id.value = r.value.id + _message.value = "people_merged" + load() + } + else -> _message.value = r.errorCode() + } + } + } + + /** After naming: the name may have merged this person into another. */ + fun renamed(into: Long?) { + if (into != null && into != _id.value) _id.value = into + load() + } + + suspend fun fetch(node: DNode): DResult = transfers.fetch(node) + + companion object { + private const val UNCERTAIN_MAX = 60 + } +} + +/** + * "Wer ist das?": a person's name — typed or picked from the family's members and the mail contacts the app already has + * —, "Sieht aus wie …" to merge, "Ausblenden"; afterwards "Rückgängig". + */ +@HiltViewModel +class DriveNameViewModel @Inject constructor( + private val api: DriveApi, + private val familie: FamilieRepository, + private val mail: MailPimRepository, +) : ViewModel() { + data class State( + val person: DPersonDetail? = null, + val similar: DSimilar? = null, + val family: List = emptyList(), + val contacts: List = emptyList(), + val named: Map = emptyMap(), + val busy: Boolean = false, + /** What was done: `save` | `merge` | `hide`, the person it ended in and how many photos it has. */ + val done: Done? = null, + val error: String? = null, + ) + + data class Done(val what: String, val name: String?, val into: Long?, val count: Int, val undo: (() -> Unit)?) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private var loading: Job? = null + + /** The person [id] afresh (each time the sheet opens). */ + fun load(id: Long) { + loading?.cancel() + _state.value = State() + loading = viewModelScope.launch { + val detail = api.person(id).valueOrNull() + val similar = api.similarPeople(id).valueOrNull()?.filter { !it.name.isNullOrBlank() }?.maxByOrNull { it.score } + val everyone = api.people().valueOrNull()?.people.orEmpty() + val profile = runCatching { familie.cachedProfile() }.getOrNull() + val family = profile?.members.orEmpty().map { m -> DrivePlacesLogic.NameOption(DrivePlacesJson.CONTACT_FAMILY, m.userId.toString(), m.name, null) } + val contacts = mail.cards.value.map { c -> DrivePlacesLogic.NameOption(DrivePlacesJson.CONTACT_MAIL, c.id.toString(), c.name, c.company) } + _state.value = State( + person = detail, + similar = similar, + family = family, + contacts = contacts, + named = everyone.filter { it.named && it.id != id }.associate { DrivePlacesLogic.fold(it.name!!) to it.count }, + ) + } + } + + fun options(typed: String): List { + val s = _state.value + return DrivePlacesLogic.nameOptions(typed, s.family, s.contacts, s.named) + } + + /** Saves [name] (and links the person picked from family or contacts). */ + fun save(id: Long, name: String, picked: DrivePlacesLogic.NameOption?) { + val clean = name.trim().take(DrivePlacesJson.NAME_MAX) + if (clean.isEmpty() || _state.value.busy) return + val before = _state.value.person?.person + act { + val contact = picked?.takeIf { DrivePlacesLogic.fold(it.name) == DrivePlacesLogic.fold(clean) }?.let { DPersonContact(it.kind, it.id, it.name) } + when (val r = api.patchPerson(id, name = clean, contact = contact)) { + is DResult.Ok -> { + val into = r.value.mergedInto + val count = r.value.person?.count ?: before?.count ?: 0 + Done("save", clean, into ?: id, count, undo = if (into == null) ({ undo(id) { api.patchPerson(id, name = before?.name.orEmpty()) } }) else null) + } + else -> null.also { _state.value = _state.value.copy(error = r.errorCode()) } + } + } + } + + fun merge(id: Long, into: DSimilar) { + act { + when (val r = api.mergePerson(id, into.id)) { + is DResult.Ok -> Done("merge", r.value.name ?: into.name, r.value.id, r.value.count, undo = null) + else -> null.also { _state.value = _state.value.copy(error = r.errorCode()) } + } + } + } + + fun hide(id: Long) { + val count = _state.value.person?.person?.count ?: 0 + act { + when (val r = api.patchPerson(id, hidden = true)) { + is DResult.Ok -> Done("hide", null, id, count, undo = { undo(id) { api.patchPerson(id, hidden = false) } }) + else -> null.also { _state.value = _state.value.copy(error = r.errorCode()) } + } + } + } + + private fun act(call: suspend () -> Done?) { + if (_state.value.busy) return + _state.value = _state.value.copy(busy = true, error = null) + viewModelScope.launch { + val done = call() + _state.value = _state.value.copy(busy = false, done = done ?: _state.value.done) + } + } + + private fun undo(id: Long, call: suspend () -> DResult<*>) { + viewModelScope.launch { + call() + load(id) + } + } + + fun clearError() { _state.value = _state.value.copy(error = null) } +} + +/** "Orte und Personen" (Drive › Einstellungen): the person's switches, this phone's "Nur beim Laden", deleting. */ +@HiltViewModel +class DrivePlacesSettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + val live: DriveLive, + private val prefs: DrivePlacesPrefs, + private val backfill: DriveGeoBackfill, + private val faceModel: DriveFaceModel, +) : ViewModel() { + val settings: StateFlow = prefs.settings + val geo: StateFlow = prefs.geo + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + fun load() { + prefs.reload() + viewModelScope.launch { + when (val r = api.photoSettings()) { + is DResult.Ok -> prefs.took(r.value) + else -> if (!prefs.settings.value.known) _message.value = r.errorCode() + } + } + } + + fun canLook(): Boolean = backfill.canLook() + + private fun put(call: suspend () -> DResult, after: (Boolean) -> Unit = {}) { + if (_busy.value) return + viewModelScope.launch { + _busy.value = true + try { + when (val r = call()) { + is DResult.Ok -> prefs.took(r.value).also { after(true) } + else -> _message.value = r.errorCode() + } + } finally { + _busy.value = false + } + } + } + + fun setKeepGeo(on: Boolean) = put({ api.putPlacesSettings(keepGeo = on) }) + + fun setStripGeo(on: Boolean) = put({ api.putPlacesSettings(stripGeoOnShare = on) }) + + fun setKids(on: Boolean) = put({ api.putPlacesSettings(peopleKids = on) }) + + /** "Personen erkennen": on starts the phone's job; off deletes every face, person and name on the server. */ + fun setPeople(on: Boolean) = put({ api.putPlacesSettings(people = on) }) { ok -> + if (!ok) return@put + if (on) { + // the feature model first (in WLAN); its job starts the faces' job + if (faceModel.ready()) DrivePeopleWork.restart(context) else DriveFaceModelWork.start(context) + } else { + DrivePeopleWork.cancel(context) + DriveFaceModelWork.cancelAndDelete(context) + _message.value = "people_deleted" + } + } + + /** The feature model's download (only while "Personen erkennen" is on). */ + val model: kotlinx.coroutines.flow.Flow = DriveFaceModelWork.state(context) + + /** "Jetzt über mobile Daten laden" ([mobile]) or "Erneut versuchen". */ + fun loadModel(mobile: Boolean) = if (mobile) DriveFaceModelWork.start(context, mobile = true) else DriveFaceModelWork.retry(context) + + fun setChargingOnly(on: Boolean) { + prefs.setChargingOnly(on) + if (prefs.settings.value.people) DrivePeopleWork.restart(context) + } + + fun fillPlaces() { + DriveGeoBackfillWork.start(context) + _message.value = "places_filling" + } + + /** Every place of the photos this person uploaded goes. */ + fun deletePlaces() { + if (_busy.value) return + viewModelScope.launch { + _busy.value = true + try { + val r = api.deleteGeo() + _message.value = if (r is DResult.Ok) "places_deleted:${r.value}" else r.errorCode() + } finally { + _busy.value = false + } + } + } + + fun clearMessage() { _message.value = null } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSelectionSwap.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSelectionSwap.kt new file mode 100644 index 00000000..a8c37eee --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSelectionSwap.kt @@ -0,0 +1,41 @@ +package com.gatecontrol.android.ui.drive + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Constraints + +/** + * The title row of a page that ticks items: [bar] normally, [selection] (✕, "N ausgewählt", …) while [selecting] — in + * one row exactly as high as the higher of both, measured either way, so starting or ending a selection moves nothing + * below (the finger of the long press stays on the photo it pressed). + */ +@Composable +internal fun SelectionSwap(selecting: Boolean, bar: @Composable () -> Unit, selection: @Composable () -> Unit, modifier: Modifier = Modifier) { + Layout(contents = listOf(bar, selection), modifier = modifier) { (bars, heads), constraints -> + val width = constraints.maxWidth + val height = maxOf( + bars.maxOfOrNull { it.maxIntrinsicHeight(width) } ?: 0, + heads.maxOfOrNull { it.maxIntrinsicHeight(width) } ?: 0, + ).coerceIn(constraints.minHeight, constraints.maxHeight) + // only the one shown is measured and placed (the other has no input and no semantics) + val shown = (if (selecting) heads else bars).map { it.measure(Constraints(constraints.minWidth, width, height, height)) } + layout(width, height) { shown.forEach { it.placeRelative(0, 0) } } + } +} + +/** While items are ticked: dimmed, and taps and TalkBack pass it by (it stays in place, nothing below moves). */ +internal fun Modifier.inert(on: Boolean): Modifier = if (!on) this else this + .alpha(INERT_ALPHA) + .clearAndSetSemantics { } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } + +private const val INERT_ALPHA = 0.45f diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt index adb1b1e9..4f79d53c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt @@ -240,6 +240,7 @@ fun DriveSettingsScreen( onBack: () -> Unit, onBackup: () -> Unit, onFolders: () -> Unit, + onPlaces: () -> Unit = {}, viewModel: DriveSettingsViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -319,6 +320,19 @@ fun DriveSettingsScreen( ) } + // Drive 1.16: places and people of the photos + if (me?.has(DMe.FEATURE_PHOTO_PLACES) == true || me?.has(DMe.FEATURE_PHOTO_PEOPLE) == true) { + SettingsGroup(stringResource(R.string.drive_mode_photos)) { + GcListRow( + title = stringResource(R.string.drive_pp_settings_title), + description = stringResource(R.string.drive_pp_settings_row), + onClick = onPlaces, + leading = { Icon(GcIcons.Pin, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { GcChevron() }, + ) + } + } + // 2. Tresore if (me?.has(DMe.FEATURE_VAULT) == true) { SettingsGroup(stringResource(R.string.drive_vaults)) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveStorageScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveStorageScreen.kt index 81330f9a..d1295c90 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveStorageScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveStorageScreen.kt @@ -164,17 +164,19 @@ fun DriveStorageScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: val selecting = selected.isNotEmpty() && report != null androidx.activity.compose.BackHandler(enabled = selecting) { viewModel.clearSelection() } Column(Modifier.fillMaxSize()) { - if (selecting) { - SelectionHead( - count = selected.size, - line = report?.let { DriveLogic.size(DriveLogic.freed(selected, it.duplicates)) }, - allSelected = false, - onClose = viewModel::clearSelection, - onAll = null, - ) - } else { - GcSubpageBar(stringResource(R.string.drive_storage_title), onBack = onBack, backDescription = stringResource(R.string.drive_back)) - } + SelectionSwap( + selecting, + bar = { GcSubpageBar(stringResource(R.string.drive_storage_title), onBack = onBack, backDescription = stringResource(R.string.drive_back)) }, + selection = { + SelectionHead( + count = selected.size, + line = report?.let { DriveLogic.size(DriveLogic.freed(selected, it.duplicates)) }, + allSelected = false, + onClose = viewModel::clearSelection, + onAll = null, + ) + }, + ) Box(Modifier.weight(1f)) { Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).padding(bottom = 16.dp), diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt index a8a93af8..c0268e80 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt @@ -585,40 +585,47 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L val where = DriveBulk.Where(writable = { ready?.folder?.writable == true }, vault = true) Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { - if (selecting) { - SelectionHead( - count = chosen.size, - line = summaryText(DriveBulk.summary(chosen.map { it.first })), - allSelected = DriveBulk.allSelected(selected, vaultItems.map { it.first.id }), - onClose = viewModel.bulk::clear, - onAll = { viewModel.touch(); viewModel.bulk.allOrNone() }, - ) - } else GcSubpageBar(barTitle, onBack = onBack, backDescription = stringResource(R.string.drive_back)) { - if (ready != null) { - GcIconButton(GcIcons.Lock, contentDescription = stringResource(R.string.drive_vault_lock_now), onClick = viewModel::lockNow, tint = MaterialTheme.colorScheme.onBackground) - GcIconButton(GcIcons.Users, contentDescription = stringResource(R.string.drive_vault_requests), onClick = viewModel::openRequests, tint = MaterialTheme.colorScheme.onBackground) - // a new key and a new recovery key: the write right (the server's rule) - if (ready.folder.writable) { - Box { - GcIconButton(GcIcons.Key, contentDescription = stringResource(R.string.drive_vault_keys), onClick = { keysOpen = true }, tint = MaterialTheme.colorScheme.onBackground) - DropdownMenu(expanded = keysOpen, onDismissRequest = { keysOpen = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.drive_vault_rotate)) }, - leadingIcon = { Icon(GcIcons.Refresh, contentDescription = null) }, - onClick = { keysOpen = false; confirmRotate = true }, - enabled = !busy, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.drive_vault_new_recovery)) }, - leadingIcon = { Icon(GcIcons.Lifebuoy, contentDescription = null) }, - onClick = { keysOpen = false; confirmRecovery = true }, - enabled = !busy, - ) + // ticking changes the title row only (as high either way) + SelectionSwap( + selecting, + bar = { + GcSubpageBar(barTitle, onBack = onBack, backDescription = stringResource(R.string.drive_back)) { + if (ready != null) { + GcIconButton(GcIcons.Lock, contentDescription = stringResource(R.string.drive_vault_lock_now), onClick = viewModel::lockNow, tint = MaterialTheme.colorScheme.onBackground) + GcIconButton(GcIcons.Users, contentDescription = stringResource(R.string.drive_vault_requests), onClick = viewModel::openRequests, tint = MaterialTheme.colorScheme.onBackground) + // a new key and a new recovery key: the write right (the server's rule) + if (ready.folder.writable) { + Box { + GcIconButton(GcIcons.Key, contentDescription = stringResource(R.string.drive_vault_keys), onClick = { keysOpen = true }, tint = MaterialTheme.colorScheme.onBackground) + DropdownMenu(expanded = keysOpen, onDismissRequest = { keysOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.drive_vault_rotate)) }, + leadingIcon = { Icon(GcIcons.Refresh, contentDescription = null) }, + onClick = { keysOpen = false; confirmRotate = true }, + enabled = !busy, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.drive_vault_new_recovery)) }, + leadingIcon = { Icon(GcIcons.Lifebuoy, contentDescription = null) }, + onClick = { keysOpen = false; confirmRecovery = true }, + enabled = !busy, + ) + } + } } } } - } - } + }, + selection = { + SelectionHead( + count = chosen.size, + line = summaryText(DriveBulk.summary(chosen.map { it.first })), + allSelected = DriveBulk.allSelected(selected, vaultItems.map { it.first.id }), + onClose = viewModel.bulk::clear, + onAll = { viewModel.touch(); viewModel.bulk.allOrNone() }, + ) + }, + ) Column( Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/res/values-de/strings_drive10.xml b/app/src/main/res/values-de/strings_drive10.xml new file mode 100644 index 00000000..c68659b7 --- /dev/null +++ b/app/src/main/res/values-de/strings_drive10.xml @@ -0,0 +1,168 @@ + + + + Fotos durchsuchen + Ort, Person oder Inhalt – z. B. „Lea Strand“ + Suche leeren + Vorschläge + Person + Ort + Bildinhalt + Kein Ort, keine Person und kein Inhalt passt zu „%1$s“. + Tipp: Mehrere Begriffe verbinden – „Mama Hamburg“ findet Fotos mit Mama in Hamburg. + %1$s aus der Suche entfernen + + Personen + Alle + Wer ist das? + Orte + Karte + Karte öffnen + + Karte der Fotos + Ort suchen + Privat und Arbeit + Mein Standort + Alle ansehen + Auf diesen Fotos: + Fotos hier + Tippe auf eine Zahl, um die Fotos dort zu sehen. Hineinzoomen teilt die Bündel auf. + Noch keine Fotos mit Ort. Neue Fotos bekommen ihren Ort bei der Sicherung, ältere über „Orte nachtragen“ in den Einstellungen. + Zurück + Als Album speichern + + %d Tag + %d Tage + + + Einstellungen für Personen + + %d benannt + %d benannt + + + %d noch ohne Namen + %d noch ohne Namen + + Gesichter werden erkannt · %1$s von %2$s Fotos + Alle %1$s Fotos angesehen + Läuft auf diesem Handy, wenn es lädt und im WLAN ist. Kein Bild verlässt deinen Server. + Läuft auf diesem Handy, wenn es im WLAN ist. Kein Bild verlässt deinen Server. + Benannt + Benennen macht sie suchbar + Benennen + + %d weitere ohne Namen + %d weitere ohne Namen + + Ausgeblendet + Ausgeblendete Gesichter erscheinen nicht in der Suche und werden nicht mehr gefragt. Tippen zeigt sie. + Wieder zeigen + „Personen erkennen“ ist aus. Schalte es ein, damit dein Handy Gesichter in deinen Fotos findet. + Einschalten + Noch keine Personen. Sobald dein Handy Gesichter erkannt hat, erscheinen sie hier. + + Weitere Aktionen + Umbenennen + Mit Kontakt verknüpfen + Verknüpfung lösen + Mit anderer Person zusammenführen + Ausblenden + Einblenden + Ohne Namen + Verknüpft mit Familie › %1$s + Verknüpft mit Kontakt › %1$s + Ist das auch %1$s? + Ist das dieselbe Person? + + %d Foto, bei dem die Erkennung unsicher ist – etwa als Kleinkind oder im Profil. + %d Fotos, bei denen die Erkennung unsicher ist – etwa als Kleinkind oder im Profil. + + Ja, alle + Einzeln prüfen + Ist das %1$s? + Ja + Nein + Zusammen mit + Fotos mit beiden + Zusammenführen mit … + Verknüpfen mit … + Zusammengeführt. + + Name + z. B. Oma Gerda + Aus Familie und Kontakten + Familie + Kontakt + + bereits benannt (%d Foto) + bereits benannt (%d Fotos) + + oft mit %1$s + Sieht aus wie %1$s + Gleiche Person? Dann werden beide zusammengeführt. + Zusammenführen + „%1$s“ gespeichert + + %1$d Foto findest du jetzt mit der Suche nach „%2$s“. Gibt es den Namen schon, werden beide zusammengeführt. + %1$d Fotos findest du jetzt mit der Suche nach „%2$s“. Gibt es den Namen schon, werden beide zusammengeführt. + + Mit %1$s zusammengeführt + + %1$s hat jetzt %2$d Foto. + %1$s hat jetzt %2$d Fotos. + + Ausgeblendet + + Dieses Foto erscheint nicht mehr unter Personen. + Diese %d Fotos erscheinen nicht mehr unter Personen. + + Nächste Person + Fertig + + Orte und Personen + Aufnahmeort mitsichern, Gesichter erkennen + Aufnahmeort mitsichern + Die Ortsangabe des Fotos geht mit auf deinen Server – nötig für Karte und Ortssuche. + Ort beim Teilen entfernen + Freigabe-Links und GateDrop verschicken Fotos ohne Ortsangabe. + Orte nachtragen + Gesicherte Fotos ohne Ort, die noch auf diesem Handy liegen, bekommen ihren Ort. + Läuft … angesehen: %1$d · Orte gefunden: %2$d + Zuletzt angesehen: %1$d · Orte gefunden: %2$d. Tippen trägt erneut nach. + Dafür braucht die App Zugriff auf deine Fotos und deren Standorte. Tippen fragt danach. + Orte werden nachgetragen … + Alle Orte löschen + Die Orte aller Fotos, die du hochgeladen hast – die Fotos bleiben. + Alle Orte löschen? + Dein Server vergisst, wo deine Fotos aufgenommen wurden. Karte und Ortssuche zeigen sie danach nicht mehr. Die Fotos bleiben. + Orte löschen + + Ort von %d Foto gelöscht. + Orte von %d Fotos gelöscht. + + Personen erkennen + Gesichter in deinen Fotos finden, damit du nach Personen suchen kannst. + So funktioniert es + Das Handy erkennt die Gesichter selbst, im WLAN und beim Laden. + Auf deinem Server landen nur Gesichtsmerkmale (Zahlen), keine Bilder bei Dritten. + Nur du siehst deine Personen und Namen. + Beim Einschalten lädt das Handy einmalig das Erkennungsmodell (39 MB) von GitHub, ersatzweise von Hugging Face – dabei geht kein Foto mit. + Ausschalten löscht alle Gesichtsdaten. + Nur beim Laden + Die erste Erkennung vieler Fotos braucht Akku – so läuft sie nachts. Gilt für dieses Handy. + Kinderordner einbeziehen + Fotos in den Ordnern der Kinder. Standard: aus. + Alle Gesichtsdaten löschen + Namen, Gruppen und Merkmale – die Fotos bleiben. Schaltet „Personen erkennen“ aus. + Gesichtsdaten löschen? + „Personen erkennen“ wird ausgeschaltet, und dein Server löscht alle Gesichter, Personen und Namen. Die Fotos bleiben. + Ausschalten und löschen + Gesichtsdaten gelöscht. + Gesichtserkennung auf dem Handy mit YuNet (MIT-Lizenz) und SFace (Apache-Lizenz 2.0) aus OpenCV Zoo, ausgeführt mit ONNX Runtime (MIT-Lizenz). + Erkennungsmodell wird geladen · %1$s von %2$s MB + Das Erkennungsmodell (%1$s MB) wird einmalig geladen, sobald das Handy im WLAN ist. Danach beginnt die Erkennung. + Jetzt über mobile Daten laden + Das Erkennungsmodell konnte nicht geladen werden. + Das geladene Erkennungsmodell war fehlerhaft und wurde verworfen. + diff --git a/app/src/main/res/values/strings_drive10.xml b/app/src/main/res/values/strings_drive10.xml new file mode 100644 index 00000000..24ca3e18 --- /dev/null +++ b/app/src/main/res/values/strings_drive10.xml @@ -0,0 +1,168 @@ + + + + Search photos + Place, person or content – e.g. “Lea beach” + Clear search + Suggestions + Person + Place + Picture content + No place, person or content matches “%1$s”. + Tip: combine words – “Mum Hamburg” finds photos of Mum in Hamburg. + Remove %1$s from the search + + People + All + Who is this? + Places + Map + Open map + + Map of the photos + Find a place + Private and work + My location + See all + In these photos: + Photos here + Tap a number to see the photos there. Zooming in splits the bundles. + No photos with a place yet. New photos get theirs with the backup, older ones with “Add places” in the settings. + Back + Save as album + + %d day + %d days + + + Settings for people + + %d named + %d named + + + %d without a name yet + %d without a name yet + + Recognising faces · %1$s of %2$s photos + All %1$s photos looked at + Runs on this phone while it is charging and on Wi-Fi. No picture leaves your server. + Runs on this phone while it is on Wi-Fi. No picture leaves your server. + Named + Naming makes them searchable + Name + + %d more without a name + %d more without a name + + Hidden + Hidden faces do not show in the search and are not asked about again. Tap to see them. + Show again + “Recognise people” is off. Switch it on so your phone finds faces in your photos. + Switch on + No people yet. As soon as your phone has recognised faces, they show here. + + More actions + Rename + Link to a contact + Remove the link + Merge with another person + Hide + Show + No name + Linked to Family › %1$s + Linked to contact › %1$s + Is this %1$s too? + Is this the same person? + + %d photo the recognition is unsure about – as a toddler, say, or in profile. + %d photos the recognition is unsure about – as a toddler, say, or in profile. + + Yes, all + Check one by one + Is this %1$s? + Yes + No + Together with + Photos with both + Merge with … + Link to … + Merged. + + Name + e.g. Grandma Gerda + From family and contacts + Family + Contact + + already named (%d photo) + already named (%d photos) + + often with %1$s + Looks like %1$s + The same person? Then both are merged. + Merge + “%1$s” saved + + You now find %1$d photo by searching for “%2$s”. If the name exists already, both are merged. + You now find %1$d photos by searching for “%2$s”. If the name exists already, both are merged. + + Merged with %1$s + + %1$s now has %2$d photo. + %1$s now has %2$d photos. + + Hidden + + This photo no longer shows under People. + These %d photos no longer show under People. + + Next person + Done + + Places and people + Keep where photos were taken, recognise faces + Keep where photos were taken + The photo’s location goes to your server with it – needed for the map and the place search. + Remove the place when sharing + Shared links and GateDrop send photos without their location. + Add places + Backed-up photos without a place that are still on this phone get their place. + Running … looked at: %1$d · places found: %2$d + Last time looked at: %1$d · places found: %2$d. Tap to add again. + For this the app needs access to your photos and their locations. Tap to allow it. + Adding places … + Delete all places + The places of all photos you uploaded – the photos stay. + Delete all places? + Your server forgets where your photos were taken. The map and the place search no longer show them. The photos stay. + Delete places + + Place of %d photo deleted. + Places of %d photos deleted. + + Recognise people + Find faces in your photos so you can search for people. + How it works + The phone recognises the faces itself, on Wi-Fi and while charging. + Only face features (numbers) reach your server, no pictures go to third parties. + Only you see your people and names. + When switched on, the phone loads the recognition model (39 MB) once from GitHub, else from Hugging Face – no photo goes with it. + Switching off deletes all face data. + Only while charging + The first recognition of many photos needs battery – this way it runs at night. Applies to this phone. + Include the children’s folders + Photos in the children’s folders. Default: off. + Delete all face data + Names, groups and features – the photos stay. Switches “Recognise people” off. + Delete face data? + “Recognise people” is switched off and your server deletes all faces, people and names. The photos stay. + Switch off and delete + Face data deleted. + Face recognition on the phone with YuNet (MIT licence) and SFace (Apache License 2.0) from OpenCV Zoo, run with ONNX Runtime (MIT licence). + Loading the recognition model · %1$s of %2$s MB + The recognition model (%1$s MB) is loaded once as soon as the phone is on Wi-Fi. Recognition starts after that. + Load now over mobile data + The recognition model could not be loaded. + The recognition model loaded was damaged and has been discarded. + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DrivePlacesJsonTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DrivePlacesJsonTest.kt new file mode 100644 index 00000000..fc6bb6bf --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DrivePlacesJsonTest.kt @@ -0,0 +1,162 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.drive.work.GeoPoint +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Drive 1.16 (`photoPlaces`, `photoPeople`): the answers parsed and the bodies sent. */ +class DrivePlacesJsonTest { + + private fun json(s: String): JsonObject = JsonParser.parseString(s).asJsonObject + + private val item = """{ "id": 12, "areaId": "mine:7", "name": "IMG_1.jpg", "mime": "image/jpeg", "preview": true }""" + + @Test + fun `a photo item carries its place, located and its people`() { + val n = DriveJson.node( + json( + """{ "id": 12, "areaId": "mine:7", "name": "IMG_1.jpg", "located": true, "people": [3, 4, 3], + "geo": { "lat": 53.545, "lon": 9.97, "place": { "key": "geonames:2911298", "name": "Hamburg", "district": "St. Pauli", "region": "Hamburg", "country": "de" } } }""", + ), + )!! + assertEquals(DGeo(53.545, 9.97, DPlace("geonames:2911298", "Hamburg", "St. Pauli", "Hamburg", "DE")), n.geo) + assertEquals(true, n.located) + assertEquals(listOf(3L, 4L), n.people) + val plain = DriveJson.node(json(item))!! + assertNull(plain.geo) + assertNull(plain.located) + assertNull(plain.people) + // broken coordinates: no place + assertNull(DriveJson.node(json("""{ "id": 1, "areaId": "mine:7", "name": "a", "geo": { "lat": 95, "lon": 9 } }"""))!!.geo) + } + + @Test + fun `places, bundles and districts`() { + val places = DrivePlacesJson.places( + json("""{ "places": [ { "key": "geonames:1", "name": "Köln", "region": "NRW", "country": "DE", "count": 1204, "lat": 50.94, "lon": 6.96, "from": "2014-05-01T10:00:00Z", "to": "2026-10-01T10:00:00Z", "cover": $item }, { "name": "kein key" } ] }"""), + )!! + assertEquals(1, places.size) + assertEquals("Köln", places[0].name) + assertEquals(1204, places[0].count) + assertEquals(12L, places[0].cover?.id) + val clusters = DrivePlacesJson.clusters(json("""{ "clusters": [ { "lat": 53.5, "lon": 10, "count": 214, "key": "geonames:2", "cover": $item }, { "lat": 1, "lon": 2, "count": 3, "key": null }, { "lat": 1, "lon": 2, "count": 0 } ] }"""))!! + assertEquals(listOf("geonames:2", null), clusters.map { it.key }) + assertNull(DrivePlacesJson.clusters(json("{}"))) + val photos = DriveJson.photos(json("""{ "items": [], "next": null, "districts": [ { "name": "Altona", "count": 47 } ], "search": { "people": [ { "id": 7, "name": "Mama" } ], "places": [ { "key": "geonames:2", "name": "Hamburg" } ] } }"""))!! + assertEquals(listOf(DDistrict("Altona", 47)), photos.districts) + assertEquals(DSearchMarks(listOf(7L to "Mama"), listOf("geonames:2" to "Hamburg")), photos.marks) + } + + @Test + fun `people, a person, similar, unsure faces and suggestions`() { + val people = DrivePlacesJson.people( + json( + """{ "people": [ { "id": 3, "name": "Lea", "count": 812, "hidden": false, "cover": { "faceId": 9, "nodeId": 12, "box": [0.1, 0.2, 0.3, 0.4], "item": $item }, + "contact": { "kind": "family", "id": 17, "name": "Lea" } }, { "id": 4, "name": null, "count": 64 } ], "progress": { "done": 4120, "total": 6212 } }""", + ), + )!! + assertEquals(2, people.people.size) + assertEquals(DPersonContact("family", "17", "Lea"), people.people[0].contact) + assertEquals(listOf(0.1, 0.2, 0.3, 0.4), people.people[0].cover?.box) + assertFalse(people.people[1].named) + assertEquals(4120, people.done) + assertEquals(6212, people.total) + val detail = DrivePlacesJson.personDetail( + json("""{ "person": { "id": 3, "name": "Lea", "count": 812 }, "together": [ { "id": 5, "name": "Mama", "count": 230 } ], "places": [ { "key": "geonames:1", "name": "Köln", "count": 610 } ], "uncertain": 12, "years": { "from": 2016, "to": 2026 } }"""), + )!! + assertEquals("Lea", detail.person.name) + assertEquals(230, detail.together[0].count) + assertEquals("Köln", detail.places[0].name) + assertEquals(12, detail.uncertain) + assertEquals(2016, detail.yearFrom) + // the same, with the person's fields on top + assertEquals(12, DrivePlacesJson.personDetail(json("""{ "id": 3, "name": "Lea", "count": 812, "uncertain": 12 }"""))!!.uncertain) + assertEquals(DPersonPatched(null, 8), DrivePlacesJson.patched(json("""{ "person": null, "mergedInto": 8 }"""))) + assertEquals(0.51, DrivePlacesJson.similar(json("""{ "people": [ { "id": 5, "name": "Tim", "count": 120, "score": 0.51 } ] }"""))!![0].score) + val faces = DrivePlacesJson.uncertain(json("""{ "faces": [ { "faceId": 1, "box": [0.1, 0.1, 0.2, 0.2], "item": $item, "score": 0.35 }, { "faceId": 2, "box": [0, 0, 0] } ] }"""))!! + assertEquals(listOf(1L), faces.map { it.faceId }) + val s = DrivePlacesJson.suggest(json("""{ "people": [ { "id": 7, "name": "Mama", "count": 530 } ], "places": [ { "key": "geonames:2", "name": "Hamburg", "sub": "St. Pauli", "count": 214 } ], "labels": [ { "label": "Beach", "text": "Strand", "count": 340 } ] }"""))!! + assertEquals("Mama", s.people[0].name) + assertEquals("St. Pauli", s.places[0].sub) + assertEquals("Strand", s.labels[0].text) + assertNull(DrivePlacesJson.suggest(json("{}"))) + } + + @Test + fun `the photo settings with the switches of places and people`() { + val s = DriveJson.photoSettings(json("""{ "keepGeo": false, "people": true, "peopleKids": false }"""))!! + assertEquals(false, s.keepGeo) + assertEquals(true, s.people) + assertNull(s.stripGeoOnShare) + assertEquals(true, DriveJson.photoSettings(json("""{ "workFolder": null, "workSubfolders": true, "stripGeoOnShare": true }"""))!!.stripGeoOnShare) + val body = DrivePlacesJson.settingsBody(people = false) + assertEquals("""{"people":false}""", body.toString()) + } + + @Test + fun `the commit and the geo route send where a photo was taken`() { + val commit = DriveJson.commitBody("m123456", 5, "a.jpg", null, "rename", geo = GeoPoint(53.5453, 9.9701)) + assertEquals(53.5453, commit.getAsJsonObject("geo").get("lat").asDouble) + assertFalse(DriveJson.commitBody("m123456", 5, "a.jpg", null, "rename").has("geo")) + assertNull(DrivePlacesJson.geoBody(GeoPoint(0.0, 0.0))) + assertNull(DrivePlacesJson.geoBody(GeoPoint(91.0, 0.0))) + assertEquals("""{"none":true}""", DrivePlacesJson.noGeoBody().toString()) + } + + @Test + fun `filters go into the query`() { + val q = DriveJson.photosQuery(q = "mama hamburg", filter = DPhotoFilter(place = "geonames:2", people = listOf(3, 4, 3), districts = true, withPeople = true))!! + assertEquals("geonames:2", q["place"]) + assertEquals("3,4", q["person"]) + assertEquals("1", q["districts"]) + assertEquals("people", q["with"]) + assertEquals("mama hamburg", q["q"]) + assertEquals("6.5,50,7.25,51.000001", DrivePlacesJson.bbox(DBbox(6.5, 50.0, 7.25, 51.000001))) + assertNull(DrivePlacesJson.bbox(DBbox(0.0, 10.0, 1.0, 5.0))) + val unfaced = DriveJson.photosQuery(filter = DPhotoFilter(unfacedModel = "sface-2021dec"))!! + assertEquals("1", unfaced["unfaced"]) + assertEquals("sface-2021dec", unfaced["model"]) + assertEquals("1", DriveJson.photosQuery(filter = DPhotoFilter(unlocated = true))!!["unlocated"]) + // an invalid place key: no query at all + assertNull(DriveJson.photosQuery(filter = DPhotoFilter(place = "a b"))) + // districts only with a place + assertFalse(DriveJson.photosQuery(filter = DPhotoFilter(districts = true))!!.containsKey("districts")) + } + + @Test + fun `faces go up with box, points, score and the embedding, at most 50`() { + val f = DFaceUpload(listOf(0.1f, 0.2f, 0.3f, 0.4f), List(10) { 0.5f }, 0.93f, "AAAA") + val body = DrivePlacesJson.facesBody("sface-2021dec", List(60) { f }) + assertEquals("sface-2021dec", body.get("model").asString) + val faces = body.getAsJsonArray("faces") + assertEquals(50, faces.size()) + val first = faces[0].asJsonObject + assertEquals(4, first.getAsJsonArray("box").size()) + assertEquals(5, first.getAsJsonArray("landmarks").size()) + assertEquals(2, first.getAsJsonArray("landmarks")[0].asJsonArray.size()) + assertEquals("AAAA", first.get("embedding").asString) + assertEquals(0.93, first.get("score").asDouble, 1e-6) + // none found: an empty list (looked at) + assertEquals("""{"model":"sface-2021dec","faces":[]}""", DrivePlacesJson.facesBody("sface-2021dec", emptyList()).toString()) + } + + @Test + fun `person changes send only what is given`() { + assertEquals("""{"name":"Oma Gerda"}""", DrivePlacesJson.personPatchBody(name = " Oma Gerda ").toString()) + assertEquals("""{"hidden":true}""", DrivePlacesJson.personPatchBody(hidden = true).toString()) + assertEquals("""{"contact":null}""", DrivePlacesJson.personPatchBody(clearContact = true).toString()) + val linked = DrivePlacesJson.personPatchBody(contact = DPersonContact("contact", "42", "Klaus")) + assertEquals("contact", linked.getAsJsonObject("contact").get("kind").asString) + assertEquals("""{"faceIds":[1,2],"yes":false}""", DrivePlacesJson.confirmBody(listOf(1, 2, 1), false).toString()) + assertEquals("""{"person":null}""", DrivePlacesJson.moveBody(null).toString()) + assertEquals("""{"into":9}""", DrivePlacesJson.mergeBody(9).toString()) + assertNotNull(DrivePlacesJson.placeKey("geonames:2911298")) + assertTrue(DrivePlacesJson.placeKey("../x y") == null) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/DrivePlacesLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DrivePlacesLogicTest.kt new file mode 100644 index 00000000..784e3e13 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DrivePlacesLogicTest.kt @@ -0,0 +1,189 @@ +package com.gatecontrol.android.drive + +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.LocalDate +import java.time.ZoneId +import java.util.Locale + +/** Drive 1.16: the map's bundles, a place's days, the search's suggestions and marks, the people filter, naming. */ +class DrivePlacesLogicTest { + + private val zone = ZoneId.of("Europe/Berlin") + + private fun node(id: Long, taken: String?, district: String? = null, people: List? = null) = DNode( + id, "mine:7", 2, "p$id.jpg", false, 10, "image/jpeg", null, 1, null, null, null, + takenAt = taken, + geo = district?.let { DGeo(53.5, 10.0, DPlace("geonames:1", "Hamburg", it)) }, + people = people, + ) + + private fun person(id: Long, name: String?, count: Int, hidden: Boolean = false) = DPerson(id, name, count, hidden, null, null) + + // ─── the map ──────────────────────────────────────────────────── + + @Test + fun `clamp keeps Mercator's latitudes and takes a box over the date line as the whole width`() { + assertEquals(DBbox(-10.0, -85.0, 10.0, 85.0), DrivePlacesLogic.clamp(DBbox(-10.0, -89.0, 10.0, 89.0))) + assertEquals(DBbox(-180.0, 10.0, 180.0, 20.0), DrivePlacesLogic.clamp(DBbox(170.0, 10.0, -170.0, 20.0))) + assertEquals(DBbox(-180.0, 10.0, 180.0, 20.0), DrivePlacesLogic.clamp(DBbox(-200.0, 10.0, 200.0, 20.0))) + } + + @Test + fun `padded grows by half on every side`() { + val p = DrivePlacesLogic.padded(DBbox(10.0, 50.0, 12.0, 52.0)) + assertEquals(DBbox(9.0, 49.0, 13.0, 53.0), p) + } + + @Test + fun `bundles load again only for another zoom or when the view leaves what was loaded`() { + val view = DrivePlacesLogic.View(DBbox(10.0, 50.0, 12.0, 52.0), 8) + assertTrue(DrivePlacesLogic.needsLoad(null, view)) + val loaded = DrivePlacesLogic.Loaded(DrivePlacesLogic.padded(view.bbox), 8) + assertFalse(DrivePlacesLogic.needsLoad(loaded, view)) + // a little to the east: still inside the padded box + assertFalse(DrivePlacesLogic.needsLoad(loaded, view.copy(bbox = DBbox(10.5, 50.0, 12.5, 52.0)))) + // far to the east: out of it + assertTrue(DrivePlacesLogic.needsLoad(loaded, view.copy(bbox = DBbox(12.0, 50.0, 14.0, 52.0)))) + // zoomed in: the bundles split, load again + assertTrue(DrivePlacesLogic.needsLoad(loaded, view.copy(zoom = 9))) + } + + @Test + fun `around spans the points with a margin, none without points`() { + assertNull(DrivePlacesLogic.around(emptyList())) + val b = DrivePlacesLogic.around(listOf(50.0 to 6.0, 54.0 to 10.0))!! + assertEquals(5.4, b.west, 1e-9) + assertEquals(49.4, b.south, 1e-9) + assertEquals(10.6, b.east, 1e-9) + assertEquals(54.6, b.north, 1e-9) + // one point: a small box, not a line + val one = DrivePlacesLogic.around(listOf(50.0 to 6.0))!! + assertTrue(one.east > one.west && one.north > one.south) + } + + // ─── days and time ────────────────────────────────────────────── + + @Test + fun `byDay groups as the photos come, each day with its most frequent district`() { + val items = listOf( + node(1, "2026-03-14T15:00:00Z", "Landungsbrücken"), + node(2, "2026-03-14T09:00:00Z", "Landungsbrücken"), + node(3, "2026-03-14T08:00:00Z", "Altona"), + node(4, "2026-03-13T20:00:00Z", "HafenCity"), + node(5, null), + ) + val days = DrivePlacesLogic.byDay(items, zone) + assertEquals(listOf(LocalDate.of(2026, 3, 14), LocalDate.of(2026, 3, 13), null), days.map { it.date }) + assertEquals(listOf(3, 1, 1), days.map { it.items.size }) + assertEquals(listOf("Landungsbrücken", "HafenCity", null), days.map { it.district }) + assertEquals(2, DrivePlacesLogic.days(items, zone)) + // just after midnight in Berlin is the next day + assertEquals(LocalDate.of(2026, 3, 15), DrivePlacesLogic.dayOf(node(9, "2026-03-14T23:30:00Z"), zone)) + } + + @Test + fun `span names a month, months of a year, or years`() { + val de = Locale.GERMAN + assertEquals("Juli 2025", DrivePlacesLogic.span("2025-07-03T10:00:00Z", "2025-07-20T10:00:00Z", de, zone)) + assertEquals("März – Juli 2025", DrivePlacesLogic.span("2025-03-03T10:00:00Z", "2025-07-20T10:00:00Z", de, zone)) + assertEquals("2014 – 2026", DrivePlacesLogic.span("2014-05-01T10:00:00Z", "2026-03-01T10:00:00Z", de, zone)) + assertEquals("August 2023", DrivePlacesLogic.span("2023-08-10", null, de, zone)) + assertNull(DrivePlacesLogic.span(null, null, de, zone)) + assertEquals("2016 – 2026", DrivePlacesLogic.years(2026, 2016)) + assertEquals("2026", DrivePlacesLogic.years(2026, 2026)) + assertEquals("2020", DrivePlacesLogic.years(null, 2020)) + assertNull(DrivePlacesLogic.years(null, null)) + } + + // ─── people ───────────────────────────────────────────────────── + + @Test + fun `peopleCounts counts who is on the photos, most first, and toggle filters`() { + val items = listOf(node(1, null, people = listOf(7, 8)), node(2, null, people = listOf(8)), node(3, null)) + assertEquals(listOf(8L to 2, 7L to 1), DrivePlacesLogic.peopleCounts(items)) + assertEquals(listOf(7L), DrivePlacesLogic.toggle(emptyList(), 7)) + assertEquals(listOf(8L), DrivePlacesLogic.toggle(listOf(7, 8), 7)) + } + + @Test + fun `groups order named, unnamed and hidden people, the bar asks for the biggest unnamed`() { + val people = listOf( + person(1, "Tim", 120), person(2, null, 41), person(3, "Lea", 812), person(4, null, 64), + person(5, "Fremder", 3, hidden = true), person(6, "anna", 120), + ) + val g = DrivePlacesLogic.groups(people) + assertEquals(listOf(3L, 6L, 1L), g.named.map { it.id }) + assertEquals(listOf(4L, 2L), g.unnamed.map { it.id }) + assertEquals(listOf(5L), g.hidden.map { it.id }) + val (bar, who) = DrivePlacesLogic.bar(people, max = 2) + assertEquals(listOf(3L, 6L), bar.map { it.id }) + assertEquals(4L, who?.id) + } + + @Test + fun `initials of one or two words`() { + assertEquals("OG", DrivePlacesLogic.initials("Oma Gerda")) + assertEquals("L", DrivePlacesLogic.initials("lea")) + assertEquals("ÄB", DrivePlacesLogic.initials("Ärmel Bauer Clara")) + assertEquals("?", DrivePlacesLogic.initials(null)) + } + + // ─── search ───────────────────────────────────────────────────── + + @Test + fun `suggestions list people, places, then what is in the photos`() { + val s = DSuggest( + people = listOf(DPersonCount(7, "Mama", 530)), + places = listOf(DSuggestPlace("geonames:2911298", "Hamburg", "St. Pauli, Altona", 214)), + labels = listOf(DSuggestLabel("Beach", "Strand", 340)), + ) + val list = DrivePlacesLogic.suggestions(s) + assertEquals(listOf(DrivePlacesLogic.Suggestion.Kind.PERSON, DrivePlacesLogic.Suggestion.Kind.PLACE, DrivePlacesLogic.Suggestion.Kind.LABEL), list.map { it.kind }) + assertEquals(listOf("7", "geonames:2911298", "Beach"), list.map { it.id }) + assertEquals(listOf("Mama", "Hamburg", "Strand"), list.map { it.name }) + assertTrue(DrivePlacesLogic.suggestions(null).isEmpty()) + } + + @Test + fun `complete replaces the word being typed`() { + assertEquals("mama Strand", DrivePlacesLogic.complete("mama str", "Strand")) + assertEquals("mama Strand", DrivePlacesLogic.complete("mama ", "Strand")) + assertEquals("Strand", DrivePlacesLogic.complete("", "Strand")) + } + + @Test + fun `marks become chips and their chip's cross takes their words out`() { + val marks = DrivePlacesLogic.marks(DSearchMarks(listOf(7L to "Mama"), listOf("geonames:1" to "Hamburg"))) + assertEquals(listOf(DrivePlacesLogic.Mark(7, null, "Mama"), DrivePlacesLogic.Mark(null, "geonames:1", "Hamburg")), marks) + assertEquals("hamburg strand", DrivePlacesLogic.withoutMark("mama hamburg strand", marks[0])) + assertEquals("Mama strand", DrivePlacesLogic.withoutMark("Mama Hambúrg strand", marks[1])) + assertEquals("koln", DrivePlacesLogic.fold(" Köln ")) + } + + // ─── naming ───────────────────────────────────────────────────── + + @Test + fun `name options come from family first, each name once, matched at a word's start`() { + val family = listOf( + DrivePlacesLogic.NameOption("family", "3", "Oma Gerda", null), + DrivePlacesLogic.NameOption("family", "4", "Opa Heinz", null), + ) + val contacts = listOf( + DrivePlacesLogic.NameOption("contact", "10", "Klaus Kramer", "Privat"), + DrivePlacesLogic.NameOption("contact", "11", "oma gerda", null), + DrivePlacesLogic.NameOption("contact", "12", "Lea Lorenz", "Elternbeirat"), + ) + val named = mapOf("oma gerda" to 96) + val all = DrivePlacesLogic.nameOptions("", family, contacts, named) + assertEquals(listOf("Oma Gerda", "Opa Heinz", "Klaus Kramer"), all.map { it.name }) + assertEquals(96, all[0].namedCount) + assertNull(all[1].namedCount) + assertEquals(listOf("Oma Gerda"), DrivePlacesLogic.nameOptions("ger", family, contacts, named).map { it.name }) + assertEquals(listOf("Lea Lorenz"), DrivePlacesLogic.nameOptions("lor", family, contacts, named).map { it.name }) + assertTrue(DrivePlacesLogic.nameOptions("erda", family, contacts, named).isEmpty()) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/faces/DriveFaceModelFetcherTest.kt b/app/src/test/java/com/gatecontrol/android/drive/faces/DriveFaceModelFetcherTest.kt new file mode 100644 index 00000000..b0da2b7a --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/faces/DriveFaceModelFetcherTest.kt @@ -0,0 +1,132 @@ +package com.gatecontrol.android.drive.faces + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest + +/** Drive 1.16: SFace loaded when switched on — sources in order, wrong bytes thrown away, resumed, progress. */ +class DriveFaceModelFetcherTest { + + @TempDir + lateinit var dir: File + + private val good = ByteArray(300_000) { (it * 31 % 251).toByte() } + private val sha = MessageDigest.getInstance("SHA-256").digest(good).joinToString("") { "%02x".format(it) } + + /** A server: per URL the bytes it sends (null: unreachable), an optional break after so many bytes. */ + private class Server(val files: Map, val breakAfter: Map = emptyMap()) { + val asked = ArrayList>() + + fun open(url: String, offset: Long): DriveFaceModelFetcher.Response? { + asked += url to offset + val bytes = files[url] ?: return null + if (bytes.isEmpty()) return response(404, ByteArray(0)) + val from = offset.toInt().coerceAtMost(bytes.size) + val body = bytes.copyOfRange(from, bytes.size) + val cut = breakAfter[url] + val stream: InputStream = if (cut == null) ByteArrayInputStream(body) else Breaking(body, cut) + return response(if (offset > 0) 206 else 200, ByteArray(0), stream) + } + + private fun response(code: Int, bytes: ByteArray, stream: InputStream = ByteArrayInputStream(bytes)) = object : DriveFaceModelFetcher.Response { + override val code = code + override val body = stream + override fun close() = Unit + } + } + + /** Sends [cut] bytes, then the connection breaks. */ + private class Breaking(val bytes: ByteArray, val cut: Int) : InputStream() { + var pos = 0 + override fun read(): Int = if (pos >= cut) throw IOException("reset") else if (pos >= bytes.size) -1 else bytes[pos++].toInt() and 0xFF + } + + private fun fetcher(server: Server) = DriveFaceModelFetcher(dir, "m.onnx", sha, good.size.toLong(), server::open) + + @Test + fun `the first source with the right bytes wins, earlier ones that fail are skipped in order`() { + val server = Server(mapOf("a" to ByteArray(0), "b" to null, "c" to good, "d" to good)) + val r = fetcher(server).fetch(listOf("a", "b", "c", "d")) + assertTrue(r is DriveFaceModelFetcher.Result.Done) + assertEquals(listOf("a", "b", "c"), server.asked.map { it.first }) + assertArrayEquals(good, File(dir, "m.onnx").readBytes()) + assertFalse(File(dir, "m.onnx.part").exists()) + } + + @Test + fun `wrong bytes are thrown away and the next source tried`() { + val bad = good.copyOf().also { it[1000] = (it[1000] + 1).toByte() } + val server = Server(mapOf("a" to bad, "b" to good)) + val f = fetcher(server) + assertTrue(f.fetch(listOf("a", "b")) is DriveFaceModelFetcher.Result.Done) + assertTrue(f.ready()) + // only wrong bytes: nothing stays, the reason is the hash + val dir2 = File(dir, "x") + val only = DriveFaceModelFetcher(dir2, "m.onnx", sha, good.size.toLong(), Server(mapOf("a" to bad, "b" to good.copyOf(good.size - 1)))::open) + assertEquals(DriveFaceModelFetcher.Result.Failed("hash"), only.fetch(listOf("a", "b"))) + assertFalse(File(dir2, "m.onnx").exists()) + assertFalse(File(dir2, "m.onnx.part").exists()) + } + + @Test + fun `a broken download goes on from where it stopped at the same source`() { + val server = Server(mapOf("a" to good), breakAfter = mapOf("a" to 120_000)) + val f = fetcher(server) + assertEquals(DriveFaceModelFetcher.Result.Failed("offline"), f.fetch(listOf("a"))) + assertFalse(f.ready()) + val part = File(dir, "m.onnx.part").length() + assertTrue(part in 1..120_000) + val resumed = Server(mapOf("a" to good)) + assertTrue(fetcher(resumed).fetch(listOf("a")) is DriveFaceModelFetcher.Result.Done) + assertEquals(listOf("a" to part), resumed.asked) + assertArrayEquals(good, File(dir, "m.onnx").readBytes()) + } + + @Test + fun `a part of another source is not continued`() { + fetcher(Server(mapOf("a" to good), breakAfter = mapOf("a" to 50_000))).fetch(listOf("a")) + val other = Server(mapOf("b" to good)) + assertTrue(fetcher(other).fetch(listOf("b")) is DriveFaceModelFetcher.Result.Done) + assertEquals(listOf("b" to 0L), other.asked) + } + + @Test + fun `progress counts up to the size, nothing is fetched when the file is there, delete removes all`() { + val seen = ArrayList() + val f = fetcher(Server(mapOf("a" to good))) + f.fetch(listOf("a"), progress = { done, total -> assertEquals(good.size.toLong(), total); seen += done }) + assertEquals(0L, seen.first()) + assertEquals(good.size.toLong(), seen.last()) + assertTrue(seen.zipWithNext().all { (a, b) -> b >= a }) + val again = Server(mapOf("a" to good)) + assertTrue(fetcher(again).fetch(listOf("a")) is DriveFaceModelFetcher.Result.Done) + assertTrue(again.asked.isEmpty()) + f.delete() + assertFalse(f.ready()) + assertFalse(File(dir, "m.onnx").exists()) + } + + @Test + fun `cancelled stops and keeps the part`() { + var blocks = 0 + val r = fetcher(Server(mapOf("a" to good))).fetch(listOf("a"), cancelled = { blocks++ > 2 }) + assertEquals(DriveFaceModelFetcher.Result.Failed("cancelled"), r) + assertTrue(File(dir, "m.onnx.part").exists()) + } + + @Test + fun `sources start with this version's release`() { + val s = DriveFaceModel.sources("1.75.0") + assertEquals("https://github.com/CallMeTechie/GateControl-Android-Client/releases/download/v1.75.0/face_recognition_sface_2021dec.onnx", s[0]) + assertTrue(s[1].startsWith("https://media.githubusercontent.com/media/opencv/opencv_zoo/")) + assertTrue(s[2].startsWith("https://huggingface.co/opencv/face_recognition_sface/")) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/faces/DriveFacesLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/faces/DriveFacesLogicTest.kt new file mode 100644 index 00000000..710bb0c3 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/faces/DriveFacesLogicTest.kt @@ -0,0 +1,213 @@ +package com.gatecontrol.android.drive.faces + +import org.junit.jupiter.api.Assertions.assertArrayEquals +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 kotlin.math.abs +import kotlin.math.ln +import kotlin.math.sqrt + +/** + * Drive 1.16 faces (`sface-2021dec`): the alignment as OpenCV's `FaceRecognizerSF::alignCrop`, YuNet's decoding as + * `FaceDetectorYN`, the feature's normalisation and base64. Expected values come from OpenCV 5.0 (Python), see the + * scratch file drive-face-model.md. + */ +class DriveFacesLogicTest { + + private fun close(expected: DoubleArray, actual: DoubleArray, eps: Double) { + assertEquals(expected.size, actual.size) + expected.indices.forEach { assertEquals(expected[it], actual[it], eps, "index $it") } + } + + // ─── alignment ────────────────────────────────────────────────── + + @Test + fun `similarity of the test picture's points is OpenCV's matrix`() { + // Alvaro_Uribe_0028.jpg of OpenCV Zoo's face_recognition benchmark data + val m = DriveFacesLogic.similarity(floatArrayOf(65f, 68f, 89f, 67f, 81f, 80f, 68f, 93f, 89f, 92f)) + close(doubleArrayOf(1.525415679, -0.0724858342, -57.7675225022, 0.0724858342, 1.525415679, -55.8153437199), m, 1e-4) + } + + @Test + fun `similarity of turned points rotates back and keeps mirrored points unmirrored`() { + val rot = DriveFacesLogic.similarity(floatArrayOf(300f, 200f, 340f, 240f, 300f, 260f, 250f, 270f, 280f, 300f)) + close(doubleArrayOf(0.3828282783, 0.345776347, -144.3525059567, -0.345776347, 0.3828282783, 76.3206633458), rot, 1e-4) + val mirror = DriveFacesLogic.similarity(floatArrayOf(89f, 67f, 65f, 68f, 81f, 80f, 89f, 92f, 68f, 93f)) + close(doubleArrayOf(0.25154076395, -0.023279770329, 38.167785732, 0.023279770329, 0.25154076395, 49.95240489), mirror, 1e-4) + // a similarity: a = e, b = -d (no shear, no mirroring) + assertEquals(mirror[0], mirror[4], 1e-12) + assertEquals(mirror[1], -mirror[3], 1e-12) + } + + @Test + fun `the template's own points map onto themselves`() { + val t = floatArrayOf(38.2946f, 51.6963f, 73.5318f, 51.5014f, 56.0252f, 71.7366f, 41.5493f, 92.3655f, 70.7299f, 92.2041f) + close(doubleArrayOf(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), DriveFacesLogic.similarity(t), 1e-3) + } + + @Test + fun `the inverse undoes the matrix`() { + val m = doubleArrayOf(1.5, -0.2, -50.0, 0.2, 1.5, -40.0) + val inv = DriveFacesLogic.invert(m) + val x = 30.0 + val y = 70.0 + val tx = m[0] * x + m[1] * y + m[2] + val ty = m[3] * x + m[4] * y + m[5] + assertEquals(x, inv[0] * tx + inv[1] * ty + inv[2], 1e-9) + assertEquals(y, inv[3] * tx + inv[4] * ty + inv[5], 1e-9) + } + + /** OpenCV's warpAffine of a pattern picture (BGR b = x·y, g = x² + y, r = 7x + 3y, each mod 256). */ + private fun pattern(w: Int, h: Int): IntArray = IntArray(w * h) { i -> + val x = i % w + val y = i / w + val r = (x * 7 + y * 3) % 256 + val g = (x * x + y) % 256 + val b = (x * y) % 256 + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + @Test + fun `warp samples like warpAffine INTER_LINEAR`() { + val m = doubleArrayOf(1.525415679, -0.0724858342, -57.7675225022, 0.0724858342, 1.525415679, -55.8153437199) + val out = DriveFacesLogic.warp(pattern(150, 150), 150, 150, m) + assertEquals(112 * 112 * 3, out.size) + // OpenCV 5.0: RGB at (x, y); OpenCV rounds in fixed point, so ±2 on this hard-edged pattern + val expected = mapOf( + (0 to 0) to intArrayOf(125, 61, 92), + (56 to 56) to intArrayOf(242, 21, 56), + (10 to 100) to intArrayOf(132, 170, 84), + (111 to 111) to intArrayOf(97, 149, 101), + (80 to 30) to intArrayOf(37, 212, 202), + ) + for ((at, rgb) in expected) { + val o = (at.second * 112 + at.first) * 3 + for (c in 0 until 3) assertTrue(abs(out[o + c] - rgb[c]) <= 2, "pixel $at channel $c: ${out[o + c]} vs ${rgb[c]}") + } + // the whole picture within 0.5 % of OpenCV's sum + assertEquals(4_806_068.0, out.sum().toDouble(), 4_806_068.0 * 0.005) + } + + @Test + fun `outside the picture is black`() { + val m = doubleArrayOf(1.0, 0.0, 200.0, 0.0, 1.0, 200.0) // the picture lies far right and below + val out = DriveFacesLogic.warp(IntArray(4) { -1 }, 2, 2, m) + assertTrue(out.all { it == 0 }) + } + + @Test + fun `feature input is planar RGB and detect input planar BGR`() { + val rgb = IntArray(112 * 112 * 3) { i -> i % 3 * 100 } + val f = DriveFacesLogic.featureInput(rgb) + val n = 112 * 112 + assertEquals(0f, f[0]) + assertEquals(100f, f[n]) + assertEquals(200f, f[2 * n]) + val px = intArrayOf((0xFF shl 24) or (10 shl 16) or (20 shl 8) or 30) + val d = DriveFacesLogic.detectInput(px, 1, 1) + val m = 640 * 640 + assertEquals(30f, d[0]) // B + assertEquals(20f, d[m]) // G + assertEquals(10f, d[2 * m]) // R + assertEquals(0f, d[1]) + } + + // ─── the feature ──────────────────────────────────────────────── + + @Test + fun `l2 gives length one and leaves zero alone`() { + val v = DriveFacesLogic.l2(floatArrayOf(3f, 4f)) + assertEquals(0.6f, v[0], 1e-6f) + assertEquals(0.8f, v[1], 1e-6f) + assertArrayEquals(floatArrayOf(0f, 0f), DriveFacesLogic.l2(floatArrayOf(0f, 0f))) + val big = DriveFacesLogic.l2(FloatArray(128) { it - 64f }) + assertEquals(1.0, sqrt(big.sumOf { it.toDouble() * it }), 1e-6) + } + + @Test + fun `embedding is base64 of float32 little endian`() { + assertEquals("AAAAPwAAgL4AAIA/AAAAAA==", DriveFacesLogic.encode(floatArrayOf(0.5f, -0.25f, 1f, 0f))) + val v = FloatArray(128) { (it - 60) / 100f } + val s = DriveFacesLogic.encode(v) + assertEquals(684, s.length) // 512 bytes + assertFalse(s.contains('\n')) + assertArrayEquals(v, DriveFacesLogic.decodeVector(s)) + assertNull(DriveFacesLogic.decodeVector("AAA=")) + } + + @Test + fun `the reference features of the same person are near, of others far`() { + // drive-face-model.md: Alvaro_Uribe_0028 and _0029 (OpenCV, fp32) + val a = DriveFacesLogic.decodeVector(REF_0028)!! + val b = DriveFacesLogic.decodeVector(REF_0029)!! + assertEquals(128, a.size) + assertEquals(1.0, sqrt(a.sumOf { it.toDouble() * it }), 1e-5) + assertEquals(0.5714, DriveFacesLogic.cosine(a, b), 1e-3) + assertEquals(-0.051028f, a[0], 1e-5f) + assertEquals(0.221019f, a[5], 1e-5f) + } + + // ─── detection ────────────────────────────────────────────────── + + /** YuNet's outputs with one face at grid cell (row 2, col 3) of stride 8. */ + private fun outputs(score: Float, cell: Int = 2 * 80 + 3): Map { + val out = HashMap() + for (s in intArrayOf(8, 16, 32)) { + val n = (640 / s) * (640 / s) + out["cls_$s"] = FloatArray(n) + out["obj_$s"] = FloatArray(n) + out["bbox_$s"] = FloatArray(n * 4) + out["kps_$s"] = FloatArray(n * 10) + } + out["cls_8"]!![cell] = score + out["obj_8"]!![cell] = 1f + out["bbox_8"]!!.let { it[cell * 4] = 0.5f; it[cell * 4 + 1] = 0.5f; it[cell * 4 + 2] = ln(4f); it[cell * 4 + 3] = ln(5f) } + out["kps_8"]!!.let { k -> for (i in 0 until 10) k[cell * 10 + i] = 0.25f * (i % 2 + 1) } + return out + } + + @Test + fun `decode reads boxes and points as OpenCV does`() { + val faces = DriveFacesLogic.decode({ outputs(0.81f)[it] }) + assertEquals(1, faces.size) + val f = faces[0] + // cx = (3 + 0.5)·8 = 28, cy = (2 + 0.5)·8 = 20, w = 4·8 = 32, h = 5·8 = 40 + assertEquals(12f, f.x, 1e-4f) + assertEquals(0f, f.y, 1e-4f) + assertEquals(32f, f.w, 1e-4f) + assertEquals(40f, f.h, 1e-4f) + assertEquals((3 + 0.25f) * 8, f.points[0], 1e-4f) + assertEquals((2 + 0.5f) * 8, f.points[1], 1e-4f) + assertEquals(sqrt(0.81f), f.score, 1e-5f) + // below the threshold (sqrt(0.6) < 0.8): none + assertTrue(DriveFacesLogic.decode({ outputs(0.6f)[it] }).isEmpty()) + } + + @Test + fun `nms keeps the best of overlapping boxes`() { + fun face(x: Float, s: Float) = DriveFacesLogic.Face(x, 0f, 100f, 100f, FloatArray(10), s) + val kept = DriveFacesLogic.nms(listOf(face(0f, 0.9f), face(10f, 0.95f), face(300f, 0.85f)), 0.3f) + assertEquals(listOf(0.95f, 0.85f), kept.map { it.score }) + } + + @Test + fun `scale, size and normalised box`() { + assertEquals(0.3125f, DriveFacesLogic.detectScale(2048, 1536), 1e-6f) + assertEquals(1f, DriveFacesLogic.detectScale(150, 150)) + val f = DriveFacesLogic.Face(-10f, 20f, 60f, 40f, FloatArray(10) { 50f }, 0.9f) + assertTrue(DriveFacesLogic.usable(f, 200, 100)) + assertFalse(DriveFacesLogic.usable(f.copy(w = 20f), 200, 100)) + val (box, pts) = DriveFacesLogic.normalized(f, 200, 100) + assertEquals(listOf(0f, 0.2f, 0.25f, 0.4f), box.map { (it * 1000).toInt() / 1000f }) + assertEquals(0.25f, pts[0], 1e-6f) + assertEquals(0.5f, pts[1], 1e-6f) + } + + companion object { + const val REF_0028 = "wwJRvQ6VmLxBMoA9BZuSOnfnHr3tUmI+41NPvkaFmr0Xzck7CIZ5vTSErDxo3Js9gYrKuyWZb70dJBo9DPA0PQ6iFby6pl89aPv2veVmgr3ES9s9L1BtPK1M4btcgQQ99wStvN8HkbzLHWq9GCevPDZAFb3zcUC9H6cBvmNdfz1pET48q5adPVDXjL3zM2O8cPINvt8yb700W3y9PpPtPT3dnrwloCe+cliUPY6xpTxswbA9O30EPhePLDx4crQ9pTzxPWKGzLszgfM7BEr/PedOoD1kBi8+oUWwvR/Ipr22/Sa9L2pivZs3Sz4iAce8DdD4vJm1Ij5tl8m91IcQvV/pWLqLST4+vsyFvD19hr6QPtC8otUdvcKqkby1nQG+8xoTPTShd76XWa29hgH0vNJrPT1jxgC+96IhvjcmSz2bgmW9EL5lvGAn17wWHmC9KMVcu2QBkDsCP2Y9rb7dPVRG9zt6LsU9MY6YvTdowzzj2Dq9WfSqPeKq1LpqQoq9dcyLvSEa1712bwS+/jCTu1fISD1Xd9U9UWpKvvJIXr5w2Uc8m2oSPnwHAj3KE4O9ftaEPddNND3JTW49dbvWvcXhPLw8K0w9XljLPbXb8buZyTQ9pwaZPYLoez108189uozIvGrRRz1TAwA+NeoPvcWnIr41vga8yKNRPWDfFj0=" + const val REF_0029 = "GpbbPQ8Rejzb2RI93AcwvqWf7byA/Nk9zsYgvi1Sm70HzPI8zisCvkoDUz0cKY493RmLPdfOoDn9hqu8NUGDvCB1Gj2KIQE9qe5zvazSSL7VGTk+alsgveQT+LwWx9g8NpAAPfaXrL39EWk79IdSPQUyz71/KZU9xuAFvpMmBL7WkaY8qE2YvKbJ2L2gxpK9G1r2vC1oJL1g7LC9vYjVvGOcMj1JVE++arCTPVFPZjxO+Zc9sMZ6PfS13j0p7vQ5pTGwPUNdUL2lraW8/A5evSNMwzwiblI+6H9rulyawjwmjpo8H0+0u+ydND4qJuy9oDpHva04tT1HkTK+YnyrvUIRsb3udOS7sxG6vV6+g758XHu7s2j8vfiEb71koKy8p2vZPVBdHL4pEnc9JQxavT+Jfj1Onem9UAdfvUqNKz5m0y++V3X9vFAsML4wsV89Tz/Dveoodb3a6Qw+NGWcOZUOnz2s9gO9xSmgPCGR7D2UJOO8avwlPX59CL70eju9bUQNvdIaxLyuaMu9kRHION5mOb2nq6A9fTaLvaQuBb79Y8E80XwIvLX5F73qXxC9Wr6SPZXJM7xNkhc+bTFsPRWTFz1zpUC8X1OpvTIKkbtbc5S7WqxSPYz9Nz4gOtk9awo2vbDyuD0fFYM9fagzvc5T37xUS0O93YMQvuWOOb0=" + } +} diff --git a/app/src/test/java/com/gatecontrol/android/ui/drive/DriveSelectionSwapTest.kt b/app/src/test/java/com/gatecontrol/android/ui/drive/DriveSelectionSwapTest.kt new file mode 100644 index 00000000..d6502184 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/drive/DriveSelectionSwapTest.kt @@ -0,0 +1,96 @@ +package com.gatecontrol.android.ui.drive + +import android.app.Application +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.theme.GateControlTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Drive's selection mode: a long press ticks a photo and the head changes — the photos below must stay exactly where + * they were (the finger is still on the screen; anything moving under it ticks the wrong photos). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class DriveSelectionSwapTest { + + @get:Rule + val rule = createComposeRule() + + private fun node(id: Long) = DNode(id, "mine:7", 2, "p$id.jpg", false, 10, "image/jpeg", null, 1, null, null, null, takenAt = "2026-10-0${id}T10:00:00Z") + + @Test + fun `the photos below the head do not move when ticking starts and ends`() { + var selecting by mutableStateOf(false) + rule.setContent { + GateControlTheme { + Column { + SelectionSwap( + selecting, + bar = { GcSubpageBar("Fotos", onBack = {}, backDescription = "Zurück") }, + selection = { SelectionHead(count = 1, line = "Zu prüfen – wohin gehört es? Tippe auf Arbeit oder Privat, die KI lernt daraus.", allSelected = false, onClose = {}, onAll = {}) }, + ) + Row(Modifier.inert(selecting)) { + GcFilterChip("Alle", selected = true, onClick = {}) + GcFilterChip("Privat", selected = false, onClick = {}) + } + Text("3 Fotos zu prüfen", Modifier.inert(selecting)) + Box(Modifier.size(96.dp).testTag("tile")) + } + } + } + val before = rule.onNodeWithTag("tile").getBoundsInRoot() + selecting = true + rule.waitForIdle() + assertEquals(before, rule.onNodeWithTag("tile").getBoundsInRoot()) + selecting = false + rule.waitForIdle() + assertEquals(before, rule.onNodeWithTag("tile").getBoundsInRoot()) + } + + @Test + fun `a ticked tile shrinks inside its own cell, its neighbours stay`() { + var selected by mutableStateOf(emptySet()) + val pages = PhotoPages(items = (1L..6L).map(::node), loaded = true) + rule.setContent { + GateControlTheme { + Box(Modifier.size(400.dp, 800.dp)) { + PhotoGrid(pages = pages, selected = selected, onOpen = {}, onToggle = {}, onMore = {}, onRetry = {}, empty = "", onSelect = { selected = it }) + } + } + } + fun place(name: String) = rule.onNodeWithContentDescriptionPrefix(name).getBoundsInRoot() + val second = place("p2.jpg") + val first = place("p1.jpg") + selected = setOf(1L) + rule.waitForIdle() + assertEquals(second, place("p2.jpg")) + assertEquals(first, place("p1.jpg")) + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.onNodeWithContentDescriptionPrefix(prefix: String) = + onNode(androidx.compose.ui.test.SemanticsMatcher("starts with $prefix") { n -> + n.config.getOrNull(androidx.compose.ui.semantics.SemanticsProperties.ContentDescription)?.any { it.startsWith(prefix) } == true + }) +} diff --git a/docs/GOOGLE_PLAY.md b/docs/GOOGLE_PLAY.md index b68f61e4..a8af0203 100644 --- a/docs/GOOGLE_PLAY.md +++ b/docs/GOOGLE_PLAY.md @@ -70,9 +70,9 @@ Für **jeden** erhobenen Datentyp gilt: nur erhoben, **nicht geteilt** (geht nur | **Finanzinformationen → Kaufverlauf** | optional | Familie: Ausgaben im Haushaltsbuch (reine Buchhaltung, keine Zahlungen) | | **Finanzinformationen → Sonstige Finanzinformationen** | optional | Familie: Taschengeld (Guthaben, Buchungen, Regeln) | | **Standort → Ungefährer Standort** | optional | Familie: Anwesenheit „zuhause/unterwegs“ (Heimnetz-Erkennung, kein GPS) und „angekommen/verlassen“ bei Orten für Regeln (ohne Position) | -| **Standort → Genauer Standort** | optional | Standortfreigabe auf Tipp (ab 1.26), Standort-Nachricht im Chat (einmalig), Familie: Kind orten auf Anfrage der Eltern, SOS. Für die WLAN-Namen wird der Standort nur auf dem Gerät genutzt. | +| **Standort → Genauer Standort** | optional | Standortfreigabe auf Tipp (ab 1.26), Standort-Nachricht im Chat (einmalig), Familie: Kind orten auf Anfrage der Eltern, SOS, Drive: Aufnahmeort gesicherter Fotos (EXIF, „Aufnahmeort mitsichern“, an den eigenen Server). Für die WLAN-Namen wird der Standort nur auf dem Gerät genutzt. | | **Nachrichten → Sonstige In-App-Nachrichten** | optional | Chats (ab 1.42): Texte, Antworten, Reaktionen | -| **Fotos und Videos → Fotos** | optional | Chat-Bilder, eigenes Profilbild (ab 1.33), Drive: Foto-Sicherung (nur eingeschaltet, in den eigenen Drive des Servers) | +| **Fotos und Videos → Fotos** | optional | Chat-Bilder, eigenes Profilbild (ab 1.33), Drive: Foto-Sicherung (nur eingeschaltet, in den eigenen Drive des Servers); Drive „Personen erkennen“ (nur eingeschaltet): auf dem Gerät berechnete Gesichtsmerkmale (Zahlen, keine Bilder) an den eigenen Server | | **Fotos und Videos → Videos** | optional | Chats: Videos, die als Datei gesendet oder aus anderen Apps geteilt werden; Drive: Foto-Sicherung mit „Auch Videos“ | | **Audiodateien → Sprach- oder Tonaufnahmen** | optional | Chats: Sprachnachrichten (Mikrofon nur, solange die Taste gehalten wird) | | **Gesundheit und Fitness → Gesundheitsinformationen** | optional | Familie: Notfall-Karte (Allergien, Medikamente, Vorerkrankungen, Blutgruppe …) | diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md index 3f792329..c8c46fcd 100644 --- a/docs/PRIVACY_POLICY.md +++ b/docs/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ # GateControl — Datenschutzerklärung / Privacy Policy -*Stand / Last updated: 15. Oktober 2026 / October 15, 2026* +*Stand / Last updated: 1. November 2026 / November 1, 2026* [Deutsch](#deutsch) · [English](#english) @@ -61,6 +61,15 @@ Die Tür-Funktionen (Schnellaktionen, Mitteilungen und der Bildschirm „Tür & Nichts davon geht an CallMeTechie oder andere Dritte. +### Drive: Orte und Personen in den Fotos (Plugin „Drive“ deines Servers) + +Nur, wenn dein Server Drive 1.16 oder neuer anbietet. + +- **Orte:** Mit „Aufnahmeort mitsichern“ (Drive › Einstellungen › Orte und Personen, standardmäßig an, solange du die Foto-Sicherung nutzt) schickt die Foto-Sicherung die Koordinaten aus den EXIF-Daten des Fotos (Breiten- und Längengrad) an **deinen eigenen Server**. Dafür liest die App die Ortsangaben der Originale (Android-Berechtigung „Zugriff auf Fotostandorte“). „Orte nachtragen“ macht dasselbe für schon gesicherte Fotos, die noch auf dem Handy liegen. Den Ortsnamen ermittelt dein Server selbst, ohne externen Dienst. Ausgeschaltet sendet die App keine Orte mehr; „Alle Orte löschen“ entfernt sie auf dem Server. Die Karte lädt Kartenbilder von OpenStreetMap — dabei erfährt OpenStreetMap den angezeigten Kartenausschnitt, aber keine Fotos. +- **Personen (Gesichtsmerkmale):** Nur wenn du „Personen erkennen“ einschaltest (standardmäßig aus). Dann findet dein Handy Gesichter in deinen Fotos — **auf dem Gerät**, mit den Modellen YuNet und SFace, im WLAN und standardmäßig nur beim Laden — und schickt je Gesicht nur eine Zahlenreihe (Gesichtsmerkmal), den Bildausschnitt als Position und die Erkennungssicherheit an **deinen eigenen Server**. Fotos verlassen dafür weder das Handy noch deinen Server. Das dafür nötige Erkennungsmodell (SFace, 39 MB) lädt das Handy beim Einschalten einmalig herunter – von der Release-Seite der App auf GitHub, ersatzweise von OpenCV Zoo auf GitHub oder von Hugging Face, standardmäßig nur im WLAN. Dabei erfahren diese Anbieter nur die IP-Adresse und die üblichen Verbindungsdaten des Handys, keine Fotos und keine Daten deines Servers; Ausschalten löscht das Modell wieder. Für Fotos, die nicht mehr auf dem Handy liegen, lädt die App die Vorschau von deinem Server. Dein Server gruppiert die Merkmale zu Personen; nur du siehst deine Personen und Namen. Ausschalten löscht alle Gesichtsdaten (Merkmale, Personen, Namen) auf dem Server. Die Ordner von Kindern sind ausgenommen, solange du „Kinderordner einbeziehen“ nicht einschaltest. + +Nichts davon geht an CallMeTechie, Google oder andere Dritte. + ### Kamera Nur zum Scannen des Einrichtungs-QR-Codes, wenn du den Scanner öffnest. Bilder werden weder gespeichert noch übertragen. Die Erkennung (Google ML Kit) läuft vollständig auf dem Gerät. @@ -149,6 +158,15 @@ The door features (quick actions, notifications and the "Door & camera" screen) None of this goes to CallMeTechie or other third parties. +### Drive: places and people in the photos (your server's "Drive" plugin) + +Only when your server offers Drive 1.16 or later. + +- **Places:** with "Keep where photos were taken" (Drive › Settings › Places and people, on by default while you use the photo backup) the photo backup sends the coordinates from the photo's EXIF data (latitude and longitude) to **your own server**. For this the app reads the location data of the originals (Android permission "Access photo locations"). "Add places" does the same for photos already backed up that are still on the phone. Your server works out the place name itself, without an external service. Switched off, the app sends no places; "Delete all places" removes them on the server. The map loads map images from OpenStreetMap — OpenStreetMap learns the map area shown, but no photos. +- **People (face features):** only when you switch on "Recognise people" (off by default). Your phone then finds faces in your photos — **on the device**, with the YuNet and SFace models, on Wi-Fi and by default only while charging — and sends for each face only a series of numbers (the face feature), the face's position in the picture and how sure the detection is to **your own server**. No photo leaves the phone or your server for this. The recognition model this needs (SFace, 39 MB) is downloaded once when you switch it on — from the app's release page on GitHub, else from OpenCV Zoo on GitHub or from Hugging Face, by default only on Wi-Fi. These providers only learn the phone's IP address and the usual connection data, no photos and nothing of your server; switching off deletes the model again. For photos no longer on the phone the app loads the preview from your server. Your server groups the features into people; only you see your people and names. Switching off deletes all face data (features, people, names) on the server. Children's folders are left out unless you switch on "Include the children's folders". + +None of this goes to CallMeTechie, Google or other third parties. + ### Camera Only to scan the setup QR code when you open the scanner. No images are stored or transmitted. Recognition (Google ML Kit) runs entirely on the device. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6cb13c79..6cb73cc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,8 @@ mlkit-barcode = "17.3.0" mlkit-document-scanner = "16.0.0" mlkit-text-recognition = "19.0.1" mlkit-image-labeling = "16.0.8" +# Drive: faces on the phone (YuNet + SFace as ONNX). 1.23.x: no telemetry provider (1.27+ ship one). +onnxruntime = "1.23.2" timber = "5.0.1" coroutines = "1.11.0" junit5 = "6.1.3" @@ -124,6 +126,9 @@ mlkit-document-scanner = { group = "com.google.android.gms", name = "play-servic 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" } +# Drive: faces on the phone (ONNX Runtime, CPU) +onnxruntime-android = { group = "com.microsoft.onnxruntime", name = "onnxruntime-android", version.ref = "onnxruntime" } + # Vault bouncycastle-prov = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } @@ -139,6 +144,8 @@ coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines- # Testing junit5-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit5" } junit5-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit5" } +# Compose UI tests on Robolectric are JUnit 4 (createComposeRule): run on the JUnit Platform. +junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junit5" } # Gradle 9 no longer ships the JUnit Platform launcher with the test runtime. junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junit5" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } diff --git a/privacy-policy.html b/privacy-policy.html index 9d3e4573..530a1f37 100644 --- a/privacy-policy.html +++ b/privacy-policy.html @@ -98,6 +98,16 @@

Door & Camera (Tür & Kamera plugin)

None of this data is sent to us or to other third parties.

+
+

Drive: Places and People in Photos (Drive plugin)

+

Only when your own GateControl server offers Drive 1.16 or later.

+
    +
  • Places — with "Keep where photos were taken" (on by default while you use the photo backup) the backup sends the coordinates from the photo's EXIF data to your own server; "Add places" does the same for backed-up photos still on the phone. The app reads the originals' location data for this (Android permission "Access photo locations"). Your server names the place itself, without an external service. Switched off, no places are sent; "Delete all places" removes them on the server. Maps load tiles from OpenStreetMap, which learns the map area shown but no photos.
  • +
  • People (face features) — only after you switch on "Recognise people" (off by default). The phone finds faces on the device (YuNet and SFace models, on Wi-Fi and by default only while charging) and sends for each face only a series of numbers, its position in the picture and the detection score to your own server. No photo leaves the phone or your server for this. The recognition model (SFace, 39 MB) is downloaded once when you switch it on — from the app's GitHub release, else from OpenCV Zoo on GitHub or from Hugging Face, by default only on Wi-Fi; these providers only learn the phone's IP address and the usual connection data, no photos. Switching off deletes the model. For photos no longer on the phone the app loads the preview from your server. Only you see your people and names. Switching off deletes all face data on the server. Children's folders are left out unless you include them.
  • +
+

None of this data is sent to us or to other third parties.

+
+

Data Storage

All configuration data (server URL, API token, WireGuard keys) is stored locally on your device using Android's EncryptedSharedPreferences (AES-256). No credentials are stored in plain text. RDP credentials (Pro feature) use end-to-end encryption (ECDH + AES-256-GCM) and are never stored unencrypted on the device.

@@ -118,7 +128,7 @@

Contact

For privacy-related inquiries, please open an issue on our GitHub repository or contact the server administrator of your GateControl instance.

-

Last updated: October 15, 2026

+

Last updated: November 1, 2026