Initial commit
This commit is contained in:
commit
9910ba6917
132 files changed
+53909
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 OneCamera contributors
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<h1 align="center">OneCamera</h1>
|
||||||
|
|
||||||
|
<h4 align="center">Take photos straight into the right folder.</h4>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#features">Features</a> •
|
||||||
|
<a href="#capture-and-storage">Capture & storage</a> •
|
||||||
|
<a href="#responsiveness">Responsiveness</a> •
|
||||||
|
<a href="#tech-stack">Tech stack</a> •
|
||||||
|
<a href="#build">Build</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
OneCamera is an Android app for taking photos directly into the folder you choose. Pick a folder, tap the camera button, and a camera app takes the shot. The captured photo is saved straight into the selected folder.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Folder-first shooting — choose a folder before taking a photo.
|
||||||
|
- Camera handoff — fires `MediaStore.ACTION_IMAGE_CAPTURE`; the system resolves it to the default camera app (no camera picker inside OneCamera).
|
||||||
|
- No broad storage permissions — only a user-selected SAF root folder; everything else is off-limits to the app.
|
||||||
|
- Folder management — create, rename, move, and delete folders in the SAF tree.
|
||||||
|
- ZIP archives — one-tap pack a folder as original files or as JPG quality 85, then share via any app.
|
||||||
|
- Gallery — adaptive grid/list view, sort by name, fullscreen viewer with zoom and subsampling of huge photos, multi-select, share via system intents.
|
||||||
|
- Draggable floating camera button with position persisted across restarts.
|
||||||
|
- Material 3 UI with dynamic color, light, dark, and system themes.
|
||||||
|
- No tracking, no ads, fully open source.
|
||||||
|
|
||||||
|
## Capture & Storage
|
||||||
|
|
||||||
|
OneCamera never asks for broad storage access. All file work goes through a single user-picked Storage Access Framework tree.
|
||||||
|
|
||||||
|
Capture flow:
|
||||||
|
|
||||||
|
1. The user grants a folder tree through the system picker; the grant is persisted.
|
||||||
|
2. The app creates a temp file exposed through `FileProvider` and fires `ACTION_IMAGE_CAPTURE` with `EXTRA_OUTPUT`; the system routes the intent to the default camera app.
|
||||||
|
3. The camera app writes the shot into the temp file; the app moves it into the selected SAF folder.
|
||||||
|
4. A photo event bus refreshes the gallery without a restart; thumbnails are decoded subsampled and cached on disk.
|
||||||
|
|
||||||
|
Folder operations — rename, delete, move — go through SAF documents, so the UI and the file system always agree. ZIP packaging runs off the main thread with progress; if a file cannot be read, the partial archive is deleted and the outcome carries an error message. Archive entry names are UTF-8 encoded to preserve non-ASCII names. Revoking tree access is handled explicitly instead of crashing.
|
||||||
|
|
||||||
|
## Responsiveness
|
||||||
|
|
||||||
|
Photo libraries can be large, so heavy work stays off the main thread:
|
||||||
|
|
||||||
|
- SAF queries, folder scanning, and ZIP packaging run on background dispatchers;
|
||||||
|
- gallery content loads incrementally, and folder statistics are computed separately from the photo list;
|
||||||
|
- thumbnails load through Coil with a bounded memory cache and a disk cache capped by LRU eviction, and oversized photos are subsampled in the fullscreen viewer;
|
||||||
|
- grid and list are lazy; sort order and view mode changes reuse loaded state instead of rescanning the tree;
|
||||||
|
- rotation recreates the Activity by design — scroll position, sort order, and the FAB position live in ViewModel state and DataStore, not in composables;
|
||||||
|
- Baseline Profile rules ship in the APK to speed up cold start.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Kotlin 2.4.21
|
||||||
|
- Jetpack Compose (BOM 2026.09.00), Material 3
|
||||||
|
- Hilt 2.60.1 (dependency injection)
|
||||||
|
- Navigation Compose 2.10.2
|
||||||
|
- DataStore Preferences 1.2.1
|
||||||
|
- Coil 3.6.3 + ZoomImage 1.7.0 (image loading and fullscreen zoom)
|
||||||
|
- kotlinx.serialization 1.11.0
|
||||||
|
- Androidx tracing with a committed Baseline Profile
|
||||||
|
- Android Gradle Plugin 9.4.1, Gradle 9.8.1, JDK 21
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :app:assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
The release APK targets `arm64-v8a`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.baselineprofile)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.onecamera"
|
||||||
|
compileSdk {
|
||||||
|
version = release(37) {
|
||||||
|
minorApiLevel = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.onecamera"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
|
||||||
|
"src/release/keepRules/strip-logs.keep"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
isEnable = true
|
||||||
|
reset()
|
||||||
|
include("arm64-v8a")
|
||||||
|
isUniversalApk = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions {
|
||||||
|
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baselineProfile {
|
||||||
|
|
||||||
|
automaticGenerationDuringBuild = false
|
||||||
|
saveInSrc = true
|
||||||
|
|
||||||
|
mergeIntoMain = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
|
implementation(libs.androidx.compose.runtime)
|
||||||
|
implementation(libs.androidx.compose.animation)
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.android.compiler)
|
||||||
|
ksp(libs.kotlin.metadata.jvm)
|
||||||
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
implementation(libs.zoomimage.compose.coil3)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
implementation(libs.androidx.profileinstaller)
|
||||||
|
implementation(libs.androidx.tracing)
|
||||||
|
debugImplementation(libs.androidx.compose.runtime.tracing)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<lint>
|
||||||
|
<!-- Kotlin 2.4.0 is available, but Hilt 2.59.2 cannot read Kotlin 2.4 metadata yet. -->
|
||||||
|
<issue id="GradleDependency">
|
||||||
|
<ignore path="../gradle/libs.versions.toml" />
|
||||||
|
</issue>
|
||||||
|
<issue id="NewerVersionAvailable">
|
||||||
|
<ignore path="../gradle/libs.versions.toml" />
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<!-- This app intentionally ships only arm64-v8a APKs. -->
|
||||||
|
<issue id="ChromeOsAbiSupport">
|
||||||
|
<ignore path="build.gradle.kts" />
|
||||||
|
</issue>
|
||||||
|
</lint>
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
-assumenosideeffects class android.util.Log {
|
||||||
|
public static *** d(...);
|
||||||
|
public static *** v(...);
|
||||||
|
public static *** i(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
-keep class androidx.tracing.perfetto.** { *; }
|
||||||
|
-keepnames class androidx.tracing.perfetto.**
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".OneCameraApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
|
tools:ignore="UnusedAttribute"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.OneCamera">
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.OneCamera">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.onecamera
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.example.onecamera.navigation.OneCameraNavGraph
|
||||||
|
import com.example.onecamera.ui.theme.OneCameraTheme
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
OneCameraTheme {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
OneCameraNavGraph(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.example.onecamera
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.PlatformContext
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.memoryCacheMaxSizePercentWhileInBackground
|
||||||
|
import com.example.onecamera.core.performance.MemoryLimitMonitor
|
||||||
|
import com.example.onecamera.data.image.SafThumbnailFetcher
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class OneCameraApp : Application(), SingletonImageLoader.Factory {
|
||||||
|
|
||||||
|
private lateinit var memoryLimitMonitor: MemoryLimitMonitor
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
memoryLimitMonitor = MemoryLimitMonitor(this).apply { start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||||
|
return ImageLoader.Builder(context)
|
||||||
|
.memoryCacheMaxSizePercentWhileInBackground(0.5)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder()
|
||||||
|
.maxSizePercent(context, 0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.components { add(SafThumbnailFetcher.Factory()) }
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.example.onecamera.core.performance
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.ApplicationExitInfo
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
private const val TAG = "MemoryLimitMonitor"
|
||||||
|
private const val MEMORY_LIMIT_DESCRIPTION = "MemoryLimiter:AnonSwap"
|
||||||
|
|
||||||
|
class MemoryLimitMonitor(
|
||||||
|
private val application: Application,
|
||||||
|
) {
|
||||||
|
private val activityManager by lazy {
|
||||||
|
application.getSystemService(ActivityManager::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val executor by lazy {
|
||||||
|
Executors.newSingleThreadExecutor { r ->
|
||||||
|
Thread(r, "MemoryLimitMonitor").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
checkHistoricalExitReasons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
executor.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkHistoricalExitReasons() {
|
||||||
|
executor.execute {
|
||||||
|
val reasons = activityManager.getHistoricalProcessExitReasons(application.packageName, 0, 5)
|
||||||
|
reasons.forEach { reason ->
|
||||||
|
if (reason.reason == ApplicationExitInfo.REASON_OTHER &&
|
||||||
|
reason.description?.contains(MEMORY_LIMIT_DESCRIPTION) == true
|
||||||
|
) {
|
||||||
|
Log.e(
|
||||||
|
TAG,
|
||||||
|
"Process was killed by per-app memory limit (pid=${reason.pid}, " +
|
||||||
|
"rss=${reason.rss}, pss=${reason.pss}, description=${reason.description})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.onecamera.core.performance
|
||||||
|
|
||||||
|
import androidx.tracing.Trace
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
object OneCameraTrace {
|
||||||
|
const val GALLERY_LOAD = "OneCamera/Gallery/Load"
|
||||||
|
const val FOLDERS_LOAD = "OneCamera/Folders/Load"
|
||||||
|
const val ZIP_CREATE = "OneCamera/Zip/Create"
|
||||||
|
|
||||||
|
private val nextCookie = AtomicInteger()
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val isAndroidRuntime = System.getProperty("java.vm.name") == "Dalvik"
|
||||||
|
|
||||||
|
suspend fun <T> asyncSection(name: String, block: suspend () -> T): T {
|
||||||
|
if (!isAndroidRuntime) return block()
|
||||||
|
val cookie = nextCookie.incrementAndGet()
|
||||||
|
Trace.beginAsyncSection(name, cookie)
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
Trace.endAsyncSection(name, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.onecamera.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
data class PhotoSavedEvent(
|
||||||
|
val folderUri: Uri,
|
||||||
|
val photo: Photo? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PhotoEventBus @Inject constructor() {
|
||||||
|
|
||||||
|
val photoSaved: SharedFlow<PhotoSavedEvent>
|
||||||
|
field: MutableSharedFlow<PhotoSavedEvent> = MutableSharedFlow(
|
||||||
|
extraBufferCapacity = 8,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun onPhotoSaved(folderUri: Uri, photo: Photo? = null) {
|
||||||
|
photoSaved.tryEmit(PhotoSavedEvent(folderUri, photo))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.example.onecamera.data.image
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Size as AndroidSize
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import android.net.Uri as AndroidUri
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.Uri
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.decode.DataSource
|
||||||
|
import coil3.fetch.FetchResult
|
||||||
|
import coil3.fetch.Fetcher
|
||||||
|
import coil3.fetch.ImageFetchResult
|
||||||
|
import coil3.request.Options
|
||||||
|
import coil3.size.Dimension
|
||||||
|
import coil3.toAndroidUri
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
|
||||||
|
internal class SafThumbnailFetcher(
|
||||||
|
private val data: Uri,
|
||||||
|
private val options: Options,
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
override suspend fun fetch(): FetchResult {
|
||||||
|
val resolver = options.context.contentResolver
|
||||||
|
val androidUri = data.toAndroidUri()
|
||||||
|
val width = (options.size.width as? Dimension.Pixels)?.px ?: FALLBACK_TARGET_PX
|
||||||
|
val height = (options.size.height as? Dimension.Pixels)?.px ?: FALLBACK_TARGET_PX
|
||||||
|
|
||||||
|
val bitmap = decodeSemaphore.withPermit {
|
||||||
|
|
||||||
|
ThumbnailDiskCache.get(options.context, androidUri, width, height)?.toHardwareOrSelf()
|
||||||
|
|
||||||
|
?: providerThumbnailIfSufficient(resolver, androidUri, width, height)
|
||||||
|
|
||||||
|
?: decodeSubsampled(resolver, androidUri, width, height)?.let { software ->
|
||||||
|
ThumbnailDiskCache.put(options.context, androidUri, width, height, software)
|
||||||
|
software.toHardwareOrSelf()
|
||||||
|
}
|
||||||
|
?: error("Unable to decode thumbnail for $androidUri")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageFetchResult(
|
||||||
|
image = bitmap.asImage(),
|
||||||
|
isSampled = true,
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun providerThumbnailIfSufficient(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: AndroidUri,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): Bitmap? {
|
||||||
|
if (width > PROVIDER_THUMB_MAX_PX || height > PROVIDER_THUMB_MAX_PX) return null
|
||||||
|
return loadProviderThumbnail(resolver, uri, width, height)
|
||||||
|
?.takeIf { it.width >= width && it.height >= height }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProviderThumbnail(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: AndroidUri,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): Bitmap? = try {
|
||||||
|
resolver.loadThumbnail(uri, AndroidSize(width, height), null)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSubsampled(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: AndroidUri,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
val sampled = decodeSingleStream(resolver, uri, bounds, width, height)
|
||||||
|
?: decodeTwoStreams(resolver, uri, bounds, width, height)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val scale = maxOf(
|
||||||
|
width.toFloat() / sampled.width,
|
||||||
|
height.toFloat() / sampled.height,
|
||||||
|
).coerceAtMost(1f)
|
||||||
|
return if (scale >= SCALE_SKIP_THRESHOLD) {
|
||||||
|
sampled
|
||||||
|
} else {
|
||||||
|
sampled.scale(
|
||||||
|
(sampled.width * scale).toInt().coerceAtLeast(1),
|
||||||
|
(sampled.height * scale).toInt().coerceAtLeast(1),
|
||||||
|
).also { if (it !== sampled) sampled.recycle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDecodeOptions(
|
||||||
|
bounds: BitmapFactory.Options,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): BitmapFactory.Options = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, width, height)
|
||||||
|
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSingleStream(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: AndroidUri,
|
||||||
|
bounds: BitmapFactory.Options,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): Bitmap? = try {
|
||||||
|
resolver.openInputStream(uri)?.use { raw ->
|
||||||
|
val buffered = raw.buffered(STREAM_MARK_LIMIT_BYTES)
|
||||||
|
buffered.mark(STREAM_MARK_LIMIT_BYTES)
|
||||||
|
|
||||||
|
BitmapFactory.decodeStream(buffered, null, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
buffered.reset()
|
||||||
|
BitmapFactory.decodeStream(buffered, null, buildDecodeOptions(bounds, width, height))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeTwoStreams(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: AndroidUri,
|
||||||
|
bounds: BitmapFactory.Options,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): Bitmap? {
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
||||||
|
val boundsStream = resolver.openInputStream(uri) ?: return null
|
||||||
|
boundsStream.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
}
|
||||||
|
return resolver.openInputStream(uri)?.use {
|
||||||
|
BitmapFactory.decodeStream(it, null, buildDecodeOptions(bounds, width, height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.toHardwareOrSelf(): Bitmap {
|
||||||
|
val hardware = try {
|
||||||
|
copy(Bitmap.Config.HARDWARE, false)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} ?: return this
|
||||||
|
recycle()
|
||||||
|
return hardware
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateInSampleSize(srcW: Int, srcH: Int, reqW: Int, reqH: Int): Int {
|
||||||
|
if (srcW <= 0 || srcH <= 0 || reqW <= 0 || reqH <= 0) return 1
|
||||||
|
var sample = 1
|
||||||
|
while (srcW / (sample * 2) >= reqW && srcH / (sample * 2) >= reqH) {
|
||||||
|
sample *= 2
|
||||||
|
}
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Fetcher.Factory<Uri> {
|
||||||
|
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
|
||||||
|
if (data.scheme != ContentResolver.SCHEME_CONTENT) return null
|
||||||
|
val size = options.size
|
||||||
|
if (size.width !is Dimension.Pixels || size.height !is Dimension.Pixels) return null
|
||||||
|
return SafThumbnailFetcher(data, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FALLBACK_TARGET_PX = 384
|
||||||
|
const val PROVIDER_THUMB_MAX_PX = 320
|
||||||
|
const val SCALE_SKIP_THRESHOLD = 0.9f
|
||||||
|
|
||||||
|
const val MAX_CONCURRENT_DECODES = 8
|
||||||
|
|
||||||
|
const val STREAM_MARK_LIMIT_BYTES = 256 * 1024
|
||||||
|
val decodeSemaphore = Semaphore(MAX_CONCURRENT_DECODES)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.example.onecamera.data.image
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
internal object ThumbnailDiskCache {
|
||||||
|
private const val DIR_NAME = "thumb_cache"
|
||||||
|
private const val MAX_BYTES = 100L * 1024 * 1024
|
||||||
|
private const val JPEG_QUALITY = 85
|
||||||
|
private const val EVICT_EVERY_N_PUTS = 16
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private var putsSinceEviction = 0
|
||||||
|
|
||||||
|
fun get(context: Context, uri: Uri, width: Int, height: Int): Bitmap? {
|
||||||
|
val file = fileFor(context, uri, width, height)
|
||||||
|
if (!file.isFile) return null
|
||||||
|
val bitmap = try {
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (bitmap == null || bitmap.width <= 0 || bitmap.height <= 0) {
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
file.setLastModified(System.currentTimeMillis())
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(context: Context, uri: Uri, width: Int, height: Int, bitmap: Bitmap) {
|
||||||
|
val file = fileFor(context, uri, width, height)
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
val written = try {
|
||||||
|
tmp.outputStream().buffered().use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!written) {
|
||||||
|
tmp.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(file)) {
|
||||||
|
tmp.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var shouldEvict = false
|
||||||
|
synchronized(lock) {
|
||||||
|
putsSinceEviction++
|
||||||
|
shouldEvict = putsSinceEviction >= EVICT_EVERY_N_PUTS
|
||||||
|
if (shouldEvict) putsSinceEviction = 0
|
||||||
|
}
|
||||||
|
if (shouldEvict) evictIfNeeded(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evictIfNeeded(context: Context) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val files = dir(context).listFiles()
|
||||||
|
?.filter { it.isFile && it.extension == "jpg" }
|
||||||
|
?: return
|
||||||
|
var total = files.sumOf { it.length() }
|
||||||
|
if (total <= MAX_BYTES) return
|
||||||
|
|
||||||
|
for (file in files.sortedBy { it.lastModified() }) {
|
||||||
|
if (total <= MAX_BYTES) break
|
||||||
|
total -= file.length()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dir(context: Context): File =
|
||||||
|
File(context.cacheDir, DIR_NAME).apply { mkdirs() }
|
||||||
|
|
||||||
|
private fun fileFor(context: Context, uri: Uri, width: Int, height: Int): File {
|
||||||
|
return File(dir(context), "${sha1("$uri@${width}x$height")}.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha1(value: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1").digest(value.toByteArray(Charsets.UTF_8))
|
||||||
|
return digest.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.example.onecamera.data.preferences
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_preferences")
|
||||||
|
|
||||||
|
data class UserPreferences(
|
||||||
|
val rootFolderUri: String? = null,
|
||||||
|
val cameraFabXFraction: Float = 1f,
|
||||||
|
val cameraFabYFraction: Float = 1f,
|
||||||
|
val gallerySortAscending: Boolean = true,
|
||||||
|
val galleryGridMode: Boolean = true,
|
||||||
|
val zipJpegCompression: Boolean = true,
|
||||||
|
val screenTransitionsEnabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class UserPreferencesRepository @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private object Keys {
|
||||||
|
val ROOT_FOLDER_URI = stringPreferencesKey("root_folder_uri")
|
||||||
|
val CAMERA_FAB_X_FRACTION = floatPreferencesKey("camera_fab_x_fraction")
|
||||||
|
val CAMERA_FAB_Y_FRACTION = floatPreferencesKey("camera_fab_y_fraction")
|
||||||
|
val GALLERY_SORT_ASCENDING = booleanPreferencesKey("gallery_sort_ascending")
|
||||||
|
val GALLERY_GRID_MODE = booleanPreferencesKey("gallery_grid_mode")
|
||||||
|
val ZIP_JPEG_COMPRESSION = booleanPreferencesKey("zip_jpeg_compression")
|
||||||
|
val SCREEN_TRANSITIONS_ENABLED = booleanPreferencesKey("screen_transitions_enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
val userPreferencesFlow: Flow<UserPreferences> = context.dataStore.data.map { prefs ->
|
||||||
|
UserPreferences(
|
||||||
|
rootFolderUri = prefs[Keys.ROOT_FOLDER_URI],
|
||||||
|
cameraFabXFraction = prefs[Keys.CAMERA_FAB_X_FRACTION]?.coerceIn(0f, 1f) ?: 1f,
|
||||||
|
cameraFabYFraction = prefs[Keys.CAMERA_FAB_Y_FRACTION]?.coerceIn(0f, 1f) ?: 1f,
|
||||||
|
gallerySortAscending = prefs[Keys.GALLERY_SORT_ASCENDING] ?: true,
|
||||||
|
galleryGridMode = prefs[Keys.GALLERY_GRID_MODE] ?: true,
|
||||||
|
zipJpegCompression = prefs[Keys.ZIP_JPEG_COMPRESSION] ?: true,
|
||||||
|
screenTransitionsEnabled = prefs[Keys.SCREEN_TRANSITIONS_ENABLED] ?: true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRootFolderUri(uri: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.ROOT_FOLDER_URI] = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearRootFolderUri() {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs.remove(Keys.ROOT_FOLDER_URI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCameraFabPosition(xFraction: Float, yFraction: Float) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.CAMERA_FAB_X_FRACTION] = xFraction.coerceIn(0f, 1f)
|
||||||
|
prefs[Keys.CAMERA_FAB_Y_FRACTION] = yFraction.coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setGalleryViewPreferences(sortAscending: Boolean, isGridMode: Boolean) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.GALLERY_SORT_ASCENDING] = sortAscending
|
||||||
|
prefs[Keys.GALLERY_GRID_MODE] = isGridMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setZipJpegCompression(enabled: Boolean) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.ZIP_JPEG_COMPRESSION] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setScreenTransitionsEnabled(enabled: Boolean) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.SCREEN_TRANSITIONS_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.example.onecamera.data.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ExternalMediaObserver @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) {
|
||||||
|
private val _changes = MutableSharedFlow<Unit>(
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
val changes: SharedFlow<Unit> = _changes
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var pending = false
|
||||||
|
|
||||||
|
private val observer = object : ContentObserver(handler) {
|
||||||
|
override fun onChange(selfChange: Boolean) = schedule()
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) = schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
resolver.registerContentObserver(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
true,
|
||||||
|
observer,
|
||||||
|
)
|
||||||
|
resolver.registerContentObserver(
|
||||||
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
true,
|
||||||
|
observer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule() {
|
||||||
|
if (pending) return
|
||||||
|
pending = true
|
||||||
|
handler.postDelayed({
|
||||||
|
pending = false
|
||||||
|
_changes.tryEmit(Unit)
|
||||||
|
}, DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEBOUNCE_MS = 400L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.onecamera.data.storage
|
||||||
|
|
||||||
|
object ImageFileMatcher {
|
||||||
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "webp", "heic", "heif")
|
||||||
|
private val archiveExtensions = setOf("zip")
|
||||||
|
|
||||||
|
fun isImageFile(mimeType: String?, fileName: String?): Boolean {
|
||||||
|
val normalizedMime = mimeType.orEmpty()
|
||||||
|
val extension = fileName.orEmpty().substringAfterLast('.', "").lowercase()
|
||||||
|
return normalizedMime.startsWith("image/") || extension in imageExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isZipArchive(mimeType: String?, fileName: String?): Boolean {
|
||||||
|
val normalizedMime = mimeType.orEmpty()
|
||||||
|
val extension = fileName.orEmpty().substringAfterLast('.', "").lowercase()
|
||||||
|
return normalizedMime == "application/zip" || extension in archiveExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isJpegImage(mimeType: String?, fileName: String?): Boolean {
|
||||||
|
val normalizedMime = mimeType.orEmpty()
|
||||||
|
val extension = fileName.orEmpty().substringAfterLast('.', "").lowercase()
|
||||||
|
return normalizedMime == "image/jpeg" || extension == "jpg" || extension == "jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
package com.example.onecamera.data.storage
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.onecamera.data.storage.saf.DocumentSource
|
||||||
|
import com.example.onecamera.data.storage.saf.SafDocument
|
||||||
|
import com.example.onecamera.data.storage.saf.ZipDocumentEntry
|
||||||
|
import com.example.onecamera.domain.model.GalleryFileKind
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.domain.model.GalleryContent
|
||||||
|
import com.example.onecamera.domain.model.MovePhotoResult
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import com.example.onecamera.domain.model.PhotoSaveOutcome
|
||||||
|
import com.example.onecamera.domain.model.SubtreeStats
|
||||||
|
import com.example.onecamera.domain.model.ZipFolderOutcome
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private const val CAPTURE_READY_CHECKS = 12
|
||||||
|
private val CAPTURE_READY_DELAY = 150.milliseconds
|
||||||
|
private const val REQUIRED_STABLE_SIZE_READS = 2
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SafPhotoRepository @Inject constructor(
|
||||||
|
private val documentSource: DocumentSource,
|
||||||
|
) : PhotoRepository {
|
||||||
|
|
||||||
|
override suspend fun listGallery(folderUri: Uri): GalleryContent = withContext(Dispatchers.IO) {
|
||||||
|
val t0 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
cachedGalleryContent(folderUri)?.let {
|
||||||
|
Log.d("PerfBaseline", "listGallery HIT total=${android.os.SystemClock.elapsedRealtime() - t0}ms uri=$folderUri")
|
||||||
|
return@withContext it
|
||||||
|
}
|
||||||
|
|
||||||
|
val folderNameDeferred = async { getFolderName(folderUri) }
|
||||||
|
val children = documentSource.listChildren(folderUri)
|
||||||
|
val folderName = folderNameDeferred.await()
|
||||||
|
val t1 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
val subfolders = children
|
||||||
|
.filter { it.isDirectory }
|
||||||
|
.map { child ->
|
||||||
|
FolderNode(
|
||||||
|
uri = child.uri,
|
||||||
|
name = child.cleanName(),
|
||||||
|
path = child.cleanName(),
|
||||||
|
depth = 0,
|
||||||
|
photoCount = 0,
|
||||||
|
firstPhotoUri = null,
|
||||||
|
previewPhotoUris = emptyList(),
|
||||||
|
statsLoaded = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
val t3 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
val photos = children
|
||||||
|
.filter { it.isGalleryFile() }
|
||||||
|
.map { it.toPhoto() }
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
val t4 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
val result = GalleryContent(
|
||||||
|
folderName = folderName,
|
||||||
|
subfolders = subfolders,
|
||||||
|
photos = photos,
|
||||||
|
)
|
||||||
|
storeGalleryContent(folderUri, result)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
"PerfBaseline",
|
||||||
|
"listGallery MISS total=${t4 - t0}ms " +
|
||||||
|
"(name+children=${t1 - t0}, buildSubfolders=${t3 - t1}, mapPhotos=${t4 - t3}) " +
|
||||||
|
"subfolders=${subfolders.size} photos=${photos.size}",
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listFolderTree(rootUri: Uri): List<FolderNode> = withContext(Dispatchers.IO) {
|
||||||
|
val rootDocument = documentSource.getDocument(rootUri)
|
||||||
|
val rootName = rootDocument?.cleanName()?.takeIf { it.isNotBlank() } ?: "Root folder"
|
||||||
|
val rootFolder = rootDocument ?: SafDocument(
|
||||||
|
uri = rootUri,
|
||||||
|
name = rootName,
|
||||||
|
mimeType = android.provider.DocumentsContract.Document.MIME_TYPE_DIR,
|
||||||
|
)
|
||||||
|
buildFolderTree(rootFolder, rootName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createFolder(parentUri: Uri, name: String): Uri? {
|
||||||
|
invalidateFolderContents(parentUri)
|
||||||
|
return name.takeIf { it.isNotBlank() }?.let { documentSource.createDirectory(parentUri, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun renameEntry(uri: Uri, newName: String): Boolean {
|
||||||
|
invalidateEntryChange(uri)
|
||||||
|
return newName.isNotBlank() && documentSource.rename(uri, newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteEntry(uri: Uri): Boolean {
|
||||||
|
invalidateEntryChange(uri)
|
||||||
|
return documentSource.delete(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun movePhoto(sourceUri: Uri, targetFolderUri: Uri, filename: String): MovePhotoResult {
|
||||||
|
val parent = parentOf(sourceUri)
|
||||||
|
invalidateBranch(
|
||||||
|
InvalidationPlan(
|
||||||
|
listingChanged = setOfNotNull(parent, targetFolderUri),
|
||||||
|
statsRoots = setOfNotNull(parent, targetFolderUri),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val sourceDocument = documentSource.getDocument(sourceUri)
|
||||||
|
val mimeType = sourceDocument?.mimeType?.takeIf { it.isNotBlank() }
|
||||||
|
?: filename.mimeTypeFromName()
|
||||||
|
val targetUri = documentSource.createFile(targetFolderUri, mimeType, filename)
|
||||||
|
?: return MovePhotoResult.Failure("Could not create the destination photo.")
|
||||||
|
|
||||||
|
return withPartialArtifactCleanup(
|
||||||
|
artifactUri = targetUri,
|
||||||
|
onCleanup = { MovePhotoResult.Failure("Could not copy the photo data.") },
|
||||||
|
isComplete = { it !is MovePhotoResult.Failure },
|
||||||
|
) {
|
||||||
|
val copiedBytes = documentSource.copyBytes(sourceUri, targetUri)
|
||||||
|
if (copiedBytes <= 0L || !documentSource.hasReadableContent(targetUri)) {
|
||||||
|
return@withPartialArtifactCleanup MovePhotoResult.Failure("Could not copy the photo data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val deletedSource = documentSource.delete(sourceUri)
|
||||||
|
if (deletedSource) {
|
||||||
|
MovePhotoResult.Success
|
||||||
|
} else {
|
||||||
|
MovePhotoResult.PartialSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun moveFolder(sourceUri: Uri, targetFolderUri: Uri, folderName: String): MovePhotoResult {
|
||||||
|
val parent = parentOf(sourceUri)
|
||||||
|
invalidateBranch(
|
||||||
|
InvalidationPlan(
|
||||||
|
listingChanged = setOfNotNull(parent, targetFolderUri),
|
||||||
|
statsRoots = setOfNotNull(parent, targetFolderUri),
|
||||||
|
purgeSubtrees = setOf(sourceUri),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val sourceDocument = documentSource.getDocument(sourceUri)
|
||||||
|
?: return MovePhotoResult.Failure("Could not find the source folder.")
|
||||||
|
if (!sourceDocument.isDirectory) {
|
||||||
|
return MovePhotoResult.Failure("Selected item is not a folder.")
|
||||||
|
}
|
||||||
|
if (containsFolder(sourceUri, targetFolderUri)) {
|
||||||
|
return MovePhotoResult.Failure("Choose a folder outside the one you are moving.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetUri = documentSource.createDirectory(targetFolderUri, folderName)
|
||||||
|
?: return MovePhotoResult.Failure("Could not create the destination folder.")
|
||||||
|
return withPartialArtifactCleanup(
|
||||||
|
artifactUri = targetUri,
|
||||||
|
onCleanup = { MovePhotoResult.Failure("Could not copy the folder contents.") },
|
||||||
|
isComplete = { it !is MovePhotoResult.Failure },
|
||||||
|
) {
|
||||||
|
val copied = copyFolderContents(sourceUri, targetUri)
|
||||||
|
if (!copied) {
|
||||||
|
return@withPartialArtifactCleanup MovePhotoResult.Failure("Could not copy the folder contents.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val deletedSource = documentSource.delete(sourceUri)
|
||||||
|
if (deletedSource) {
|
||||||
|
MovePhotoResult.Success
|
||||||
|
} else {
|
||||||
|
MovePhotoResult.PartialSuccess("Folder was copied, but the original could not be deleted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveCapturedPhoto(tempPhotoUri: Uri, targetFolderUri: Uri, filename: String): PhotoSaveOutcome {
|
||||||
|
invalidateFolderContents(targetFolderUri)
|
||||||
|
val sourceLength = waitForStableCapturedPhoto(tempPhotoUri)
|
||||||
|
if (sourceLength == null) {
|
||||||
|
Log.e(TAG, "saveCapturedPhoto: empty/unstable capture, uri=$tempPhotoUri")
|
||||||
|
return PhotoSaveOutcome(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Camera returned an empty or incomplete photo. Please retake it.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val photoUri = documentSource.createFile(targetFolderUri, "image/jpeg", filename)
|
||||||
|
if (photoUri == null) {
|
||||||
|
Log.e(TAG, "saveCapturedPhoto: createFile failed, folder=$targetFolderUri name=$filename")
|
||||||
|
return PhotoSaveOutcome(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Failed to create file",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return withPartialArtifactCleanup(
|
||||||
|
artifactUri = photoUri,
|
||||||
|
onCleanup = { PhotoSaveOutcome(success = false, errorMessage = "Failed to copy the complete photo. Please retake it.") },
|
||||||
|
isComplete = { it.success },
|
||||||
|
) {
|
||||||
|
val copiedBytes = documentSource.copyBytes(tempPhotoUri, photoUri)
|
||||||
|
if (copiedBytes <= 0L || copiedBytes != sourceLength) {
|
||||||
|
Log.e(TAG, "saveCapturedPhoto: copy mismatch, copied=$copiedBytes source=$sourceLength")
|
||||||
|
return@withPartialArtifactCleanup PhotoSaveOutcome(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Failed to copy the complete photo. Please retake it.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetLength = documentSource.getContentLength(photoUri)
|
||||||
|
val hasValidImageContent = documentSource.hasValidImageContent(photoUri)
|
||||||
|
if (targetLength != sourceLength || !hasValidImageContent) {
|
||||||
|
Log.e(TAG, "saveCapturedPhoto: verification failed, target=$targetLength source=$sourceLength validImage=$hasValidImageContent")
|
||||||
|
return@withPartialArtifactCleanup PhotoSaveOutcome(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Saved photo verification failed. Please retake it.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "saveCapturedPhoto: saved $filename ($sourceLength bytes) to $targetFolderUri")
|
||||||
|
|
||||||
|
PhotoSaveOutcome(
|
||||||
|
success = true,
|
||||||
|
savedPhoto = Photo(
|
||||||
|
uri = photoUri,
|
||||||
|
name = filename,
|
||||||
|
kind = GalleryFileKind.Photo,
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun zipFolderPhotos(sourceFolderUri: Uri, targetFolderUri: Uri, archiveName: String): ZipFolderOutcome {
|
||||||
|
return zipEntries(
|
||||||
|
targetFolderUri = targetFolderUri,
|
||||||
|
archiveName = archiveName,
|
||||||
|
emptyMessage = "No photos found in this folder.",
|
||||||
|
) {
|
||||||
|
collectImageEntries(sourceFolderUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun zipFiles(targetFolderUri: Uri, archiveName: String, files: List<Photo>): ZipFolderOutcome {
|
||||||
|
return zipEntries(
|
||||||
|
targetFolderUri = targetFolderUri,
|
||||||
|
archiveName = archiveName,
|
||||||
|
emptyMessage = "Select at least one image.",
|
||||||
|
) {
|
||||||
|
files
|
||||||
|
.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
.map { ZipDocumentEntry(uri = it.uri, name = it.name.toZipEntryName()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun zipFolderCompressedJpegs(
|
||||||
|
sourceFolderUri: Uri,
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
archiveName: String,
|
||||||
|
quality: Int,
|
||||||
|
): ZipFolderOutcome {
|
||||||
|
return zipEntries(
|
||||||
|
targetFolderUri = targetFolderUri,
|
||||||
|
archiveName = archiveName,
|
||||||
|
emptyMessage = "No JPG photos found in this folder.",
|
||||||
|
writeMode = ZipWriteMode.CompressedJpeg(quality),
|
||||||
|
) {
|
||||||
|
collectJpegEntries(sourceFolderUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun zipCompressedJpegs(
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
archiveName: String,
|
||||||
|
files: List<Photo>,
|
||||||
|
quality: Int,
|
||||||
|
): ZipFolderOutcome {
|
||||||
|
return zipEntries(
|
||||||
|
targetFolderUri = targetFolderUri,
|
||||||
|
archiveName = archiveName,
|
||||||
|
emptyMessage = "Select at least one JPG photo.",
|
||||||
|
writeMode = ZipWriteMode.CompressedJpeg(quality),
|
||||||
|
) {
|
||||||
|
files
|
||||||
|
.filter { it.kind == GalleryFileKind.Photo && ImageFileMatcher.isJpegImage(it.mimeType, it.name) }
|
||||||
|
.map { ZipDocumentEntry(uri = it.uri, name = it.name.toZipEntryName()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> withPartialArtifactCleanup(
|
||||||
|
artifactUri: Uri,
|
||||||
|
onCleanup: () -> T,
|
||||||
|
isComplete: (T) -> Boolean,
|
||||||
|
block: suspend () -> T,
|
||||||
|
): T {
|
||||||
|
val result = try {
|
||||||
|
block()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
withContext(NonCancellable) { documentSource.delete(artifactUri) }
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
withContext(NonCancellable) { documentSource.delete(artifactUri) }
|
||||||
|
return onCleanup()
|
||||||
|
}
|
||||||
|
if (!isComplete(result)) {
|
||||||
|
withContext(NonCancellable) { documentSource.delete(artifactUri) }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun zipEntries(
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
archiveName: String,
|
||||||
|
emptyMessage: String,
|
||||||
|
writeMode: ZipWriteMode = ZipWriteMode.Original,
|
||||||
|
buildEntries: suspend () -> List<ZipDocumentEntry>,
|
||||||
|
): ZipFolderOutcome {
|
||||||
|
invalidateFolderContents(targetFolderUri)
|
||||||
|
val entries = buildEntries()
|
||||||
|
.sortedBy { it.name.lowercase() }
|
||||||
|
.withUniqueEntryNames()
|
||||||
|
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return ZipFolderOutcome(success = false, errorMessage = emptyMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
val uniqueArchiveName = archiveName.withAvailableArchiveSuffix(targetFolderUri)
|
||||||
|
|
||||||
|
val archiveUri = documentSource.createFile(targetFolderUri, ZIP_MIME_TYPE, uniqueArchiveName)
|
||||||
|
?: return ZipFolderOutcome(success = false, errorMessage = "Could not create ZIP archive.")
|
||||||
|
|
||||||
|
return withPartialArtifactCleanup(
|
||||||
|
artifactUri = archiveUri,
|
||||||
|
onCleanup = { ZipFolderOutcome(success = false, errorMessage = "Could not write photos to ZIP archive.") },
|
||||||
|
isComplete = { it.success },
|
||||||
|
) {
|
||||||
|
val copiedBytes = when (writeMode) {
|
||||||
|
ZipWriteMode.Original -> documentSource.writeZip(archiveUri, entries)
|
||||||
|
is ZipWriteMode.CompressedJpeg -> {
|
||||||
|
documentSource.writeCompressedJpegZip(archiveUri, entries, writeMode.quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copiedBytes <= 0L) {
|
||||||
|
return@withPartialArtifactCleanup ZipFolderOutcome(
|
||||||
|
success = false,
|
||||||
|
errorMessage = "Could not write photos to ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ZipFolderOutcome(
|
||||||
|
success = true,
|
||||||
|
archive = Photo(
|
||||||
|
uri = archiveUri,
|
||||||
|
name = uniqueArchiveName,
|
||||||
|
kind = GalleryFileKind.Archive,
|
||||||
|
mimeType = ZIP_MIME_TYPE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectImageEntries(folderUri: Uri, relativePath: String = ""): List<ZipDocumentEntry> {
|
||||||
|
val children = documentSource.listChildren(folderUri)
|
||||||
|
return children.flatMap { child ->
|
||||||
|
val childName = StorageNameValidator.sanitizeSegment(child.cleanName())
|
||||||
|
val entryName = if (relativePath.isBlank()) childName else "$relativePath/$childName"
|
||||||
|
when {
|
||||||
|
child.isDirectory -> collectImageEntries(child.uri, entryName)
|
||||||
|
ImageFileMatcher.isImageFile(child.mimeType, child.name) -> {
|
||||||
|
listOf(ZipDocumentEntry(uri = child.uri, name = entryName))
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectJpegEntries(folderUri: Uri, relativePath: String = ""): List<ZipDocumentEntry> {
|
||||||
|
val children = documentSource.listChildren(folderUri)
|
||||||
|
return children.flatMap { child ->
|
||||||
|
val childName = StorageNameValidator.sanitizeSegment(child.cleanName())
|
||||||
|
val entryName = if (relativePath.isBlank()) childName else "$relativePath/$childName"
|
||||||
|
when {
|
||||||
|
child.isDirectory -> collectJpegEntries(child.uri, entryName)
|
||||||
|
ImageFileMatcher.isJpegImage(child.mimeType, child.name) -> {
|
||||||
|
listOf(ZipDocumentEntry(uri = child.uri, name = entryName))
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun waitForStableCapturedPhoto(uri: Uri): Long? {
|
||||||
|
var lastLength: Long? = null
|
||||||
|
var stableReads = 0
|
||||||
|
|
||||||
|
repeat(CAPTURE_READY_CHECKS) {
|
||||||
|
val length = documentSource.getContentLength(uri)
|
||||||
|
if (length != null && length > 0L && length == lastLength) {
|
||||||
|
stableReads++
|
||||||
|
} else {
|
||||||
|
stableReads = if (length != null && length > 0L) 1 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length != null && length > 0L && stableReads >= REQUIRED_STABLE_SIZE_READS) {
|
||||||
|
if (documentSource.hasValidImageContent(uri)) return length
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLength = length
|
||||||
|
delay(CAPTURE_READY_DELAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun buildFolderTree(rootFolder: SafDocument, rootName: String): List<FolderNode> {
|
||||||
|
data class PendingFolder(
|
||||||
|
val folder: SafDocument,
|
||||||
|
val path: String,
|
||||||
|
val depth: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = mutableListOf<FolderNode>()
|
||||||
|
val pending = ArrayDeque<PendingFolder>()
|
||||||
|
pending.addLast(
|
||||||
|
PendingFolder(
|
||||||
|
folder = rootFolder,
|
||||||
|
path = rootName,
|
||||||
|
depth = 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
while (pending.isNotEmpty()) {
|
||||||
|
val current = pending.removeFirst()
|
||||||
|
val children = documentSource.listChildren(current.folder.uri)
|
||||||
|
result += buildFolderNode(
|
||||||
|
folder = current.folder,
|
||||||
|
path = current.path,
|
||||||
|
depth = current.depth,
|
||||||
|
children = children,
|
||||||
|
)
|
||||||
|
|
||||||
|
val childFolders = children
|
||||||
|
.filter { it.isDirectory }
|
||||||
|
.sortedBy { it.cleanName().lowercase() }
|
||||||
|
|
||||||
|
childFolders.forEach { child ->
|
||||||
|
pending.addLast(
|
||||||
|
PendingFolder(
|
||||||
|
folder = child,
|
||||||
|
path = "${current.path}/${child.cleanName()}",
|
||||||
|
depth = current.depth + 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun copyFolderContents(sourceFolderUri: Uri, targetFolderUri: Uri): Boolean {
|
||||||
|
data class CopyTask(
|
||||||
|
val sourceFolderUri: Uri,
|
||||||
|
val targetFolderUri: Uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
val pending = ArrayDeque<CopyTask>()
|
||||||
|
pending.addLast(CopyTask(sourceFolderUri = sourceFolderUri, targetFolderUri = targetFolderUri))
|
||||||
|
|
||||||
|
while (pending.isNotEmpty()) {
|
||||||
|
val task = pending.removeLast()
|
||||||
|
val children = documentSource.listChildren(task.sourceFolderUri)
|
||||||
|
for (child in children) {
|
||||||
|
if (child.isDirectory) {
|
||||||
|
val createdFolderUri = documentSource.createDirectory(task.targetFolderUri, child.cleanName())
|
||||||
|
?: return false
|
||||||
|
pending.addLast(CopyTask(sourceFolderUri = child.uri, targetFolderUri = createdFolderUri))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetUri = documentSource.createFile(task.targetFolderUri, child.mimeType.mimeTypeFromName(child.name), child.cleanName())
|
||||||
|
?: return false
|
||||||
|
val sourceLength = documentSource.getContentLength(child.uri)
|
||||||
|
val copiedBytes = documentSource.copyBytes(child.uri, targetUri)
|
||||||
|
val targetLength = documentSource.getContentLength(targetUri)
|
||||||
|
val completeCopy = copiedBytes >= 0L &&
|
||||||
|
(sourceLength == null || copiedBytes == sourceLength) &&
|
||||||
|
(sourceLength == null || targetLength == sourceLength)
|
||||||
|
val readableIfNonEmpty = copiedBytes == 0L || documentSource.hasReadableContent(targetUri)
|
||||||
|
if (!completeCopy || !readableIfNonEmpty) {
|
||||||
|
documentSource.delete(targetUri)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun containsFolder(rootUri: Uri, targetUri: Uri): Boolean {
|
||||||
|
val rootId = rootUri.docIdPathOrNull()
|
||||||
|
val targetId = targetUri.docIdPathOrNull()
|
||||||
|
if (rootId != null && targetId != null) {
|
||||||
|
return targetId == rootId || targetId.startsWith("$rootId/")
|
||||||
|
}
|
||||||
|
if (rootUri == targetUri) return true
|
||||||
|
val pending = ArrayDeque<Uri>()
|
||||||
|
pending.addLast(rootUri)
|
||||||
|
while (pending.isNotEmpty()) {
|
||||||
|
val current = pending.removeLast()
|
||||||
|
val childFolders = documentSource.listChildren(current).filter { it.isDirectory }
|
||||||
|
childFolders.forEach { child ->
|
||||||
|
if (child.uri == targetUri) return true
|
||||||
|
pending.addLast(child.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.docIdPathOrNull(): String? =
|
||||||
|
runCatching { android.provider.DocumentsContract.getDocumentId(this) }.getOrNull()
|
||||||
|
?: runCatching { android.provider.DocumentsContract.getTreeDocumentId(this) }.getOrNull()
|
||||||
|
|
||||||
|
private suspend fun buildFolderNode(folder: SafDocument, path: String, depth: Int, children: List<SafDocument>): FolderNode {
|
||||||
|
cachedFolderNode(folder.uri)?.let { return it }
|
||||||
|
val stats = subtreePhotoStats(folder.uri, children)
|
||||||
|
return FolderNode(
|
||||||
|
uri = folder.uri,
|
||||||
|
name = folder.cleanName(),
|
||||||
|
path = path,
|
||||||
|
depth = depth,
|
||||||
|
photoCount = stats.count,
|
||||||
|
firstPhotoUri = stats.previews.firstOrNull(),
|
||||||
|
previewPhotoUris = stats.previews,
|
||||||
|
).also { storeFolderNode(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CachedFolderNode(
|
||||||
|
val node: FolderNode,
|
||||||
|
val createdAtMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val folderNodeCacheLock = Any()
|
||||||
|
private val folderNodeCache = object : LinkedHashMap<Uri, CachedFolderNode>(64, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Uri, CachedFolderNode>?): Boolean =
|
||||||
|
size > FOLDER_NODE_CACHE_MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cachedFolderNode(uri: Uri): FolderNode? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return synchronized(folderNodeCacheLock) {
|
||||||
|
folderNodeCache[uri]?.let { cached ->
|
||||||
|
if (now - cached.createdAtMs < FOLDER_NODE_CACHE_TTL_MS) cached.node else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storeFolderNode(node: FolderNode) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
synchronized(folderNodeCacheLock) {
|
||||||
|
folderNodeCache[node.uri] = CachedFolderNode(node, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateFolderNodeCache() = synchronized(folderNodeCacheLock) {
|
||||||
|
folderNodeCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CachedGalleryContent(
|
||||||
|
val content: GalleryContent,
|
||||||
|
val createdAtMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val galleryCacheLock = Any()
|
||||||
|
private val galleryCache = object : LinkedHashMap<Uri, CachedGalleryContent>(32, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Uri, CachedGalleryContent>?): Boolean =
|
||||||
|
size > GALLERY_CACHE_MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateGalleryCache() = synchronized(galleryCacheLock) {
|
||||||
|
galleryCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cachedGalleryContent(folderUri: Uri): GalleryContent? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return synchronized(galleryCacheLock) {
|
||||||
|
galleryCache[folderUri]?.let { cached ->
|
||||||
|
if (now - cached.createdAtMs < GALLERY_CACHE_TTL_MS) cached.content else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storeGalleryContent(folderUri: Uri, content: GalleryContent) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
synchronized(galleryCacheLock) {
|
||||||
|
galleryCache[folderUri] = CachedGalleryContent(content, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.VisibleForTesting
|
||||||
|
internal data class SubtreePhotoStats(
|
||||||
|
val count: Int,
|
||||||
|
val previews: List<Uri>,
|
||||||
|
val createdAtMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val subtreeCacheLock = Any()
|
||||||
|
private val subtreeStatsCache = object : LinkedHashMap<Uri, SubtreePhotoStats>(64, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Uri, SubtreePhotoStats>?): Boolean =
|
||||||
|
size > SUBTREE_CACHE_MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateSubtreeStats() = synchronized(subtreeCacheLock) {
|
||||||
|
subtreeStatsCache.clear()
|
||||||
|
invalidateGalleryCache()
|
||||||
|
invalidateFolderNodeCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.annotation.VisibleForTesting
|
||||||
|
internal fun invalidateSubtreeStatsForTest() = invalidateSubtreeStats()
|
||||||
|
|
||||||
|
private data class InvalidationPlan(
|
||||||
|
val listingChanged: Set<Uri> = emptySet(),
|
||||||
|
val statsRoots: Set<Uri> = emptySet(),
|
||||||
|
val purgeSubtrees: Set<Uri> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun invalidateBranch(plan: InvalidationPlan) {
|
||||||
|
synchronized(subtreeCacheLock) {
|
||||||
|
subtreeStatsCache.keys.removeAll { key ->
|
||||||
|
plan.statsRoots.any { key.isSameOrAncestorOf(it) } ||
|
||||||
|
plan.purgeSubtrees.any { it.isSameOrAncestorOf(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized(galleryCacheLock) {
|
||||||
|
galleryCache.keys.removeAll { key ->
|
||||||
|
plan.listingChanged.any { it samePathAs key } ||
|
||||||
|
plan.purgeSubtrees.any { it.isSameOrAncestorOf(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized(folderNodeCacheLock) {
|
||||||
|
folderNodeCache.keys.removeAll { key ->
|
||||||
|
plan.statsRoots.any { key.isSameOrAncestorOf(it) } ||
|
||||||
|
plan.purgeSubtrees.any { it.isSameOrAncestorOf(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(plan.listingChanged + plan.purgeSubtrees).forEach { documentSource.invalidate(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.pathId(): String =
|
||||||
|
runCatching { android.provider.DocumentsContract.getDocumentId(this) }.getOrElse {
|
||||||
|
runCatching { android.provider.DocumentsContract.getTreeDocumentId(this) }
|
||||||
|
.getOrElse { toString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private infix fun Uri.samePathAs(other: Uri): Boolean = pathId() == other.pathId()
|
||||||
|
|
||||||
|
private fun Uri.isSameOrAncestorOf(other: Uri): Boolean {
|
||||||
|
val a = pathId()
|
||||||
|
val b = other.pathId()
|
||||||
|
return b == a || b.startsWith("$a/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parentOf(uri: Uri): Uri? {
|
||||||
|
val id = runCatching { android.provider.DocumentsContract.getDocumentId(uri) }.getOrNull()
|
||||||
|
?: return null
|
||||||
|
val cut = id.lastIndexOf('/')
|
||||||
|
if (cut <= 0) return null
|
||||||
|
return runCatching {
|
||||||
|
android.provider.DocumentsContract.buildDocumentUriUsingTree(uri, id.substring(0, cut))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateFolderContents(folderUri: Uri) = invalidateBranch(
|
||||||
|
InvalidationPlan(listingChanged = setOf(folderUri), statsRoots = setOf(folderUri)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun invalidateEntryChange(uri: Uri) {
|
||||||
|
val parent = parentOf(uri)
|
||||||
|
invalidateBranch(
|
||||||
|
InvalidationPlan(
|
||||||
|
listingChanged = setOfNotNull(parent),
|
||||||
|
statsRoots = setOfNotNull(parent),
|
||||||
|
purgeSubtrees = setOf(uri),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prewarmScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var prewarmJob: Job? = null
|
||||||
|
|
||||||
|
override fun invalidateCaches() {
|
||||||
|
documentSource.invalidateAll()
|
||||||
|
invalidateSubtreeStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun prewarmTree(rootUri: Uri) {
|
||||||
|
prewarmJob?.cancel()
|
||||||
|
prewarmJob = prewarmScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val queue = ArrayDeque<Uri>()
|
||||||
|
queue.add(rootUri)
|
||||||
|
while (queue.isNotEmpty()) {
|
||||||
|
val uri = queue.removeFirst()
|
||||||
|
val children = documentSource.listChildren(uri)
|
||||||
|
subtreePhotoStats(uri, children)
|
||||||
|
|
||||||
|
listGallery(uri)
|
||||||
|
children.filter { it.isDirectory }.forEach { queue.addLast(it.uri) }
|
||||||
|
|
||||||
|
delay(PREWARM_STEP_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cachedSubtreeStats(folderUri: Uri, now: Long): SubtreePhotoStats? =
|
||||||
|
synchronized(subtreeCacheLock) {
|
||||||
|
subtreeStatsCache[folderUri]?.let { cached ->
|
||||||
|
if (now - cached.createdAtMs < SUBTREE_CACHE_TTL_MS) {
|
||||||
|
cached
|
||||||
|
} else {
|
||||||
|
subtreeStatsCache.remove(folderUri)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun subtreePhotoStats(folderUri: Uri, children: List<SafDocument>): SubtreePhotoStats {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
cachedSubtreeStats(folderUri, now)?.let { return it }
|
||||||
|
val computed = collectDescendantPhotoStats(children, now)
|
||||||
|
synchronized(subtreeCacheLock) { subtreeStatsCache[folderUri] = computed }
|
||||||
|
return computed
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun subtreeStats(folderUri: Uri): SubtreeStats {
|
||||||
|
val t0 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
|
||||||
|
cachedSubtreeStats(folderUri, System.currentTimeMillis())?.let { cached ->
|
||||||
|
Log.d("PerfBaseline", "subtreeStats HIT total=${android.os.SystemClock.elapsedRealtime() - t0}ms uri=$folderUri")
|
||||||
|
return SubtreeStats(count = cached.count, previews = cached.previews)
|
||||||
|
}
|
||||||
|
val children = documentSource.listChildren(folderUri)
|
||||||
|
val t1 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
val stats = subtreePhotoStats(folderUri, children)
|
||||||
|
val t2 = android.os.SystemClock.elapsedRealtime()
|
||||||
|
Log.d("PerfBaseline", "subtreeStats MISS total=${t2 - t0}ms (listChildren=${t1 - t0}, walk=${t2 - t1}) uri=$folderUri")
|
||||||
|
return SubtreeStats(count = stats.count, previews = stats.previews)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun collectDescendantPhotoStats(
|
||||||
|
children: List<SafDocument>,
|
||||||
|
createdAtMs: Long,
|
||||||
|
): SubtreePhotoStats = withContext(Dispatchers.IO) {
|
||||||
|
var count = 0
|
||||||
|
val previews = ArrayList<Uri>(FOLDER_PREVIEW_COUNT)
|
||||||
|
|
||||||
|
var level: List<Pair<SafDocument?, List<SafDocument>>> =
|
||||||
|
listOf(null to children.sortedBy { it.cleanName().lowercase() })
|
||||||
|
while (level.isNotEmpty()) {
|
||||||
|
|
||||||
|
val deferredChildren = level.map { (branch, docs) ->
|
||||||
|
async {
|
||||||
|
val childFolders = docs.filter { it.isDirectory }
|
||||||
|
childFolders.map { folder ->
|
||||||
|
val subChildren = documentSource.listChildren(folder.uri)
|
||||||
|
.sortedBy { it.cleanName().lowercase() }
|
||||||
|
(branch ?: folder) to subChildren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll().flatten()
|
||||||
|
|
||||||
|
val roundRobin = LinkedHashMap<SafDocument?, ArrayDeque<Uri>>()
|
||||||
|
var buffered = 0
|
||||||
|
for ((branch, docs) in level) {
|
||||||
|
for (child in docs) {
|
||||||
|
if (!child.isDirectory && ImageFileMatcher.isImageFile(child.mimeType, child.name)) {
|
||||||
|
count++
|
||||||
|
|
||||||
|
if (previews.size + buffered < FOLDER_PREVIEW_COUNT) {
|
||||||
|
roundRobin.getOrPut(branch) { ArrayDeque() }.addLast(child.uri)
|
||||||
|
buffered++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (previews.size < FOLDER_PREVIEW_COUNT && buffered > 0) {
|
||||||
|
for (bucket in roundRobin.values) {
|
||||||
|
if (previews.size == FOLDER_PREVIEW_COUNT) break
|
||||||
|
val uri = bucket.removeFirstOrNull() ?: continue
|
||||||
|
previews.add(uri)
|
||||||
|
buffered--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = deferredChildren
|
||||||
|
}
|
||||||
|
SubtreePhotoStats(count = count, previews = previews, createdAtMs = createdAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getFolderName(folderUri: Uri): String {
|
||||||
|
return documentSource.getDocument(folderUri)?.cleanName()?.takeIf { it.isNotBlank() } ?: "Gallery"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SafDocument.cleanName(): String = StorageNameFormatter.cleanFolderName(name)
|
||||||
|
|
||||||
|
private fun SafDocument.toPhoto(): Photo {
|
||||||
|
val isArchive = ImageFileMatcher.isZipArchive(mimeType, name)
|
||||||
|
return Photo(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
kind = if (isArchive) GalleryFileKind.Archive else GalleryFileKind.Photo,
|
||||||
|
mimeType = if (isArchive) ZIP_MIME_TYPE else mimeType.takeIf { it.isNotBlank() } ?: "image/jpeg",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SafDocument.isGalleryFile(): Boolean {
|
||||||
|
return ImageFileMatcher.isImageFile(mimeType, name) || ImageFileMatcher.isZipArchive(mimeType, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.mimeTypeFromName(): String {
|
||||||
|
return if (ImageFileMatcher.isZipArchive(null, this)) ZIP_MIME_TYPE else "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.mimeTypeFromName(name: String): String {
|
||||||
|
return takeIf { it.isNotBlank() } ?: name.mimeTypeFromName()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toZipEntryName(): String {
|
||||||
|
return StorageNameValidator.sanitizeSegment(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun String.withAvailableArchiveSuffix(targetFolderUri: Uri): String {
|
||||||
|
val usedNames = documentSource.listChildren(targetFolderUri)
|
||||||
|
.map { it.cleanName().lowercase() }
|
||||||
|
.toSet()
|
||||||
|
val desiredName = ensureZipExtension()
|
||||||
|
if (desiredName.lowercase() !in usedNames) return desiredName
|
||||||
|
|
||||||
|
val baseName = desiredName.removeSuffix(".zip")
|
||||||
|
var suffix = 2
|
||||||
|
while (true) {
|
||||||
|
val candidate = "${baseName}_$suffix.zip"
|
||||||
|
if (candidate.lowercase() !in usedNames) return candidate
|
||||||
|
suffix++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.ensureZipExtension(): String {
|
||||||
|
return if (endsWith(".zip", ignoreCase = true)) this else "$this.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ZipDocumentEntry>.withUniqueEntryNames(): List<ZipDocumentEntry> {
|
||||||
|
val seen = mutableMapOf<String, Int>()
|
||||||
|
return map { entry ->
|
||||||
|
val count = seen.getOrDefault(entry.name, 0)
|
||||||
|
seen[entry.name] = count + 1
|
||||||
|
if (count == 0) {
|
||||||
|
entry
|
||||||
|
} else {
|
||||||
|
val baseName = entry.name.substringBeforeLast('.', entry.name)
|
||||||
|
val extension = entry.name.substringAfterLast('.', "")
|
||||||
|
val uniqueName = if (extension.isBlank()) {
|
||||||
|
"${baseName}_$count"
|
||||||
|
} else {
|
||||||
|
"${baseName}_$count.$extension"
|
||||||
|
}
|
||||||
|
entry.copy(name = uniqueName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "OneCameraSave"
|
||||||
|
const val ZIP_MIME_TYPE = "application/zip"
|
||||||
|
|
||||||
|
const val SUBTREE_CACHE_TTL_MS = 300_000L
|
||||||
|
const val SUBTREE_CACHE_MAX_ENTRIES = 256
|
||||||
|
const val FOLDER_PREVIEW_COUNT = 4
|
||||||
|
const val GALLERY_CACHE_TTL_MS = 300_000L
|
||||||
|
const val GALLERY_CACHE_MAX_ENTRIES = 32
|
||||||
|
const val FOLDER_NODE_CACHE_TTL_MS = 300_000L
|
||||||
|
const val FOLDER_NODE_CACHE_MAX_ENTRIES = 64
|
||||||
|
|
||||||
|
val PREWARM_STEP_DELAY = 30.milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface ZipWriteMode {
|
||||||
|
data object Original : ZipWriteMode
|
||||||
|
data class CompressedJpeg(val quality: Int) : ZipWriteMode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.onecamera.data.storage
|
||||||
|
|
||||||
|
object StorageNameFormatter {
|
||||||
|
fun cleanFolderName(name: String): String {
|
||||||
|
return if (name.endsWith("_") && !name.endsWith("__")) {
|
||||||
|
name.removeSuffix("_")
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.onecamera.data.storage
|
||||||
|
|
||||||
|
object StorageNameValidator {
|
||||||
|
|
||||||
|
const val MAX_NAME_LENGTH = 255
|
||||||
|
|
||||||
|
private val ILLEGAL_CHARS = Regex("""[\\/:*?"<>|]""")
|
||||||
|
|
||||||
|
private val WHITESPACE_RUNS = Regex("""\s+""")
|
||||||
|
|
||||||
|
private val RESERVED_NAMES = setOf(".", "..")
|
||||||
|
|
||||||
|
fun validate(rawName: String): String? {
|
||||||
|
val name = rawName.trim()
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
if (name.length > MAX_NAME_LENGTH) return null
|
||||||
|
if (name in RESERVED_NAMES) return null
|
||||||
|
if (ILLEGAL_CHARS.containsMatchIn(name)) return null
|
||||||
|
if (name.all { it == '.' || it == ' ' }) return null
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
fun folderName(rawName: String): String? = validate(rawName.trim().replace(WHITESPACE_RUNS, "_"))
|
||||||
|
|
||||||
|
fun isValid(rawName: String): Boolean = validate(rawName) != null
|
||||||
|
|
||||||
|
fun sanitizeSegment(rawSegment: String): String {
|
||||||
|
val leaf = rawSegment
|
||||||
|
.substringAfterLast('/')
|
||||||
|
.substringAfterLast('\\')
|
||||||
|
.trim()
|
||||||
|
val cleaned = ILLEGAL_CHARS.replace(leaf, "_")
|
||||||
|
return when {
|
||||||
|
cleaned.isEmpty() || cleaned in RESERVED_NAMES -> "_"
|
||||||
|
cleaned.all { it == '.' || it == ' ' } -> "_"
|
||||||
|
cleaned.length > MAX_NAME_LENGTH -> cleaned.take(MAX_NAME_LENGTH)
|
||||||
|
else -> cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.example.onecamera.data.storage.saf
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CachingDocumentSource @Inject constructor(
|
||||||
|
private val delegate: DocumentSource,
|
||||||
|
) : DocumentSource {
|
||||||
|
|
||||||
|
private val listLock = Any()
|
||||||
|
private val docLock = Any()
|
||||||
|
private val listCache = object : LinkedHashMap<Uri, CachedList>(
|
||||||
|
LIST_CACHE_MAX_ENTRIES,
|
||||||
|
0.75f,
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
override fun removeEldestEntry(
|
||||||
|
eldest: MutableMap.MutableEntry<Uri, CachedList>?,
|
||||||
|
): Boolean = size > LIST_CACHE_MAX_ENTRIES
|
||||||
|
}
|
||||||
|
private val docCache = object : LinkedHashMap<Uri, CachedDoc>(
|
||||||
|
DOC_CACHE_MAX_ENTRIES,
|
||||||
|
0.75f,
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
override fun removeEldestEntry(
|
||||||
|
eldest: MutableMap.MutableEntry<Uri, CachedDoc>?,
|
||||||
|
): Boolean = size > DOC_CACHE_MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
var listHits: Long = 0
|
||||||
|
private set
|
||||||
|
var listMisses: Long = 0
|
||||||
|
private set
|
||||||
|
var docHits: Long = 0
|
||||||
|
private set
|
||||||
|
var docMisses: Long = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun invalidateAll() {
|
||||||
|
synchronized(listLock) { listCache.clear() }
|
||||||
|
synchronized(docLock) { docCache.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun invalidate(uri: Uri) {
|
||||||
|
val id = docPathId(uri)
|
||||||
|
synchronized(listLock) {
|
||||||
|
if (id == null) listCache.remove(uri)
|
||||||
|
else listCache.keys.removeAll { docPathId(it) == id }
|
||||||
|
}
|
||||||
|
synchronized(docLock) {
|
||||||
|
if (id == null) docCache.remove(uri)
|
||||||
|
else docCache.keys.removeAll { docPathId(it) == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun docPathId(uri: Uri): String? =
|
||||||
|
runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull()
|
||||||
|
?: runCatching { DocumentsContract.getTreeDocumentId(uri) }.getOrNull()
|
||||||
|
|
||||||
|
override suspend fun listChildren(parentUri: Uri): List<SafDocument> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val hit: CachedList? = synchronized(listLock) {
|
||||||
|
val entry = listCache[parentUri]
|
||||||
|
if (entry != null && now - entry.at < LIST_TTL_MS) {
|
||||||
|
listHits++
|
||||||
|
entry
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
if (hit != null) return hit.docs
|
||||||
|
synchronized(listLock) { listMisses++ }
|
||||||
|
val docs = delegate.listChildren(parentUri)
|
||||||
|
synchronized(listLock) { listCache[parentUri] = CachedList(now, docs) }
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDocument(uri: Uri): SafDocument? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val hit: SafDocument? = synchronized(docLock) {
|
||||||
|
val entry = docCache[uri]
|
||||||
|
if (entry != null && now - entry.at < DOC_TTL_MS) {
|
||||||
|
docHits++
|
||||||
|
entry.doc
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
if (hit != null) return hit
|
||||||
|
synchronized(docLock) { docMisses++ }
|
||||||
|
val doc = delegate.getDocument(uri)
|
||||||
|
if (doc != null) synchronized(docLock) { docCache[uri] = CachedDoc(now, doc) }
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createDirectory(parentUri: Uri, name: String): Uri? =
|
||||||
|
delegate.createDirectory(parentUri, name)
|
||||||
|
|
||||||
|
override suspend fun createFile(parentUri: Uri, mimeType: String, name: String): Uri? =
|
||||||
|
delegate.createFile(parentUri, mimeType, name)
|
||||||
|
|
||||||
|
override suspend fun rename(uri: Uri, newName: String): Boolean =
|
||||||
|
delegate.rename(uri, newName)
|
||||||
|
|
||||||
|
override suspend fun delete(uri: Uri): Boolean =
|
||||||
|
delegate.delete(uri)
|
||||||
|
|
||||||
|
override suspend fun copyBytes(sourceUri: Uri, targetUri: Uri): Long =
|
||||||
|
delegate.copyBytes(sourceUri, targetUri)
|
||||||
|
|
||||||
|
override suspend fun writeZip(targetUri: Uri, entries: List<ZipDocumentEntry>): Long =
|
||||||
|
delegate.writeZip(targetUri, entries)
|
||||||
|
|
||||||
|
override suspend fun writeCompressedJpegZip(
|
||||||
|
targetUri: Uri,
|
||||||
|
entries: List<ZipDocumentEntry>,
|
||||||
|
quality: Int,
|
||||||
|
): Long = delegate.writeCompressedJpegZip(targetUri, entries, quality)
|
||||||
|
|
||||||
|
override suspend fun getContentLength(uri: Uri): Long? =
|
||||||
|
delegate.getContentLength(uri)
|
||||||
|
|
||||||
|
override suspend fun hasReadableContent(uri: Uri): Boolean =
|
||||||
|
delegate.hasReadableContent(uri)
|
||||||
|
|
||||||
|
override suspend fun hasValidImageContent(uri: Uri): Boolean =
|
||||||
|
delegate.hasValidImageContent(uri)
|
||||||
|
|
||||||
|
private data class CachedList(val at: Long, val docs: List<SafDocument>)
|
||||||
|
private data class CachedDoc(val at: Long, val doc: SafDocument)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LIST_TTL_MS = 30_000L
|
||||||
|
const val DOC_TTL_MS = 30_000L
|
||||||
|
const val LIST_CACHE_MAX_ENTRIES = 256
|
||||||
|
const val DOC_CACHE_MAX_ENTRIES = 64
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.onecamera.data.storage.saf
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
data class ZipDocumentEntry(
|
||||||
|
val uri: Uri,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface DocumentSource {
|
||||||
|
suspend fun listChildren(parentUri: Uri): List<SafDocument>
|
||||||
|
suspend fun getDocument(uri: Uri): SafDocument?
|
||||||
|
|
||||||
|
fun invalidate(uri: Uri) {}
|
||||||
|
|
||||||
|
fun invalidateAll() {}
|
||||||
|
suspend fun createDirectory(parentUri: Uri, name: String): Uri?
|
||||||
|
suspend fun createFile(parentUri: Uri, mimeType: String, name: String): Uri?
|
||||||
|
suspend fun rename(uri: Uri, newName: String): Boolean
|
||||||
|
suspend fun delete(uri: Uri): Boolean
|
||||||
|
suspend fun copyBytes(sourceUri: Uri, targetUri: Uri): Long
|
||||||
|
suspend fun writeZip(targetUri: Uri, entries: List<ZipDocumentEntry>): Long
|
||||||
|
suspend fun writeCompressedJpegZip(targetUri: Uri, entries: List<ZipDocumentEntry>, quality: Int): Long
|
||||||
|
suspend fun getContentLength(uri: Uri): Long?
|
||||||
|
suspend fun hasReadableContent(uri: Uri): Boolean
|
||||||
|
suspend fun hasValidImageContent(uri: Uri): Boolean
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.onecamera.data.storage.saf
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
class MissingUriPermissionException(
|
||||||
|
uri: Uri,
|
||||||
|
) : IllegalStateException("Persisted URI permission is missing or was revoked for $uri")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.onecamera.data.storage.saf
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
data class SafDocument(
|
||||||
|
val uri: Uri,
|
||||||
|
val name: String,
|
||||||
|
val mimeType: String,
|
||||||
|
) {
|
||||||
|
val isDirectory: Boolean
|
||||||
|
get() = mimeType == android.provider.DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package com.example.onecamera.data.storage.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.zip.CRC32
|
||||||
|
import java.util.zip.CheckedInputStream
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SafDocumentSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : DocumentSource {
|
||||||
|
private val resolver = context.contentResolver
|
||||||
|
|
||||||
|
private val permissionPrefixesLock = Any()
|
||||||
|
private var cachedPermissionPrefixes: List<String>? = null
|
||||||
|
private var cachedPermissionPrefixesAtMs = 0L
|
||||||
|
|
||||||
|
override suspend fun listChildren(parentUri: Uri): List<SafDocument> = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
|
requirePersistedPermission(parentUri)
|
||||||
|
val parentDocId = getDocumentId(parentUri)
|
||||||
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parentUri, parentDocId)
|
||||||
|
val documents = mutableListOf<SafDocument>()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
resolver.query(
|
||||||
|
childrenUri,
|
||||||
|
arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
|
||||||
|
"${DocumentsContract.Document.COLUMN_DISPLAY_NAME} ASC",
|
||||||
|
)?.use { cursor ->
|
||||||
|
val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
|
val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||||
|
val mimeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||||
|
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val childDocId = cursor.getString(idIndex)
|
||||||
|
documents += SafDocument(
|
||||||
|
uri = DocumentsContract.buildDocumentUriUsingTree(parentUri, childDocId),
|
||||||
|
name = cursor.getString(nameIndex).orEmpty(),
|
||||||
|
mimeType = cursor.getString(mimeIndex).orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { error ->
|
||||||
|
|
||||||
|
if (error is SecurityException) throw MissingUriPermissionException(parentUri)
|
||||||
|
}
|
||||||
|
documents
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDocument(uri: Uri): SafDocument? = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(uri)
|
||||||
|
runCatching {
|
||||||
|
val docId = getDocumentId(uri)
|
||||||
|
val docUri = DocumentsContract.buildDocumentUriUsingTree(uri, docId)
|
||||||
|
resolver.query(
|
||||||
|
docUri,
|
||||||
|
arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
SafDocument(
|
||||||
|
uri = docUri,
|
||||||
|
name = cursor.getString(1).orEmpty(),
|
||||||
|
mimeType = cursor.getString(2).orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createDirectory(parentUri: Uri, name: String): Uri? = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(parentUri)
|
||||||
|
runCatching {
|
||||||
|
DocumentsContract.createDocument(
|
||||||
|
resolver,
|
||||||
|
documentUri(parentUri),
|
||||||
|
DocumentsContract.Document.MIME_TYPE_DIR,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createFile(parentUri: Uri, mimeType: String, name: String): Uri? = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(parentUri)
|
||||||
|
runCatching {
|
||||||
|
DocumentsContract.createDocument(resolver, documentUri(parentUri), mimeType, name)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(uri: Uri, newName: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(uri)
|
||||||
|
runCatching {
|
||||||
|
DocumentsContract.renameDocument(resolver, uri, newName) != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(uri)
|
||||||
|
runCatching {
|
||||||
|
DocumentsContract.deleteDocument(resolver, uri)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun copyBytes(sourceUri: Uri, targetUri: Uri): Long = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(sourceUri)
|
||||||
|
requirePersistedPermission(targetUri)
|
||||||
|
var copiedBytes = 0L
|
||||||
|
resolver.openInputStream(sourceUri)?.use { input ->
|
||||||
|
resolver.openOutputStream(targetUri, "w")?.use { output ->
|
||||||
|
copiedBytes = input.copyTo(output)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copiedBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeZip(targetUri: Uri, entries: List<ZipDocumentEntry>): Long = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(targetUri)
|
||||||
|
var copiedBytes = 0L
|
||||||
|
resolver.openOutputStream(targetUri, "w")?.use { output ->
|
||||||
|
ZipOutputStream(BufferedOutputStream(output, ZIP_BUFFER_SIZE), StandardCharsets.UTF_8).use { zipOutput ->
|
||||||
|
zipOutput.setLevel(Deflater.DEFAULT_COMPRESSION)
|
||||||
|
val buffer = ByteArray(ZIP_BUFFER_SIZE)
|
||||||
|
entries.forEach { entry ->
|
||||||
|
resolver.openInputStream(entry.uri)?.use { rawInput ->
|
||||||
|
CheckedInputStream(rawInput, CRC32()).use { input ->
|
||||||
|
val ze = ZipEntry(entry.name).apply { method = ZipEntry.DEFLATED }
|
||||||
|
zipOutput.putNextEntry(ze)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) break
|
||||||
|
zipOutput.write(buffer, 0, read)
|
||||||
|
copiedBytes += read
|
||||||
|
}
|
||||||
|
zipOutput.closeEntry()
|
||||||
|
}
|
||||||
|
} ?: error("Could not open ${entry.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: error("Could not open archive for writing")
|
||||||
|
copiedBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeCompressedJpegZip(
|
||||||
|
targetUri: Uri,
|
||||||
|
entries: List<ZipDocumentEntry>,
|
||||||
|
quality: Int,
|
||||||
|
): Long = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(targetUri)
|
||||||
|
val parallelism = encodeParallelism()
|
||||||
|
val semaphore = Semaphore(parallelism)
|
||||||
|
|
||||||
|
val compressedBuffers = entries.map { entry ->
|
||||||
|
async(Dispatchers.Default) {
|
||||||
|
semaphore.withPermit {
|
||||||
|
val source = ImageDecoder.createSource(resolver, entry.uri)
|
||||||
|
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||||
|
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||||
|
val sampleSize = computeSampleSize(info.size.width, info.size.height)
|
||||||
|
if (sampleSize > 1) {
|
||||||
|
decoder.setTargetSampleSize(sampleSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val buf = ByteArrayOutputStream()
|
||||||
|
val compressed = bitmap.compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(0, 100), buf)
|
||||||
|
bitmap.recycle()
|
||||||
|
if (!compressed) {
|
||||||
|
error("Could not compress ${entry.name}")
|
||||||
|
}
|
||||||
|
entry to buf.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
|
||||||
|
var writtenEntries = 0L
|
||||||
|
resolver.openOutputStream(targetUri, "w")?.use { output ->
|
||||||
|
ZipOutputStream(BufferedOutputStream(output, ZIP_BUFFER_SIZE), StandardCharsets.UTF_8).use { zipOutput ->
|
||||||
|
zipOutput.setLevel(Deflater.DEFAULT_COMPRESSION)
|
||||||
|
val buffer = ByteArray(ZIP_BUFFER_SIZE)
|
||||||
|
compressedBuffers.forEach { (entry, bytes) ->
|
||||||
|
CheckedInputStream(ByteArrayInputStream(bytes), CRC32()).use { input ->
|
||||||
|
val ze = ZipEntry(entry.name.toJpegEntryName()).apply { method = ZipEntry.DEFLATED }
|
||||||
|
zipOutput.putNextEntry(ze)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) break
|
||||||
|
zipOutput.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
zipOutput.closeEntry()
|
||||||
|
}
|
||||||
|
writtenEntries++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: error("Could not open archive for writing")
|
||||||
|
writtenEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeSampleSize(width: Int, height: Int): Int {
|
||||||
|
if (width <= 0 || height <= 0) return 1
|
||||||
|
var sampleSize = 1
|
||||||
|
var pixels = width.toLong() * height.toLong()
|
||||||
|
while (pixels > MAX_DECODE_PIXELS) {
|
||||||
|
sampleSize *= 2
|
||||||
|
pixels /= 4
|
||||||
|
}
|
||||||
|
return sampleSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeParallelism(): Int {
|
||||||
|
val byCores = Runtime.getRuntime().availableProcessors().coerceIn(1, MAX_PARALLEL_ENCODES)
|
||||||
|
val maxHeapBytes = Runtime.getRuntime().maxMemory()
|
||||||
|
val perDecodeBytes = MAX_DECODE_PIXELS * 4L
|
||||||
|
val byMemory = (maxHeapBytes / 4 / perDecodeBytes).toInt().coerceAtLeast(1)
|
||||||
|
return minOf(byCores, byMemory)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getContentLength(uri: Uri): Long? = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(uri)
|
||||||
|
runCatching {
|
||||||
|
queryContentLength(uri) ?: resolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
|
descriptor.length.takeIf { it >= 0L }
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun hasReadableContent(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(uri)
|
||||||
|
runCatching {
|
||||||
|
resolver.openInputStream(uri)?.use { input -> input.read() != -1 } ?: false
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun hasValidImageContent(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
requirePersistedPermission(uri)
|
||||||
|
|
||||||
|
runCatching { hasJpegBoundariesSeekable(uri) }
|
||||||
|
.getOrElse { runCatching { hasJpegBoundariesStreamed(uri) }.getOrDefault(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasJpegBoundariesSeekable(uri: Uri): Boolean {
|
||||||
|
val pfd = resolver.openFileDescriptor(uri, "r") ?: return false
|
||||||
|
return hasJpegBoundaries(pfd)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasJpegBoundaries(pfd: ParcelFileDescriptor): Boolean {
|
||||||
|
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
|
||||||
|
val size = pfd.statSize
|
||||||
|
|
||||||
|
if (size < MIN_VALID_JPEG_BYTES) return false
|
||||||
|
val channel = input.channel
|
||||||
|
val head = ByteArray(JPEG_MARKER_BYTES)
|
||||||
|
if (!readFullyAt(channel, head, 0L)) return false
|
||||||
|
val tail = ByteArray(JPEG_MARKER_BYTES)
|
||||||
|
if (!readFullyAt(channel, tail, size - JPEG_MARKER_BYTES)) return false
|
||||||
|
return (head[0].toInt() and 0xFF) == JPEG_START_FIRST_BYTE &&
|
||||||
|
(head[1].toInt() and 0xFF) == JPEG_START_SECOND_BYTE &&
|
||||||
|
(tail[0].toInt() and 0xFF) == JPEG_END_FIRST_BYTE &&
|
||||||
|
(tail[1].toInt() and 0xFF) == JPEG_END_SECOND_BYTE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFullyAt(channel: FileChannel, target: ByteArray, position: Long): Boolean {
|
||||||
|
var read = 0
|
||||||
|
while (read < target.size) {
|
||||||
|
val chunk = channel.read(ByteBuffer.wrap(target, read, target.size - read), position + read)
|
||||||
|
if (chunk < 0) return false
|
||||||
|
read += chunk
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasJpegBoundariesStreamed(uri: Uri): Boolean {
|
||||||
|
val input = resolver.openInputStream(uri) ?: return false
|
||||||
|
input.use { return hasJpegBoundaries(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasJpegBoundaries(input: InputStream): Boolean {
|
||||||
|
var totalBytes = 0L
|
||||||
|
var firstByte = -1
|
||||||
|
var secondByte = -1
|
||||||
|
var previousByte = -1
|
||||||
|
var lastByte = -1
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val bytesRead = input.read(buffer)
|
||||||
|
if (bytesRead == -1) break
|
||||||
|
for (index in 0 until bytesRead) {
|
||||||
|
val value = buffer[index].toInt() and 0xFF
|
||||||
|
when (totalBytes) {
|
||||||
|
0L -> firstByte = value
|
||||||
|
1L -> secondByte = value
|
||||||
|
}
|
||||||
|
previousByte = lastByte
|
||||||
|
lastByte = value
|
||||||
|
totalBytes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalBytes > 2L &&
|
||||||
|
firstByte == JPEG_START_FIRST_BYTE &&
|
||||||
|
secondByte == JPEG_START_SECOND_BYTE &&
|
||||||
|
previousByte == JPEG_END_FIRST_BYTE &&
|
||||||
|
lastByte == JPEG_END_SECOND_BYTE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePersistedPermission(uri: Uri) {
|
||||||
|
|
||||||
|
if (uri.authority == "${context.packageName}.fileprovider") return
|
||||||
|
val value = uri.toString()
|
||||||
|
if (permissionPrefixes().any { value == it || value.startsWith(it) }) return
|
||||||
|
|
||||||
|
val fresh = resolver.persistedUriPermissions.map { it.uri.toString() }
|
||||||
|
synchronized(permissionPrefixesLock) {
|
||||||
|
cachedPermissionPrefixes = fresh
|
||||||
|
cachedPermissionPrefixesAtMs = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
if (fresh.none { value == it || value.startsWith(it) }) {
|
||||||
|
throw MissingUriPermissionException(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permissionPrefixes(): List<String> = synchronized(permissionPrefixesLock) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val cached = cachedPermissionPrefixes
|
||||||
|
if (cached != null && now - cachedPermissionPrefixesAtMs < PERMISSION_CACHE_TTL_MS) {
|
||||||
|
cached
|
||||||
|
} else {
|
||||||
|
resolver.persistedUriPermissions
|
||||||
|
.map { it.uri.toString() }
|
||||||
|
.also {
|
||||||
|
cachedPermissionPrefixes = it
|
||||||
|
cachedPermissionPrefixesAtMs = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDocumentId(uri: Uri): String {
|
||||||
|
return try {
|
||||||
|
DocumentsContract.getDocumentId(uri)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun documentUri(uri: Uri): Uri {
|
||||||
|
return DocumentsContract.buildDocumentUriUsingTree(uri, getDocumentId(uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryContentLength(uri: Uri): Long? {
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
arrayOf(OpenableColumns.SIZE),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) return@use null
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex == -1 || cursor.isNull(sizeIndex)) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
cursor.getLong(sizeIndex).takeIf { it >= 0L }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJpegEntryName(): String {
|
||||||
|
val baseName = substringBeforeLast('.', this)
|
||||||
|
return "$baseName.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val JPEG_START_FIRST_BYTE = 0xFF
|
||||||
|
const val JPEG_START_SECOND_BYTE = 0xD8
|
||||||
|
const val JPEG_END_FIRST_BYTE = 0xFF
|
||||||
|
const val JPEG_END_SECOND_BYTE = 0xD9
|
||||||
|
const val JPEG_MARKER_BYTES = 2
|
||||||
|
|
||||||
|
const val MIN_VALID_JPEG_BYTES = 4L
|
||||||
|
|
||||||
|
const val ZIP_BUFFER_SIZE = 256 * 1024
|
||||||
|
const val MAX_PARALLEL_ENCODES = 6
|
||||||
|
const val MAX_DECODE_PIXELS = 16_000_000L
|
||||||
|
const val PERMISSION_CACHE_TTL_MS = 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.onecamera.di
|
||||||
|
|
||||||
|
import com.example.onecamera.data.storage.SafPhotoRepository
|
||||||
|
import com.example.onecamera.data.storage.saf.CachingDocumentSource
|
||||||
|
import com.example.onecamera.data.storage.saf.DocumentSource
|
||||||
|
import com.example.onecamera.data.storage.saf.SafDocumentSource
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDocumentSource(source: SafDocumentSource): DocumentSource =
|
||||||
|
CachingDocumentSource(source)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePhotoRepository(repository: SafPhotoRepository): PhotoRepository = repository
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class FolderNode(
|
||||||
|
val uri: Uri,
|
||||||
|
val name: String,
|
||||||
|
val path: String,
|
||||||
|
val depth: Int,
|
||||||
|
val photoCount: Int,
|
||||||
|
val firstPhotoUri: Uri?,
|
||||||
|
val previewPhotoUris: List<Uri> = emptyList(),
|
||||||
|
|
||||||
|
val statsLoaded: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun mergeLoadedSubfolderStats(fresh: List<FolderNode>, previous: List<FolderNode>): List<FolderNode> {
|
||||||
|
if (previous.isEmpty()) return fresh
|
||||||
|
val previousById = previous.associateBy { it.uri }
|
||||||
|
return fresh.map { node ->
|
||||||
|
val loaded = previousById[node.uri]?.takeIf { it.statsLoaded } ?: return@map node
|
||||||
|
node.copy(
|
||||||
|
photoCount = loaded.photoCount,
|
||||||
|
firstPhotoUri = loaded.firstPhotoUri,
|
||||||
|
previewPhotoUris = loaded.previewPhotoUris,
|
||||||
|
statsLoaded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
data class GalleryContent(
|
||||||
|
val folderName: String,
|
||||||
|
val subfolders: List<FolderNode>,
|
||||||
|
val photos: List<Photo>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
sealed interface MovePhotoResult {
|
||||||
|
data object Success : MovePhotoResult
|
||||||
|
data class PartialSuccess(
|
||||||
|
val message: String = "Photo was copied, but the original could not be deleted.",
|
||||||
|
) : MovePhotoResult
|
||||||
|
data class Failure(val message: String) : MovePhotoResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class Photo(
|
||||||
|
val uri: Uri,
|
||||||
|
val name: String,
|
||||||
|
val kind: GalleryFileKind = GalleryFileKind.Photo,
|
||||||
|
val mimeType: String = "image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class GalleryFileKind {
|
||||||
|
Photo,
|
||||||
|
Archive,
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
data class PhotoSaveOutcome(
|
||||||
|
val success: Boolean,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val savedPhoto: Photo? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
data class SubtreeStats(
|
||||||
|
val count: Int,
|
||||||
|
val previews: List<Uri>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.onecamera.domain.model
|
||||||
|
|
||||||
|
data class ZipFolderOutcome(
|
||||||
|
val success: Boolean,
|
||||||
|
val archive: Photo? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.onecamera.domain.repository
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.domain.model.GalleryContent
|
||||||
|
import com.example.onecamera.domain.model.MovePhotoResult
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import com.example.onecamera.domain.model.PhotoSaveOutcome
|
||||||
|
import com.example.onecamera.domain.model.SubtreeStats
|
||||||
|
import com.example.onecamera.domain.model.ZipFolderOutcome
|
||||||
|
|
||||||
|
interface PhotoRepository {
|
||||||
|
suspend fun listGallery(folderUri: Uri): GalleryContent
|
||||||
|
suspend fun listFolderTree(rootUri: Uri): List<FolderNode>
|
||||||
|
|
||||||
|
suspend fun subtreeStats(folderUri: Uri): SubtreeStats
|
||||||
|
|
||||||
|
fun prewarmTree(rootUri: Uri)
|
||||||
|
|
||||||
|
fun invalidateCaches()
|
||||||
|
|
||||||
|
suspend fun createFolder(parentUri: Uri, name: String): Uri?
|
||||||
|
suspend fun renameEntry(uri: Uri, newName: String): Boolean
|
||||||
|
suspend fun deleteEntry(uri: Uri): Boolean
|
||||||
|
suspend fun movePhoto(sourceUri: Uri, targetFolderUri: Uri, filename: String): MovePhotoResult
|
||||||
|
suspend fun moveFolder(sourceUri: Uri, targetFolderUri: Uri, folderName: String): MovePhotoResult
|
||||||
|
suspend fun saveCapturedPhoto(tempPhotoUri: Uri, targetFolderUri: Uri, filename: String): PhotoSaveOutcome
|
||||||
|
suspend fun zipFolderPhotos(sourceFolderUri: Uri, targetFolderUri: Uri, archiveName: String): ZipFolderOutcome
|
||||||
|
suspend fun zipFiles(targetFolderUri: Uri, archiveName: String, files: List<Photo>): ZipFolderOutcome
|
||||||
|
suspend fun zipFolderCompressedJpegs(
|
||||||
|
sourceFolderUri: Uri,
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
archiveName: String,
|
||||||
|
quality: Int,
|
||||||
|
): ZipFolderOutcome
|
||||||
|
suspend fun zipCompressedJpegs(
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
archiveName: String,
|
||||||
|
files: List<Photo>,
|
||||||
|
quality: Int,
|
||||||
|
): ZipFolderOutcome
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.data.storage.StorageNameValidator
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CreateFolderUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(parentUri: Uri, name: String): Uri? {
|
||||||
|
val safeName = StorageNameValidator.folderName(name) ?: return null
|
||||||
|
return repository.createFolder(parentUri, safeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class DeleteEntryUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(uri: Uri): Boolean = repository.deleteEntry(uri)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ListFolderTreeUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(rootUri: Uri) = repository.listFolderTree(rootUri)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ListGalleryUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(folderUri: Uri) = repository.listGallery(folderUri)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.model.MovePhotoResult
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MovePhotoUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(sourceUri: Uri, currentFolderUri: Uri, targetFolderUri: Uri, filename: String): MovePhotoResult {
|
||||||
|
if (currentFolderUri == targetFolderUri) {
|
||||||
|
return MovePhotoResult.Failure("Choose a different destination folder.")
|
||||||
|
}
|
||||||
|
return repository.movePhoto(sourceUri, targetFolderUri, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun folder(sourceUri: Uri, currentFolderUri: Uri, targetFolderUri: Uri, folderName: String): MovePhotoResult {
|
||||||
|
if (currentFolderUri == targetFolderUri || sourceUri == targetFolderUri) {
|
||||||
|
return MovePhotoResult.Failure("Choose a different destination folder.")
|
||||||
|
}
|
||||||
|
return repository.moveFolder(sourceUri, targetFolderUri, folderName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.data.storage.StorageNameValidator
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RenameEntryUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(uri: Uri, newName: String): Boolean {
|
||||||
|
val safeName = StorageNameValidator.validate(newName) ?: return false
|
||||||
|
return repository.renameEntry(uri, safeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun folder(uri: Uri, newName: String): Boolean {
|
||||||
|
val safeName = StorageNameValidator.folderName(newName) ?: return false
|
||||||
|
return repository.renameEntry(uri, safeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.model.PhotoSaveOutcome
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SaveCapturedPhotoUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(tempPhotoUri: Uri, targetFolderUri: Uri): PhotoSaveOutcome {
|
||||||
|
val filename = "IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.jpg"
|
||||||
|
return repository.saveCapturedPhoto(tempPhotoUri, targetFolderUri, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.example.onecamera.domain.usecase
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import com.example.onecamera.domain.model.ZipFolderOutcome
|
||||||
|
import com.example.onecamera.core.performance.OneCameraTrace
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ZipFolderUseCase @Inject constructor(
|
||||||
|
private val repository: PhotoRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(sourceFolderUri: Uri, targetFolderUri: Uri, folderName: String): ZipFolderOutcome {
|
||||||
|
val archiveName = folderName.safeZipFileName()
|
||||||
|
return OneCameraTrace.asyncSection(OneCameraTrace.ZIP_CREATE) {
|
||||||
|
repository.zipFolderPhotos(sourceFolderUri, targetFolderUri, archiveName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun selectedFiles(targetFolderUri: Uri, folderName: String, files: List<Photo>): ZipFolderOutcome {
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
return ZipFolderOutcome(success = false, errorMessage = "Select at least one image.")
|
||||||
|
}
|
||||||
|
val archiveName = folderName.safeZipFileName()
|
||||||
|
return OneCameraTrace.asyncSection(OneCameraTrace.ZIP_CREATE) {
|
||||||
|
repository.zipFiles(targetFolderUri, archiveName, files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun compressedFolderJpegs(
|
||||||
|
sourceFolderUri: Uri,
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
folderName: String,
|
||||||
|
quality: Int = DEFAULT_JPEG_QUALITY,
|
||||||
|
): ZipFolderOutcome {
|
||||||
|
val archiveName = folderName.safeZipFileName()
|
||||||
|
return OneCameraTrace.asyncSection(OneCameraTrace.ZIP_CREATE) {
|
||||||
|
repository.zipFolderCompressedJpegs(sourceFolderUri, targetFolderUri, archiveName, quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun compressedSelectedJpegs(
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
folderName: String,
|
||||||
|
files: List<Photo>,
|
||||||
|
quality: Int = DEFAULT_JPEG_QUALITY,
|
||||||
|
): ZipFolderOutcome {
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
return ZipFolderOutcome(success = false, errorMessage = "Select at least one JPG photo.")
|
||||||
|
}
|
||||||
|
val archiveName = folderName.safeZipFileName()
|
||||||
|
return OneCameraTrace.asyncSection(OneCameraTrace.ZIP_CREATE) {
|
||||||
|
repository.zipCompressedJpegs(targetFolderUri, archiveName, files, quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.safeFileBaseName(): String {
|
||||||
|
return trim()
|
||||||
|
.ifBlank { "photos" }
|
||||||
|
.replace(Regex("""[\\/:*?"<>|]"""), "_")
|
||||||
|
.removeSuffix(".zip")
|
||||||
|
.ifBlank { "photos" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.safeZipFileName(): String {
|
||||||
|
return "${safeFileBaseName()}.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_JPEG_QUALITY = 85
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.example.onecamera.navigation
|
||||||
|
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.toRoute
|
||||||
|
import com.example.onecamera.data.preferences.UserPreferences
|
||||||
|
import com.example.onecamera.data.preferences.UserPreferencesRepository
|
||||||
|
import com.example.onecamera.ui.camera.CameraScreen
|
||||||
|
import com.example.onecamera.ui.folders.FoldersScreen
|
||||||
|
import com.example.onecamera.ui.gallery.GalleryScreen
|
||||||
|
import com.example.onecamera.ui.settings.SettingsScreen
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object FoldersRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GalleryRoute(val folderUri: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CameraRoute(
|
||||||
|
val targetFolderUri: String,
|
||||||
|
val showTargetGalleryAfterSave: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object SettingsRoute
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NavGraphViewModel @Inject constructor(
|
||||||
|
preferencesRepository: UserPreferencesRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
val preferences: StateFlow<UserPreferences> = preferencesRepository.userPreferencesFlow
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserPreferences())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OneCameraNavGraph(navController: NavHostController) {
|
||||||
|
val viewModel: NavGraphViewModel = hiltViewModel()
|
||||||
|
val preferences by viewModel.preferences.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val transitionsEnabled = preferences.screenTransitionsEnabled
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
|
com.example.onecamera.ui.components.LocalOneCameraAnimationsEnabled provides transitionsEnabled,
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = FoldersRoute,
|
||||||
|
enterTransition = { if (transitionsEnabled) fadeIn(tween(250)) else EnterTransition.None },
|
||||||
|
exitTransition = { if (transitionsEnabled) fadeOut(tween(250)) else ExitTransition.None },
|
||||||
|
popEnterTransition = { if (transitionsEnabled) fadeIn(tween(250)) else EnterTransition.None },
|
||||||
|
popExitTransition = { if (transitionsEnabled) fadeOut(tween(250)) else ExitTransition.None },
|
||||||
|
) {
|
||||||
|
composable<FoldersRoute> {
|
||||||
|
FoldersScreen(
|
||||||
|
onNavigateToGallery = { folderUri ->
|
||||||
|
navController.navigate(GalleryRoute(folderUri))
|
||||||
|
},
|
||||||
|
onNavigateToCamera = { folderUri ->
|
||||||
|
navController.navigate(
|
||||||
|
CameraRoute(
|
||||||
|
targetFolderUri = folderUri,
|
||||||
|
showTargetGalleryAfterSave = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onNavigateToSettings = {
|
||||||
|
navController.navigate(SettingsRoute)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<GalleryRoute> { backStackEntry ->
|
||||||
|
val route = backStackEntry.toRoute<GalleryRoute>()
|
||||||
|
GalleryScreen(
|
||||||
|
folderUri = route.folderUri,
|
||||||
|
onNavigateToCamera = { folderUri ->
|
||||||
|
navController.navigate(CameraRoute(folderUri))
|
||||||
|
},
|
||||||
|
onNavigateToSubfolder = { folderUri ->
|
||||||
|
navController.navigate(GalleryRoute(folderUri))
|
||||||
|
},
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<CameraRoute> { backStackEntry ->
|
||||||
|
val route = backStackEntry.toRoute<CameraRoute>()
|
||||||
|
CameraScreen(
|
||||||
|
targetFolderUri = route.targetFolderUri,
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onPhotoSaved = {
|
||||||
|
navController.popBackStack()
|
||||||
|
if (route.showTargetGalleryAfterSave) {
|
||||||
|
navController.navigate(GalleryRoute(route.targetFolderUri))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<SettingsRoute> {
|
||||||
|
SettingsScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.example.onecamera.ui.camera
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.PermissionChecker
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.ui.components.OneCameraEmptyState
|
||||||
|
import com.example.onecamera.ui.components.OneCameraScreenPadding
|
||||||
|
import com.example.onecamera.ui.components.oneCameraTopAppBarColors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CameraScreen(
|
||||||
|
targetFolderUri: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onPhotoSaved: () -> Unit,
|
||||||
|
viewModel: CameraViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var hasPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PermissionChecker.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var permissionDeniedPermanently by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
hasPermission = granted
|
||||||
|
if (!granted) {
|
||||||
|
permissionDeniedPermanently = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!hasPermission) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPermission) {
|
||||||
|
SystemCameraCapture(
|
||||||
|
targetFolderUri = targetFolderUri,
|
||||||
|
onNavigateBack = onNavigateBack,
|
||||||
|
onPhotoSaved = onPhotoSaved,
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.camera_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = oneCameraTopAppBarColors(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
OneCameraEmptyState(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_photo_camera),
|
||||||
|
title = stringResource(R.string.camera_permission_title),
|
||||||
|
body = stringResource(
|
||||||
|
if (permissionDeniedPermanently) {
|
||||||
|
R.string.camera_permission_denied
|
||||||
|
} else {
|
||||||
|
R.string.camera_permission_body
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actionLabel = stringResource(
|
||||||
|
if (permissionDeniedPermanently) {
|
||||||
|
R.string.camera_open_settings
|
||||||
|
} else {
|
||||||
|
R.string.camera_grant_permission
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onAction = {
|
||||||
|
if (permissionDeniedPermanently) {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(OneCameraScreenPadding)
|
||||||
|
.widthIn(max = 560.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.example.onecamera.ui.camera
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.data.PhotoEventBus
|
||||||
|
import com.example.onecamera.domain.model.PhotoSaveOutcome
|
||||||
|
import com.example.onecamera.domain.usecase.SaveCapturedPhotoUseCase
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class PhotoSaveResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class CameraViewModel @Inject constructor(
|
||||||
|
private val saveCapturedPhotoUseCase: SaveCapturedPhotoUseCase,
|
||||||
|
private val photoEventBus: PhotoEventBus,
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _lastSaveResult = MutableStateFlow<PhotoSaveResult?>(null)
|
||||||
|
val lastSaveResult: StateFlow<PhotoSaveResult?> = _lastSaveResult.asStateFlow()
|
||||||
|
|
||||||
|
private val _isSaving = MutableStateFlow(false)
|
||||||
|
val isSaving: StateFlow<Boolean> = _isSaving.asStateFlow()
|
||||||
|
|
||||||
|
fun clearLastSaveResult() {
|
||||||
|
_lastSaveResult.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePhoto(tempPhotoUri: Uri, tempPhotoPath: String?, targetFolderUri: String) {
|
||||||
|
if (_isSaving.value) return
|
||||||
|
_isSaving.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val outcome = saveCapturedPhotoUseCase(tempPhotoUri, targetFolderUri.toUri())
|
||||||
|
_lastSaveResult.value = outcome.toPhotoSaveResult()
|
||||||
|
if (outcome.success) {
|
||||||
|
photoEventBus.onPhotoSaved(targetFolderUri.toUri(), outcome.savedPhoto)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "savePhoto threw", e)
|
||||||
|
_lastSaveResult.value = PhotoSaveResult(
|
||||||
|
success = false,
|
||||||
|
errorMessage = appContext.getString(R.string.camera_save_unexpected_error),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching { tempPhotoPath?.let(::File)?.delete() }
|
||||||
|
}
|
||||||
|
_isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordSaveFailure(errorMessage: String) {
|
||||||
|
_lastSaveResult.value = PhotoSaveResult(
|
||||||
|
success = false,
|
||||||
|
errorMessage = errorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "OneCameraSave"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PhotoSaveOutcome.toPhotoSaveResult(): PhotoSaveResult {
|
||||||
|
return PhotoSaveResult(
|
||||||
|
success = success,
|
||||||
|
errorMessage = errorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.example.onecamera.ui.camera
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.ui.components.OneCameraEmptyState
|
||||||
|
import com.example.onecamera.ui.components.OneCameraScreenPadding
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SystemCameraCapture(
|
||||||
|
targetFolderUri: String,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onPhotoSaved: () -> Unit,
|
||||||
|
viewModel: CameraViewModel,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val lastSaveResult by viewModel.lastSaveResult.collectAsStateWithLifecycle()
|
||||||
|
val isSaving by viewModel.isSaving.collectAsStateWithLifecycle()
|
||||||
|
val unexpectedSaveError = stringResource(R.string.camera_save_unexpected_error)
|
||||||
|
|
||||||
|
var tempPhotoUriString by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var tempPhotoPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var hasLaunchedCamera by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var hasReceivedCameraResult by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var launchUptimeMillis by rememberSaveable { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
fun cleanupTempPhoto() {
|
||||||
|
tempPhotoUriString = null
|
||||||
|
tempPhotoPath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteTempFile() {
|
||||||
|
tempPhotoPath?.let { path -> runCatching { File(path).delete() } }
|
||||||
|
cleanupTempPhoto()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelCapture() {
|
||||||
|
viewModel.clearLastSaveResult()
|
||||||
|
onNavigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCapturedPhoto(tempPhotoUri: Uri) {
|
||||||
|
viewModel.savePhoto(
|
||||||
|
tempPhotoUri = tempPhotoUri,
|
||||||
|
tempPhotoPath = tempPhotoPath,
|
||||||
|
targetFolderUri = targetFolderUri,
|
||||||
|
)
|
||||||
|
cleanupTempPhoto()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(lastSaveResult) {
|
||||||
|
if (lastSaveResult?.success == true) {
|
||||||
|
onPhotoSaved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val takePictureLauncher = rememberLauncherForActivityResult(
|
||||||
|
CameraOutputContract()
|
||||||
|
) { success ->
|
||||||
|
hasReceivedCameraResult = true
|
||||||
|
val tempPhotoUri = tempPhotoUriString?.let(Uri::parse)
|
||||||
|
val tempLength = tempPhotoPath?.let { path ->
|
||||||
|
runCatching { File(path).length() }.getOrNull()
|
||||||
|
} ?: 0L
|
||||||
|
if ((success || tempLength > 0L) && tempPhotoUri != null) {
|
||||||
|
saveCapturedPhoto(tempPhotoUri)
|
||||||
|
} else {
|
||||||
|
deleteTempFile()
|
||||||
|
cancelCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(hasLaunchedCamera) {
|
||||||
|
if (!hasLaunchedCamera) {
|
||||||
|
viewModel.clearLastSaveResult()
|
||||||
|
val tempDir = File(context.cacheDir, "camera_captures").apply { mkdirs() }
|
||||||
|
|
||||||
|
tempDir.listFiles()?.forEach { runCatching { it.delete() } }
|
||||||
|
val tempFile = File(tempDir, "capture_${System.currentTimeMillis()}.jpg").apply {
|
||||||
|
createNewFile()
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
tempFile,
|
||||||
|
)
|
||||||
|
tempPhotoUriString = uri.toString()
|
||||||
|
tempPhotoPath = tempFile.absolutePath
|
||||||
|
hasLaunchedCamera = true
|
||||||
|
hasReceivedCameraResult = false
|
||||||
|
launchUptimeMillis = SystemClock.elapsedRealtime()
|
||||||
|
takePictureLauncher.launch(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LifecycleResumeEffect(hasLaunchedCamera, hasReceivedCameraResult, tempPhotoUriString, tempPhotoPath, isSaving) {
|
||||||
|
if (hasLaunchedCamera && !hasReceivedCameraResult && tempPhotoUriString != null && !isSaving) {
|
||||||
|
scope.launch {
|
||||||
|
delay(600.milliseconds)
|
||||||
|
val elapsedSinceLaunch = SystemClock.elapsedRealtime() - launchUptimeMillis
|
||||||
|
if (
|
||||||
|
elapsedSinceLaunch >= 1_000L &&
|
||||||
|
!hasReceivedCameraResult &&
|
||||||
|
!isSaving &&
|
||||||
|
tempPhotoUriString != null
|
||||||
|
) {
|
||||||
|
val tempLength = tempPhotoPath?.let { path ->
|
||||||
|
runCatching { File(path).length() }.getOrNull()
|
||||||
|
} ?: 0L
|
||||||
|
val tempPhotoUri = tempPhotoUriString?.let(Uri::parse)
|
||||||
|
if (tempLength > 0L && tempPhotoUri != null) {
|
||||||
|
saveCapturedPhoto(tempPhotoUri)
|
||||||
|
} else {
|
||||||
|
deleteTempFile()
|
||||||
|
cancelCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
lastSaveResult?.let { result ->
|
||||||
|
if (!result.success) {
|
||||||
|
OneCameraEmptyState(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_cloud_off),
|
||||||
|
title = stringResource(R.string.camera_save_failed_title),
|
||||||
|
body = result.errorMessage
|
||||||
|
?: stringResource(R.string.camera_save_unknown_error),
|
||||||
|
actionLabel = stringResource(R.string.camera_back_to_gallery),
|
||||||
|
onAction = onNavigateBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(OneCameraScreenPadding)
|
||||||
|
.widthIn(max = 480.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CameraOutputContract : ActivityResultContract<Uri, Boolean>() {
|
||||||
|
override fun createIntent(context: Context, input: Uri): Intent {
|
||||||
|
return Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
||||||
|
putExtra(MediaStore.EXTRA_OUTPUT, input)
|
||||||
|
clipData = ClipData.newUri(
|
||||||
|
context.contentResolver,
|
||||||
|
context.getString(R.string.camera_captured_photo),
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||||
|
return resultCode == Activity.RESULT_OK
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.draggable2D
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggable2DState
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DraggableCameraFab(
|
||||||
|
xFraction: Float,
|
||||||
|
yFraction: Float,
|
||||||
|
onPositionChange: (xFraction: Float, yFraction: Float) -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val buttonSizePx = with(density) { CameraFabSize.toPx() }
|
||||||
|
val edgePaddingPx = with(density) { CameraFabEdgePadding.toPx() }
|
||||||
|
val widthPx = with(density) { maxWidth.toPx() }
|
||||||
|
val heightPx = with(density) { maxHeight.toPx() }
|
||||||
|
val maxX = (widthPx - buttonSizePx - edgePaddingPx * 2f).coerceAtLeast(0f)
|
||||||
|
val maxY = (heightPx - buttonSizePx - edgePaddingPx * 2f).coerceAtLeast(0f)
|
||||||
|
|
||||||
|
val restOffset = Offset(
|
||||||
|
x = edgePaddingPx + xFraction.coerceIn(0f, 1f) * maxX,
|
||||||
|
y = edgePaddingPx + yFraction.coerceIn(0f, 1f) * maxY,
|
||||||
|
)
|
||||||
|
|
||||||
|
var dragOffset by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
val dragState = rememberDraggable2DState { dragAmount ->
|
||||||
|
val base = dragOffset ?: restOffset
|
||||||
|
dragOffset = Offset(
|
||||||
|
x = (base.x + dragAmount.x).coerceIn(edgePaddingPx, edgePaddingPx + maxX),
|
||||||
|
y = (base.y + dragAmount.y).coerceIn(edgePaddingPx, edgePaddingPx + maxY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(maxX, maxY) {
|
||||||
|
dragOffset = null
|
||||||
|
}
|
||||||
|
|
||||||
|
CameraFabButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
val position = dragOffset ?: restOffset
|
||||||
|
IntOffset(
|
||||||
|
x = position.x.roundToInt(),
|
||||||
|
y = position.y.roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.draggable2D(
|
||||||
|
state = dragState,
|
||||||
|
onDragStopped = {
|
||||||
|
val position = dragOffset ?: restOffset
|
||||||
|
onPositionChange(
|
||||||
|
((position.x - edgePaddingPx) / maxX.coerceAtLeast(1f)).coerceIn(0f, 1f),
|
||||||
|
((position.y - edgePaddingPx) / maxY.coerceAtLeast(1f)).coerceIn(0f, 1f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CameraFabButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val draggableDescription = stringResource(R.string.camera_draggable_button)
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onClick,
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = modifier
|
||||||
|
.size(CameraFabSize)
|
||||||
|
.semantics { contentDescription = draggableDescription },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_photo_camera),
|
||||||
|
contentDescription = stringResource(R.string.camera_take_photo),
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CameraFabSize = 64.dp
|
||||||
|
private val CameraFabEdgePadding = 16.dp
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
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.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FolderCard(
|
||||||
|
folder: FolderNode,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onCompress: () -> Unit,
|
||||||
|
onCompressJpeg85: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onMove: (() -> Unit)? = null,
|
||||||
|
showActions: Boolean = true,
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentDescription = "Folder: ${folder.name}" }
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = {
|
||||||
|
if (showActions) {
|
||||||
|
showMenu = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
FolderPreview(
|
||||||
|
photos = folder.previewPhotoUris,
|
||||||
|
statsLoaded = folder.statsLoaded,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 12.dp, top = 10.dp, end = 4.dp, bottom = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = folder.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
|
||||||
|
text = if (folder.statsLoaded) {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.photo_count,
|
||||||
|
folder.photoCount,
|
||||||
|
folder.photoCount,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!showActions) {
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
if (showActions) {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.action_more_options),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_zip)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onCompress()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_jpeg_zip)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onCompressJpeg85()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_rename)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_delete)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (onMove != null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_move)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onMove()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderPreview(photos: List<Uri>, statsLoaded: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (photos.isEmpty()) {
|
||||||
|
|
||||||
|
if (statsLoaded) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_folder),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
MosaicTile(photos.getOrNull(0), Modifier.weight(1f).fillMaxHeight())
|
||||||
|
MosaicTile(photos.getOrNull(1), Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
MosaicTile(photos.getOrNull(2), Modifier.weight(1f).fillMaxHeight())
|
||||||
|
MosaicTile(photos.getOrNull(3), Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MosaicTile(uri: Uri?, modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
if (uri != null) {
|
||||||
|
ProgressiveThumbnail(
|
||||||
|
uri = uri,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.ui.gallery.MovePhotoSheetState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FolderTreePickerSheet(
|
||||||
|
state: MovePhotoSheetState,
|
||||||
|
currentFolderUri: String?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (FolderNode) -> Unit,
|
||||||
|
) {
|
||||||
|
var selectedFolderUri by rememberSaveable(state.title) { mutableStateOf<String?>(null) }
|
||||||
|
val selectedFolder = state.folders.firstOrNull { it.uri.toString() == selectedFolderUri }
|
||||||
|
val canMove = selectedFolder != null && !state.isMoving
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
state.isFolderMove -> stringResource(R.string.move_folder_title)
|
||||||
|
state.files.size > 1 -> stringResource(R.string.move_files_title)
|
||||||
|
else -> stringResource(R.string.move_file_title)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.error != null && state.folders.isEmpty() -> {
|
||||||
|
Text(
|
||||||
|
text = state.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (state.error != null) {
|
||||||
|
Text(
|
||||||
|
text = state.error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, fill = false),
|
||||||
|
) {
|
||||||
|
items(state.folders, key = { it.uri }) { folder ->
|
||||||
|
val isCurrent = currentFolderUri == folder.uri.toString()
|
||||||
|
val isSelected = selectedFolderUri == folder.uri.toString()
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.clickable(
|
||||||
|
enabled = !isCurrent && !state.isMoving,
|
||||||
|
onClick = { selectedFolderUri = folder.uri.toString() },
|
||||||
|
),
|
||||||
|
leadingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.width((folder.depth * 20).dp))
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_folder),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = if (isSelected) {
|
||||||
|
painterResource(R.drawable.ic_symbol_radio_button_checked)
|
||||||
|
} else {
|
||||||
|
painterResource(R.drawable.ic_symbol_radio_button_unchecked)
|
||||||
|
},
|
||||||
|
contentDescription = if (isSelected) {
|
||||||
|
stringResource(R.string.action_selected)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.move_select_folder)
|
||||||
|
},
|
||||||
|
tint = when {
|
||||||
|
isCurrent -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||||
|
isSelected -> MaterialTheme.colorScheme.primary
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = if (isCurrent) {
|
||||||
|
stringResource(R.string.move_current_folder)
|
||||||
|
} else {
|
||||||
|
folder.path
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = folder.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
enabled = !state.isMoving,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
enabled = canMove,
|
||||||
|
onClick = {
|
||||||
|
selectedFolder?.let(onSelect)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (state.isMoving) {
|
||||||
|
stringResource(R.string.move_in_progress)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.move_here)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.domain.model.GalleryFileKind
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private const val AUTOSCROLL_EDGE_FRACTION = 0.12f
|
||||||
|
private const val AUTOSCROLL_MAX_PX = 24f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GalleryGrid(
|
||||||
|
folders: List<FolderNode>,
|
||||||
|
photos: List<Photo>,
|
||||||
|
state: LazyGridState,
|
||||||
|
onFolderClick: (FolderNode) -> Unit,
|
||||||
|
onFolderCompress: (FolderNode) -> Unit,
|
||||||
|
onFolderCompressJpeg85: (FolderNode) -> Unit,
|
||||||
|
onFolderMove: (FolderNode) -> Unit,
|
||||||
|
onFolderRename: (FolderNode) -> Unit,
|
||||||
|
onFolderDelete: (FolderNode) -> Unit,
|
||||||
|
onPhotoClick: (Photo) -> Unit,
|
||||||
|
onPhotoLongClick: (Photo) -> Unit,
|
||||||
|
onPhotoMove: (Photo) -> Unit,
|
||||||
|
onPhotoRename: (Photo) -> Unit,
|
||||||
|
onPhotoCompressFolder: (Photo) -> Unit,
|
||||||
|
onPhotoCompressJpeg85: (Photo) -> Unit,
|
||||||
|
onPhotoShare: (Photo) -> Unit,
|
||||||
|
onPhotoDelete: (Photo) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showItemActions: Boolean = true,
|
||||||
|
isGridMode: Boolean = true,
|
||||||
|
selectedPhotoUris: Set<String> = emptySet(),
|
||||||
|
onDragSelectStart: (String) -> Unit = {},
|
||||||
|
onDragSelectUpdate: (Set<String>) -> Unit = {},
|
||||||
|
onDragSelectEnd: () -> Unit = {},
|
||||||
|
minCellSize: androidx.compose.ui.unit.Dp = 136.dp,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(12.dp),
|
||||||
|
) {
|
||||||
|
|
||||||
|
val photoIndexByUri = remember(photos) {
|
||||||
|
photos.withIndex().associate { (index, photo) -> photo.uri.toString() to index }
|
||||||
|
}
|
||||||
|
val updateState = rememberUpdatedState(onDragSelectUpdate)
|
||||||
|
val startState = rememberUpdatedState(onDragSelectStart)
|
||||||
|
val endState = rememberUpdatedState(onDragSelectEnd)
|
||||||
|
|
||||||
|
val currentSelection by rememberUpdatedState(selectedPhotoUris)
|
||||||
|
|
||||||
|
var dragPointerY by remember { mutableStateOf<Float?>(null) }
|
||||||
|
var viewportHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
var suppressedClickUri by remember { mutableStateOf<String?>(null) }
|
||||||
|
var suppressClickUntilMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { dragPointerY }.collectLatest { y ->
|
||||||
|
if (y == null || viewportHeightPx == 0) return@collectLatest
|
||||||
|
val edge = viewportHeightPx * AUTOSCROLL_EDGE_FRACTION
|
||||||
|
while (true) {
|
||||||
|
val current = dragPointerY ?: break
|
||||||
|
val delta = when {
|
||||||
|
current < edge -> -(edge - current) / edge * AUTOSCROLL_MAX_PX
|
||||||
|
current > viewportHeightPx - edge -> (current - (viewportHeightPx - edge)) / edge * AUTOSCROLL_MAX_PX
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (delta != 0f) state.scrollBy(delta)
|
||||||
|
delay(16.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dragSelectModifier = if (!isGridMode) Modifier else Modifier.pointerInput(photos) {
|
||||||
|
var anchorIndex: Int? = null
|
||||||
|
|
||||||
|
fun photoIndexAt(position: Offset): Int? {
|
||||||
|
val item = state.layoutInfo.visibleItemsInfo.firstOrNull { info ->
|
||||||
|
val itemSize = info.size
|
||||||
|
position.x >= info.offset.x &&
|
||||||
|
position.x <= info.offset.x + itemSize.width &&
|
||||||
|
position.y >= info.offset.y &&
|
||||||
|
position.y <= info.offset.y + itemSize.height
|
||||||
|
} ?: return null
|
||||||
|
val uri = (item.key as? android.net.Uri)?.toString() ?: return null
|
||||||
|
return photoIndexByUri[uri]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rangeUris(from: Int, to: Int): Set<String> {
|
||||||
|
val lo = minOf(from, to)
|
||||||
|
val hi = maxOf(from, to)
|
||||||
|
return (lo..hi).mapNotNull { photos.getOrNull(it) }
|
||||||
|
.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
.map { it.uri.toString() }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
val index = photoIndexAt(offset)
|
||||||
|
if (index != null && photos.getOrNull(index)?.kind == GalleryFileKind.Photo) {
|
||||||
|
val uri = photos[index].uri.toString()
|
||||||
|
anchorIndex = index
|
||||||
|
dragPointerY = offset.y
|
||||||
|
suppressedClickUri = uri
|
||||||
|
suppressClickUntilMs = SystemClock.uptimeMillis() + 1200L
|
||||||
|
startState.value(uri)
|
||||||
|
} else {
|
||||||
|
anchorIndex = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrag = { change, _ ->
|
||||||
|
change.consume()
|
||||||
|
if (anchorIndex == null) return@detectDragGesturesAfterLongPress
|
||||||
|
val anchor = anchorIndex!!
|
||||||
|
dragPointerY = change.position.y
|
||||||
|
val current = photoIndexAt(change.position)
|
||||||
|
if (current != null) updateState.value(rangeUris(anchor, current))
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
if (anchorIndex != null) endState.value()
|
||||||
|
anchorIndex = null
|
||||||
|
dragPointerY = null
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
if (anchorIndex != null) endState.value()
|
||||||
|
anchorIndex = null
|
||||||
|
dragPointerY = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
state = state,
|
||||||
|
columns = if (isGridMode) GridCells.Adaptive(minSize = minCellSize) else GridCells.Fixed(1),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onSizeChanged { viewportHeightPx = it.height }
|
||||||
|
.then(dragSelectModifier)
|
||||||
|
.testTag("gallery_grid"),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = folders,
|
||||||
|
key = { "folder_${it.uri}" },
|
||||||
|
contentType = { "folder" },
|
||||||
|
) { folder ->
|
||||||
|
if (isGridMode) {
|
||||||
|
FolderCard(
|
||||||
|
folder = folder,
|
||||||
|
onClick = { onFolderClick(folder) },
|
||||||
|
onCompress = { onFolderCompress(folder) },
|
||||||
|
onCompressJpeg85 = { onFolderCompressJpeg85(folder) },
|
||||||
|
onMove = { onFolderMove(folder) },
|
||||||
|
onRename = { onFolderRename(folder) },
|
||||||
|
onDelete = { onFolderDelete(folder) },
|
||||||
|
showActions = showItemActions,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FolderListItem(
|
||||||
|
folder = folder,
|
||||||
|
onClick = { onFolderClick(folder) },
|
||||||
|
onCompress = { onFolderCompress(folder) },
|
||||||
|
onCompressJpeg85 = { onFolderCompressJpeg85(folder) },
|
||||||
|
onMove = { onFolderMove(folder) },
|
||||||
|
onRename = { onFolderRename(folder) },
|
||||||
|
onDelete = { onFolderDelete(folder) },
|
||||||
|
showActions = showItemActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = photos,
|
||||||
|
key = { it.uri },
|
||||||
|
contentType = { "photo" },
|
||||||
|
) { photo ->
|
||||||
|
if (isGridMode) {
|
||||||
|
val photoUri = photo.uri.toString()
|
||||||
|
PhotoCard(
|
||||||
|
photo = photo,
|
||||||
|
onClick = {
|
||||||
|
if (suppressedClickUri == photoUri && SystemClock.uptimeMillis() <= suppressClickUntilMs) {
|
||||||
|
suppressedClickUri = null
|
||||||
|
suppressClickUntilMs = 0L
|
||||||
|
} else {
|
||||||
|
onPhotoClick(photo)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMove = { onPhotoMove(photo) },
|
||||||
|
onRename = { onPhotoRename(photo) },
|
||||||
|
onCompressFolder = { onPhotoCompressFolder(photo) },
|
||||||
|
onCompressJpeg85 = { onPhotoCompressJpeg85(photo) },
|
||||||
|
onShare = { onPhotoShare(photo) },
|
||||||
|
onDelete = { onPhotoDelete(photo) },
|
||||||
|
isSelected = photoUri in currentSelection,
|
||||||
|
showActions = showItemActions,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PhotoListItem(
|
||||||
|
photo = photo,
|
||||||
|
isSelected = photo.uri.toString() in currentSelection,
|
||||||
|
onClick = { onPhotoClick(photo) },
|
||||||
|
onLongClick = { onPhotoLongClick(photo) },
|
||||||
|
onMove = { onPhotoMove(photo) },
|
||||||
|
onRename = { onPhotoRename(photo) },
|
||||||
|
onCompressFolder = { onPhotoCompressFolder(photo) },
|
||||||
|
onCompressJpeg85 = { onPhotoCompressJpeg85(photo) },
|
||||||
|
onShare = { onPhotoShare(photo) },
|
||||||
|
onDelete = { onPhotoDelete(photo) },
|
||||||
|
showActions = showItemActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderListItem(
|
||||||
|
folder: FolderNode,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onCompress: () -> Unit,
|
||||||
|
onCompressJpeg85: () -> Unit,
|
||||||
|
onMove: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
showActions: Boolean,
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
leadingContent = {
|
||||||
|
GalleryListThumbnail {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_folder),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
|
||||||
|
if (folder.statsLoaded) {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.photo_count,
|
||||||
|
folder.photoCount,
|
||||||
|
folder.photoCount,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
|
||||||
|
if (!showActions) {
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
if (showActions) {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.action_more_options),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_zip)) }, onClick = { showMenu = false; onCompress() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_jpeg_zip)) }, onClick = { showMenu = false; onCompressJpeg85() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_move)) }, onClick = { showMenu = false; onMove() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_rename)) }, onClick = { showMenu = false; onRename() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_delete)) }, onClick = { showMenu = false; onDelete() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent),
|
||||||
|
) {
|
||||||
|
Text(folder.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun PhotoListItem(
|
||||||
|
photo: Photo,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onMove: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onCompressFolder: () -> Unit,
|
||||||
|
onCompressJpeg85: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
showActions: Boolean,
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
leadingContent = {
|
||||||
|
PhotoListThumbnail(photo = photo, isSelected = isSelected)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
if (photo.kind == GalleryFileKind.Archive) {
|
||||||
|
stringResource(R.string.gallery_zip_archive)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.gallery_photo)
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
|
||||||
|
if (!showActions) {
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
if (showActions) {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.action_more_options),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||||
|
if (photo.kind == GalleryFileKind.Photo) {
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_zip)) }, onClick = { showMenu = false; onCompressFolder() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_jpeg_zip)) }, onClick = { showMenu = false; onCompressJpeg85() })
|
||||||
|
} else {
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_share)) }, onClick = { showMenu = false; onShare() })
|
||||||
|
}
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_rename)) }, onClick = { showMenu = false; onRename() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_move)) }, onClick = { showMenu = false; onMove() })
|
||||||
|
DropdownMenuItem(text = { Text(stringResource(R.string.action_delete)) }, onClick = { showMenu = false; onDelete() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent),
|
||||||
|
) {
|
||||||
|
Text(photo.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GalleryListThumbnail(content: @Composable BoxScope.() -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhotoListThumbnail(photo: Photo, isSelected: Boolean) {
|
||||||
|
GalleryListThumbnail {
|
||||||
|
if (photo.kind == GalleryFileKind.Archive) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_draft),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProgressiveThumbnail(
|
||||||
|
uri = photo.uri,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isSelected) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_check_circle),
|
||||||
|
contentDescription = stringResource(R.string.action_selected),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge)
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val LocalOneCameraAnimationsEnabled = androidx.compose.runtime.compositionLocalOf { true }
|
||||||
|
|
||||||
|
val OneCameraContentMaxWidth = 840.dp
|
||||||
|
val OneCameraGalleryMaxWidth = 1200.dp
|
||||||
|
val OneCameraScreenPadding = 16.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun oneCameraTopAppBarColors() = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OneCameraEmptyState(
|
||||||
|
icon: Painter,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp, vertical = 36.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OneCameraIconBadge(icon = icon, size = 64.dp, iconSize = 32.dp)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onAction,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OneCameraIconBadge(
|
||||||
|
icon: Painter,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: androidx.compose.ui.unit.Dp = 40.dp,
|
||||||
|
iconSize: androidx.compose.ui.unit.Dp = 22.dp,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.size(size),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding((size - iconSize) / 2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.domain.model.GalleryFileKind
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PhotoCard(
|
||||||
|
photo: Photo,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onMove: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onCompressFolder: () -> Unit,
|
||||||
|
onCompressJpeg85: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isSelected: Boolean = false,
|
||||||
|
showActions: Boolean = true,
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { contentDescription = "Photo: ${photo.name}" }
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
) {
|
||||||
|
ThumbnailBox(photo = photo)
|
||||||
|
if (isSelected) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_check_circle),
|
||||||
|
contentDescription = stringResource(R.string.action_selected),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(28.dp)
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.alpha(0.96f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 12.dp, top = 10.dp, end = 4.dp, bottom = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = photo.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (photo.kind == GalleryFileKind.Archive) {
|
||||||
|
stringResource(R.string.gallery_zip_archive)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.gallery_photo)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!showActions) {
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
if (showActions) {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.action_more_options),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
|
) {
|
||||||
|
if (photo.kind == GalleryFileKind.Photo) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_zip)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onCompressFolder()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_jpeg_zip)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onCompressJpeg85()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_share)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onShare()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_rename)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onRename()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_move)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onMove()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.action_delete)) },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThumbnailBox(photo: Photo) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (photo.kind == GalleryFileKind.Archive) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_folder),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "ZIP",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var failed by remember(photo.uri) { mutableStateOf(false) }
|
||||||
|
if (failed) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_broken_image),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProgressiveThumbnail(
|
||||||
|
uri = photo.uri,
|
||||||
|
contentDescription = photo.name,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onError = { failed = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FloatingToolbarDefaults
|
||||||
|
import androidx.compose.material3.HorizontalFloatingToolbar
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.onecamera.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.SelectionActionsBar(
|
||||||
|
visible: Boolean,
|
||||||
|
onZip: () -> Unit,
|
||||||
|
onMove: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
enter = slideInVertically { it * 2 } + fadeIn(),
|
||||||
|
exit = slideOutVertically { it * 2 } + fadeOut(),
|
||||||
|
) {
|
||||||
|
HorizontalFloatingToolbar(
|
||||||
|
expanded = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
|
||||||
|
) {
|
||||||
|
SelectionBarAction(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_delete),
|
||||||
|
label = stringResource(R.string.action_delete),
|
||||||
|
onClick = onDelete,
|
||||||
|
)
|
||||||
|
SelectionBarAction(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_drive_file_move),
|
||||||
|
label = stringResource(R.string.action_move),
|
||||||
|
onClick = onMove,
|
||||||
|
)
|
||||||
|
SelectionBarAction(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_folder_zip),
|
||||||
|
label = stringResource(R.string.action_zip),
|
||||||
|
onClick = onZip,
|
||||||
|
)
|
||||||
|
SelectionBarAction(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_share),
|
||||||
|
label = stringResource(R.string.action_share),
|
||||||
|
onClick = onShare,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectionBarAction(
|
||||||
|
icon: Painter,
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val contentColor = LocalContentColor.current
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.defaultMinSize(minHeight = 64.dp, minWidth = 64.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.example.onecamera.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.draw.alpha
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.size.Precision
|
||||||
|
import coil3.size.Size
|
||||||
|
|
||||||
|
const val THUMBNAIL_SIZE_PX = 480
|
||||||
|
|
||||||
|
const val THUMBNAIL_CROSSFADE_MS = 150
|
||||||
|
|
||||||
|
const val THUMBNAIL_DRAFT_SIZE_PX = 320
|
||||||
|
|
||||||
|
fun thumbnailRequest(context: Context, uri: Uri, crossfadeMillis: Int = THUMBNAIL_CROSSFADE_MS): ImageRequest =
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(uri)
|
||||||
|
.size(Size(THUMBNAIL_SIZE_PX, THUMBNAIL_SIZE_PX))
|
||||||
|
.precision(Precision.INEXACT)
|
||||||
|
.memoryCacheKey("thumb:$uri")
|
||||||
|
.crossfade(crossfadeMillis)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun thumbnailDraftRequest(context: Context, uri: Uri, crossfadeMillis: Int = THUMBNAIL_CROSSFADE_MS): ImageRequest =
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(uri)
|
||||||
|
.size(Size(THUMBNAIL_DRAFT_SIZE_PX, THUMBNAIL_DRAFT_SIZE_PX))
|
||||||
|
.precision(Precision.INEXACT)
|
||||||
|
.memoryCacheKey("thumb_draft:$uri")
|
||||||
|
.crossfade(crossfadeMillis)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
const val ENABLE_SHARP_THUMBNAIL = false
|
||||||
|
|
||||||
|
const val NAVIGATION_PREFETCH_THUMBNAIL_COUNT = 12
|
||||||
|
|
||||||
|
fun evictThumbnails(loader: coil3.ImageLoader, removedUris: Collection<Uri>) {
|
||||||
|
if (removedUris.isEmpty()) return
|
||||||
|
val cache = loader.memoryCache ?: return
|
||||||
|
removedUris.forEach { uri ->
|
||||||
|
cache.remove(coil3.memory.MemoryCache.Key("thumb_draft:$uri"))
|
||||||
|
cache.remove(coil3.memory.MemoryCache.Key("thumb:$uri"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun thumbnailWarmupRequests(context: Context, uri: Uri): List<ImageRequest> =
|
||||||
|
if (ENABLE_SHARP_THUMBNAIL) {
|
||||||
|
listOf(thumbnailDraftRequest(context, uri), thumbnailRequest(context, uri))
|
||||||
|
} else {
|
||||||
|
listOf(thumbnailDraftRequest(context, uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProgressiveThumbnail(
|
||||||
|
uri: Uri,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onError: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val animationsEnabled = LocalOneCameraAnimationsEnabled.current
|
||||||
|
val fadeMs = if (animationsEnabled) THUMBNAIL_CROSSFADE_MS else 0
|
||||||
|
var fullLoaded by remember(uri) { mutableStateOf(false) }
|
||||||
|
val fullAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (fullLoaded) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = fadeMs),
|
||||||
|
label = "thumbnailFade",
|
||||||
|
)
|
||||||
|
Box(modifier) {
|
||||||
|
if (fullAlpha < 1f) {
|
||||||
|
AsyncImage(
|
||||||
|
model = remember(uri, context, fadeMs) { thumbnailDraftRequest(context, uri, fadeMs) },
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ENABLE_SHARP_THUMBNAIL) {
|
||||||
|
AsyncImage(
|
||||||
|
model = remember(uri, context, fadeMs) { thumbnailRequest(context, uri, fadeMs) },
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alpha(fullAlpha),
|
||||||
|
onSuccess = { fullLoaded = true },
|
||||||
|
onError = { onError() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package com.example.onecamera.ui.folders
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CreateFolderDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.dialog_create_folder_title)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.dialog_folder_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onCreate(name) },
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_create))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RenameFolderDialog(
|
||||||
|
folder: FolderNode,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(folder.name) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.dialog_rename_folder_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.dialog_current_name, folder.name))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.dialog_new_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onRename(name) },
|
||||||
|
enabled = name.isNotBlank() && name != folder.name,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_rename))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RenameFileDialog(
|
||||||
|
file: Photo,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(file.name) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.dialog_rename_file_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.dialog_current_name, file.name))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.dialog_new_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onRename(name) },
|
||||||
|
enabled = name.isNotBlank() && name != file.name,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_rename))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteFolderDialog(
|
||||||
|
folder: FolderNode,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.dialog_delete_folder_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.dialog_delete_folder_message,
|
||||||
|
folder.photoCount,
|
||||||
|
folder.name,
|
||||||
|
folder.photoCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDelete) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.action_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package com.example.onecamera.ui.folders
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.ui.components.DraggableCameraFab
|
||||||
|
import com.example.onecamera.ui.components.FolderCard
|
||||||
|
import com.example.onecamera.ui.components.OneCameraContentMaxWidth
|
||||||
|
import com.example.onecamera.ui.components.OneCameraEmptyState
|
||||||
|
import com.example.onecamera.ui.components.OneCameraScreenPadding
|
||||||
|
import com.example.onecamera.ui.components.oneCameraTopAppBarColors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FoldersScreen(
|
||||||
|
onNavigateToGallery: (String) -> Unit,
|
||||||
|
onNavigateToCamera: (String) -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
viewModel: FoldersViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val gridState = rememberLazyGridState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val shareChooserTitle = stringResource(
|
||||||
|
R.string.folders_share_chooser,
|
||||||
|
uiState.shareFileRequest?.name.orEmpty(),
|
||||||
|
)
|
||||||
|
val shareNoAppMessage = stringResource(R.string.folders_share_no_app)
|
||||||
|
val shareFailedMessage = stringResource(R.string.folders_share_failed)
|
||||||
|
val permissionRevokedAction = stringResource(R.string.folders_permission_revoked_action)
|
||||||
|
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
viewModel.onResume()
|
||||||
|
onPauseOrDispose {}
|
||||||
|
}
|
||||||
|
|
||||||
|
val folderPicker = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
it,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
viewModel.setRootFolderUri(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
val message = uiState.error ?: return@LaunchedEffect
|
||||||
|
val needsReselect = uiState.needsRootReselection
|
||||||
|
val actionLabel = if (needsReselect) permissionRevokedAction else null
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = actionLabel,
|
||||||
|
duration = SnackbarDuration.Long,
|
||||||
|
)
|
||||||
|
viewModel.clearError()
|
||||||
|
if (needsReselect) {
|
||||||
|
viewModel.dismissRootReselection()
|
||||||
|
if (result == SnackbarResult.ActionPerformed) {
|
||||||
|
|
||||||
|
viewModel.clearRootAndReselect()
|
||||||
|
folderPicker.launch(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.shareFileRequest) {
|
||||||
|
uiState.shareFileRequest?.let { request ->
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = request.mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, request.uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, request.name)
|
||||||
|
clipData = ClipData.newUri(context.contentResolver, request.name, request.uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(
|
||||||
|
shareIntent,
|
||||||
|
shareChooserTitle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
viewModel.clearShareRequest()
|
||||||
|
}.onFailure { error ->
|
||||||
|
val message = if (error is ActivityNotFoundException) {
|
||||||
|
shareNoAppMessage
|
||||||
|
} else {
|
||||||
|
error.message ?: shareFailedMessage
|
||||||
|
}
|
||||||
|
viewModel.reportShareFailure(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.semantics { testTagsAsResourceId = true },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.app_name)) },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = viewModel::showCreateDialog) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_create_new_folder),
|
||||||
|
contentDescription = stringResource(R.string.folders_create),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_settings),
|
||||||
|
contentDescription = stringResource(R.string.settings_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = oneCameraTopAppBarColors(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = uiState.isLoading,
|
||||||
|
onRefresh = viewModel::refresh,
|
||||||
|
|
||||||
|
indicator = {},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
!uiState.isRootFolderResolved -> Unit
|
||||||
|
uiState.rootFolderUri == null -> {
|
||||||
|
OneCameraEmptyState(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_folder),
|
||||||
|
title = stringResource(R.string.folders_root_title),
|
||||||
|
body = stringResource(R.string.folders_root_body),
|
||||||
|
actionLabel = stringResource(R.string.folders_choose_root),
|
||||||
|
onAction = { folderPicker.launch(null) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(OneCameraScreenPadding)
|
||||||
|
.widthIn(max = 560.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiState.isLoading && uiState.folders.isEmpty() -> Unit
|
||||||
|
uiState.folders.isEmpty() -> {
|
||||||
|
OneCameraEmptyState(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_create_new_folder),
|
||||||
|
title = stringResource(R.string.folders_empty_title),
|
||||||
|
body = stringResource(R.string.folders_empty_body),
|
||||||
|
actionLabel = stringResource(R.string.folders_empty_action),
|
||||||
|
onAction = viewModel::showCreateDialog,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(OneCameraScreenPadding)
|
||||||
|
.widthIn(max = 560.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
state = gridState,
|
||||||
|
columns = GridCells.Adaptive(minSize = 168.dp),
|
||||||
|
contentPadding = PaddingValues(OneCameraScreenPadding),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.widthIn(max = OneCameraContentMaxWidth)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.testTag("folders_grid"),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = uiState.folders,
|
||||||
|
key = { it.uri },
|
||||||
|
contentType = { "folder" },
|
||||||
|
) { folder ->
|
||||||
|
FolderCard(
|
||||||
|
folder = folder,
|
||||||
|
|
||||||
|
onClick = {
|
||||||
|
viewModel.prefetchFolderForNavigation(folder.uri.toString())
|
||||||
|
onNavigateToGallery(folder.uri.toString())
|
||||||
|
},
|
||||||
|
onCompress = { viewModel.zipFolderAndShare(folder) },
|
||||||
|
onCompressJpeg85 = {
|
||||||
|
viewModel.zipFolderCompressedJpegsAndShare(folder)
|
||||||
|
},
|
||||||
|
onRename = { viewModel.showRenameDialog(folder) },
|
||||||
|
onDelete = { viewModel.showDeleteDialog(folder) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.rootFolderUri?.let { rootFolderUri ->
|
||||||
|
DraggableCameraFab(
|
||||||
|
xFraction = uiState.cameraFabXFraction,
|
||||||
|
yFraction = uiState.cameraFabYFraction,
|
||||||
|
onPositionChange = viewModel::saveCameraFabPosition,
|
||||||
|
onClick = { onNavigateToCamera(rootFolderUri) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.showCreateDialog) {
|
||||||
|
CreateFolderDialog(
|
||||||
|
onDismiss = viewModel::dismissCreateDialog,
|
||||||
|
onCreate = viewModel::createFolder,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiState.showRenameDialog?.let { folder ->
|
||||||
|
RenameFolderDialog(
|
||||||
|
folder = folder,
|
||||||
|
onDismiss = viewModel::dismissRenameDialog,
|
||||||
|
onRename = { viewModel.renameFolder(folder, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiState.showDeleteDialog?.let { folder ->
|
||||||
|
DeleteFolderDialog(
|
||||||
|
folder = folder,
|
||||||
|
onDismiss = viewModel::dismissDeleteDialog,
|
||||||
|
onDelete = { viewModel.deleteFolder(folder) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package com.example.onecamera.ui.folders
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.core.performance.OneCameraTrace
|
||||||
|
import com.example.onecamera.data.PhotoEventBus
|
||||||
|
import com.example.onecamera.data.preferences.UserPreferencesRepository
|
||||||
|
import com.example.onecamera.data.storage.ExternalMediaObserver
|
||||||
|
import com.example.onecamera.data.storage.saf.MissingUriPermissionException
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.domain.model.GalleryFileKind
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import com.example.onecamera.domain.model.mergeLoadedSubfolderStats
|
||||||
|
import com.example.onecamera.domain.repository.PhotoRepository
|
||||||
|
import com.example.onecamera.domain.usecase.CreateFolderUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.DeleteEntryUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.ListGalleryUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.RenameEntryUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.ZipFolderUseCase
|
||||||
|
import com.example.onecamera.ui.components.evictThumbnails
|
||||||
|
import com.example.onecamera.ui.components.thumbnailWarmupRequests
|
||||||
|
import com.example.onecamera.ui.components.NAVIGATION_PREFETCH_THUMBNAIL_COUNT
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class FoldersUiState(
|
||||||
|
val folders: List<FolderNode> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val rootFolderUri: String? = null,
|
||||||
|
val isRootFolderResolved: Boolean = false,
|
||||||
|
val showCreateDialog: Boolean = false,
|
||||||
|
val showRenameDialog: FolderNode? = null,
|
||||||
|
val showDeleteDialog: FolderNode? = null,
|
||||||
|
val cameraFabXFraction: Float = 1f,
|
||||||
|
val cameraFabYFraction: Float = 1f,
|
||||||
|
val shareFileRequest: com.example.onecamera.ui.gallery.ShareFileRequest? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
|
||||||
|
val needsRootReselection: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FoldersViewModel @Inject constructor(
|
||||||
|
private val preferencesRepository: UserPreferencesRepository,
|
||||||
|
private val photoRepository: PhotoRepository,
|
||||||
|
private val listGalleryUseCase: ListGalleryUseCase,
|
||||||
|
private val createFolderUseCase: CreateFolderUseCase,
|
||||||
|
private val renameEntryUseCase: RenameEntryUseCase,
|
||||||
|
private val deleteEntryUseCase: DeleteEntryUseCase,
|
||||||
|
private val zipFolderUseCase: ZipFolderUseCase,
|
||||||
|
private val photoEventBus: PhotoEventBus,
|
||||||
|
private val externalMediaObserver: ExternalMediaObserver,
|
||||||
|
@ApplicationContext private val appContext: android.content.Context,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<FoldersUiState>
|
||||||
|
field: MutableStateFlow<FoldersUiState> = MutableStateFlow(FoldersUiState())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.userPreferencesFlow.collect { prefs ->
|
||||||
|
val oldUri = uiState.value.rootFolderUri
|
||||||
|
val newUri = prefs.rootFolderUri
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
rootFolderUri = newUri,
|
||||||
|
isRootFolderResolved = true,
|
||||||
|
cameraFabXFraction = prefs.cameraFabXFraction,
|
||||||
|
cameraFabYFraction = prefs.cameraFabYFraction,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (oldUri != newUri) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
folders = emptyList(),
|
||||||
|
error = null,
|
||||||
|
needsRootReselection = false,
|
||||||
|
)
|
||||||
|
photoRepository.invalidateCaches()
|
||||||
|
newUri?.let { loadFolders(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
photoEventBus.photoSaved.collect { event ->
|
||||||
|
if (event.folderUri.toString() == uiState.value.rootFolderUri) return@collect
|
||||||
|
applyIncrementalUpdate(event.folderUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
externalMediaObserver.changes.collect {
|
||||||
|
photoRepository.invalidateCaches()
|
||||||
|
loadFolders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRootFolderUri(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
|
||||||
|
preferencesRepository.setRootFolderUri(uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume() {
|
||||||
|
loadFolders()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFolders(rootUriString: String? = uiState.value.rootFolderUri) {
|
||||||
|
rootUriString ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(isLoading = true)
|
||||||
|
val uri = rootUriString.toUri()
|
||||||
|
runCatching {
|
||||||
|
OneCameraTrace.asyncSection(OneCameraTrace.FOLDERS_LOAD) {
|
||||||
|
listGalleryUseCase(uri).subfolders
|
||||||
|
}
|
||||||
|
}.onSuccess { folders ->
|
||||||
|
|
||||||
|
val merged = mergeLoadedSubfolderStats(folders, uiState.value.folders)
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
folders = merged,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
needsRootReselection = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
loadFolderStats(merged)
|
||||||
|
|
||||||
|
photoRepository.prewarmTree(uri)
|
||||||
|
}.onFailure { error ->
|
||||||
|
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
if (error is MissingUriPermissionException) {
|
||||||
|
|
||||||
|
val message = appContext.getString(R.string.folders_permission_revoked)
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = message,
|
||||||
|
needsRootReselection = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = error.message ?: appContext.getString(R.string.folders_load_failed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearRootAndReselect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.clearRootFolderUri()
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
folders = emptyList(),
|
||||||
|
error = null,
|
||||||
|
needsRootReselection = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRootReselection() {
|
||||||
|
uiState.value = uiState.value.copy(needsRootReselection = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prefetchFolderForNavigation(folderUriString: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
listGalleryUseCase(folderUriString.toUri())
|
||||||
|
}.onSuccess { content ->
|
||||||
|
val loader = coil3.SingletonImageLoader.get(appContext)
|
||||||
|
content.photos
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
.take(NAVIGATION_PREFETCH_THUMBNAIL_COUNT)
|
||||||
|
.forEach { photo ->
|
||||||
|
thumbnailWarmupRequests(appContext, photo.uri).forEach(loader::enqueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFolderStats(folders: List<FolderNode>) {
|
||||||
|
folders.forEach { folder ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
photoRepository.subtreeStats(folder.uri)
|
||||||
|
}.onSuccess { stats ->
|
||||||
|
val current = uiState.value.folders
|
||||||
|
val idx = current.indexOfFirst { it.uri == folder.uri }
|
||||||
|
if (idx >= 0) {
|
||||||
|
val old = current[idx]
|
||||||
|
|
||||||
|
val gone = old.previewPhotoUris.toSet() - stats.previews.toSet()
|
||||||
|
if (gone.isNotEmpty()) {
|
||||||
|
evictThumbnails(coil3.SingletonImageLoader.get(appContext), gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (old.photoCount != stats.count ||
|
||||||
|
old.previewPhotoUris != stats.previews ||
|
||||||
|
old.firstPhotoUri != stats.previews.firstOrNull()
|
||||||
|
) {
|
||||||
|
val updated = current.toMutableList()
|
||||||
|
updated[idx] = old.copy(
|
||||||
|
photoCount = stats.count,
|
||||||
|
firstPhotoUri = stats.previews.firstOrNull(),
|
||||||
|
previewPhotoUris = stats.previews,
|
||||||
|
statsLoaded = true,
|
||||||
|
)
|
||||||
|
uiState.value = uiState.value.copy(folders = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.previews.isNotEmpty()) {
|
||||||
|
val imageLoader = coil3.SingletonImageLoader.get(appContext)
|
||||||
|
stats.previews.forEach { uri ->
|
||||||
|
thumbnailWarmupRequests(appContext, uri).forEach(imageLoader::enqueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyIncrementalUpdate(savedFolderUri: Uri) {
|
||||||
|
val root = uiState.value.rootFolderUri?.toUri() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val chain = listOf(savedFolderUri, root)
|
||||||
|
val updated = uiState.value.folders.toMutableList()
|
||||||
|
var changed = false
|
||||||
|
chain.forEach { uri ->
|
||||||
|
val stats = photoRepository.subtreeStats(uri)
|
||||||
|
val idx = updated.indexOfFirst { it.uri == uri }
|
||||||
|
if (idx >= 0) {
|
||||||
|
val old = updated[idx]
|
||||||
|
if (old.photoCount != stats.count ||
|
||||||
|
old.previewPhotoUris != stats.previews ||
|
||||||
|
old.firstPhotoUri != stats.previews.firstOrNull()
|
||||||
|
) {
|
||||||
|
updated[idx] = old.copy(
|
||||||
|
photoCount = stats.count,
|
||||||
|
firstPhotoUri = stats.previews.firstOrNull(),
|
||||||
|
previewPhotoUris = stats.previews,
|
||||||
|
statsLoaded = true,
|
||||||
|
)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
uiState.value = uiState.value.copy(folders = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCreateDialog() {
|
||||||
|
uiState.value = uiState.value.copy(showCreateDialog = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissCreateDialog() {
|
||||||
|
uiState.value = uiState.value.copy(showCreateDialog = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFolder(name: String) {
|
||||||
|
val rootUri = uiState.value.rootFolderUri ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val created = createFolderUseCase(rootUri.toUri(), name)
|
||||||
|
dismissCreateDialog()
|
||||||
|
if (created == null) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to create folder")
|
||||||
|
}
|
||||||
|
loadFolders(rootUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
photoRepository.invalidateCaches()
|
||||||
|
loadFolders()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRenameDialog(folder: FolderNode) {
|
||||||
|
uiState.value = uiState.value.copy(showRenameDialog = folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRenameDialog() {
|
||||||
|
uiState.value = uiState.value.copy(showRenameDialog = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameFolder(folder: FolderNode, newName: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val renamed = renameEntryUseCase.folder(folder.uri, newName)
|
||||||
|
dismissRenameDialog()
|
||||||
|
if (!renamed) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to rename folder")
|
||||||
|
}
|
||||||
|
loadFolders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDeleteDialog(folder: FolderNode) {
|
||||||
|
uiState.value = uiState.value.copy(showDeleteDialog = folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDeleteDialog() {
|
||||||
|
uiState.value = uiState.value.copy(showDeleteDialog = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteFolder(folder: FolderNode) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val deleted = deleteEntryUseCase(folder.uri)
|
||||||
|
dismissDeleteDialog()
|
||||||
|
if (!deleted) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to delete folder")
|
||||||
|
}
|
||||||
|
loadFolders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipFolderAndShare(folder: FolderNode) {
|
||||||
|
val rootUri = uiState.value.rootFolderUri?.toUri() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
val outcome = zipFolderUseCase(folder.uri, rootUri, folder.name)
|
||||||
|
if (outcome.success && outcome.archive != null) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
shareFileRequest = outcome.archive.toShareFileRequest(),
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(rootUri, outcome.archive)
|
||||||
|
loadFolders()
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = outcome.errorMessage ?: "Could not create ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipFolderCompressedJpegsAndShare(folder: FolderNode) {
|
||||||
|
val rootUri = uiState.value.rootFolderUri?.toUri() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
val outcome = zipFolderUseCase.compressedFolderJpegs(folder.uri, rootUri, folder.name)
|
||||||
|
if (outcome.success && outcome.archive != null) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
shareFileRequest = outcome.archive.toShareFileRequest(),
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(rootUri, outcome.archive)
|
||||||
|
loadFolders()
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = outcome.errorMessage ?: "Could not create ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearShareRequest() {
|
||||||
|
uiState.value = uiState.value.copy(shareFileRequest = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportShareFailure(message: String) {
|
||||||
|
uiState.value = uiState.value.copy(error = message, shareFileRequest = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCameraFabPosition(xFraction: Float, yFraction: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setCameraFabPosition(xFraction, yFraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Photo.toShareFileRequest(): com.example.onecamera.ui.gallery.ShareFileRequest {
|
||||||
|
return com.example.onecamera.ui.gallery.ShareFileRequest(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType.ifBlank { "application/octet-stream" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
package com.example.onecamera.ui.gallery
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonGroup
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.domain.model.GalleryFileKind
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import com.example.onecamera.ui.components.DraggableCameraFab
|
||||||
|
import com.example.onecamera.ui.components.FolderTreePickerSheet
|
||||||
|
import com.example.onecamera.ui.components.GalleryGrid
|
||||||
|
import com.example.onecamera.ui.components.LocalOneCameraAnimationsEnabled
|
||||||
|
import com.example.onecamera.ui.components.OneCameraEmptyState
|
||||||
|
import com.example.onecamera.ui.components.OneCameraGalleryMaxWidth
|
||||||
|
import com.example.onecamera.ui.components.OneCameraScreenPadding
|
||||||
|
import com.example.onecamera.ui.components.SelectionActionsBar
|
||||||
|
import com.example.onecamera.ui.components.oneCameraTopAppBarColors
|
||||||
|
import com.example.onecamera.ui.components.thumbnailDraftRequest
|
||||||
|
import com.example.onecamera.ui.folders.CreateFolderDialog
|
||||||
|
import com.example.onecamera.ui.folders.DeleteFolderDialog
|
||||||
|
import com.example.onecamera.ui.folders.RenameFileDialog
|
||||||
|
import com.example.onecamera.ui.folders.RenameFolderDialog
|
||||||
|
import com.github.panpf.zoomimage.CoilZoomAsyncImage
|
||||||
|
import com.github.panpf.zoomimage.rememberCoilZoomState
|
||||||
|
import com.github.panpf.zoomimage.zoom.GestureType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun GalleryScreen(
|
||||||
|
folderUri: String,
|
||||||
|
onNavigateToCamera: (String) -> Unit,
|
||||||
|
onNavigateToSubfolder: (String) -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: GalleryViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val gridState = rememberLazyGridState()
|
||||||
|
val shareChooserTitle = stringResource(
|
||||||
|
R.string.folders_share_chooser,
|
||||||
|
uiState.shareFileRequest?.name.orEmpty(),
|
||||||
|
)
|
||||||
|
val sharePhotosChooserTitle = stringResource(R.string.gallery_share_photos_chooser)
|
||||||
|
val shareNoAppMessage = stringResource(R.string.folders_share_no_app)
|
||||||
|
val shareFailedMessage = stringResource(R.string.folders_share_failed)
|
||||||
|
|
||||||
|
LifecycleResumeEffect(folderUri) {
|
||||||
|
|
||||||
|
if (uiState.selectedPhoto == null) {
|
||||||
|
viewModel.resumeRefresh(folderUri)
|
||||||
|
}
|
||||||
|
onPauseOrDispose {}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
uiState.error?.let {
|
||||||
|
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.shareFileRequest) {
|
||||||
|
uiState.shareFileRequest?.let { request ->
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = request.mimeType
|
||||||
|
putExtra(Intent.EXTRA_STREAM, request.uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, request.name)
|
||||||
|
clipData = ClipData.newUri(context.contentResolver, request.name, request.uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(
|
||||||
|
shareIntent,
|
||||||
|
shareChooserTitle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
viewModel.clearShareRequest()
|
||||||
|
}.onFailure { error ->
|
||||||
|
val message = if (error is ActivityNotFoundException) {
|
||||||
|
shareNoAppMessage
|
||||||
|
} else {
|
||||||
|
error.message ?: shareFailedMessage
|
||||||
|
}
|
||||||
|
viewModel.reportShareFailure(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.shareMultipleRequest) {
|
||||||
|
val request = uiState.shareMultipleRequest ?: return@LaunchedEffect
|
||||||
|
val uris = ArrayList(request.map { it.uri })
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||||
|
type = "image/*"
|
||||||
|
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
val clip = ClipData.newUri(context.contentResolver, request.first().name, request.first().uri)
|
||||||
|
request.drop(1).forEach { item ->
|
||||||
|
clip.addItem(ClipData.Item(item.uri))
|
||||||
|
}
|
||||||
|
clipData = clip
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(
|
||||||
|
shareIntent,
|
||||||
|
sharePhotosChooserTitle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onSuccess {
|
||||||
|
viewModel.clearShareRequest()
|
||||||
|
}.onFailure { error ->
|
||||||
|
val message = if (error is ActivityNotFoundException) {
|
||||||
|
shareNoAppMessage
|
||||||
|
} else {
|
||||||
|
error.message ?: shareFailedMessage
|
||||||
|
}
|
||||||
|
viewModel.reportShareFailure(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortedPhotos = remember(uiState.photos, uiState.sortAscending) {
|
||||||
|
if (uiState.sortAscending) {
|
||||||
|
uiState.photos.sortedBy { it.name.lowercase() }
|
||||||
|
} else {
|
||||||
|
uiState.photos.sortedByDescending { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sortedSubfolders = remember(uiState.subfolders, uiState.sortAscending) {
|
||||||
|
if (uiState.sortAscending) {
|
||||||
|
uiState.subfolders.sortedBy { it.name.lowercase() }
|
||||||
|
} else {
|
||||||
|
uiState.subfolders.sortedByDescending { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sortedGalleryPhotos by remember(sortedPhotos) {
|
||||||
|
derivedStateOf {
|
||||||
|
sortedPhotos.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val dialog = uiState.dialog) {
|
||||||
|
GalleryDialog.CreateSubfolder -> {
|
||||||
|
CreateFolderDialog(
|
||||||
|
onDismiss = { viewModel.dismissDialog() },
|
||||||
|
onCreate = { viewModel.createSubfolder(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is GalleryDialog.DeletePhoto -> {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.dismissDialog() },
|
||||||
|
title = { Text(stringResource(R.string.gallery_delete_photo_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.gallery_delete_photo_message,
|
||||||
|
dialog.photo.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.deletePhoto(dialog.photo) }) {
|
||||||
|
Text(stringResource(R.string.action_delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.dismissDialog() }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is GalleryDialog.RenameFile -> {
|
||||||
|
RenameFileDialog(
|
||||||
|
file = dialog.file,
|
||||||
|
onDismiss = { viewModel.dismissDialog() },
|
||||||
|
onRename = { viewModel.renameFile(dialog.file, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is GalleryDialog.RenameSubfolder -> {
|
||||||
|
RenameFolderDialog(
|
||||||
|
folder = dialog.folder,
|
||||||
|
onDismiss = { viewModel.dismissDialog() },
|
||||||
|
onRename = { viewModel.renameSubfolder(dialog.folder, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is GalleryDialog.DeleteSubfolder -> {
|
||||||
|
DeleteFolderDialog(
|
||||||
|
folder = dialog.folder,
|
||||||
|
onDismiss = { viewModel.dismissDialog() },
|
||||||
|
onDelete = { viewModel.deleteSubfolder(dialog.folder) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.movePhotoSheet?.let { sheetState ->
|
||||||
|
FolderTreePickerSheet(
|
||||||
|
state = sheetState,
|
||||||
|
currentFolderUri = uiState.currentFolderUri,
|
||||||
|
onDismiss = { viewModel.dismissMovePhotoSheet() },
|
||||||
|
onSelect = {
|
||||||
|
sheetState.folder?.let { folder -> viewModel.moveFolder(folder, it) }
|
||||||
|
?: sheetState.files.takeIf { files -> files.isNotEmpty() }?.let { _ ->
|
||||||
|
viewModel.moveSelectedPhotos(it)
|
||||||
|
}
|
||||||
|
?: sheetState.file?.let { file -> viewModel.movePhoto(file, it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.semantics { testTagsAsResourceId = true },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
uiState.folderName.ifEmpty {
|
||||||
|
stringResource(R.string.gallery_title)
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (uiState.isSelectionMode) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.gallery_selected_count,
|
||||||
|
uiState.selectedPhotoUris.size,
|
||||||
|
uiState.selectedPhotoUris.size,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
if (uiState.isSelectionMode) {
|
||||||
|
IconButton(onClick = viewModel::clearSelection) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_close),
|
||||||
|
contentDescription = stringResource(R.string.gallery_clear_selection),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (uiState.isSelectionMode) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.selectAllPhotos() },
|
||||||
|
enabled = !uiState.allPhotosSelected,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.action_select_all),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
ButtonGroup(overflowIndicator = {}) {
|
||||||
|
customItem(
|
||||||
|
buttonGroupContent = {
|
||||||
|
IconButton(onClick = { viewModel.toggleSortOrder() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (uiState.sortAscending) {
|
||||||
|
R.drawable.sort_ascending_24
|
||||||
|
} else {
|
||||||
|
R.drawable.sort_descending_24
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentDescription = if (uiState.sortAscending) {
|
||||||
|
stringResource(R.string.gallery_sort_descending)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.gallery_sort_ascending)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
menuContent = {},
|
||||||
|
)
|
||||||
|
customItem(
|
||||||
|
buttonGroupContent = {
|
||||||
|
IconButton(onClick = { viewModel.toggleDisplayMode() }) {
|
||||||
|
Icon(
|
||||||
|
painter = if (uiState.isGridMode) {
|
||||||
|
painterResource(R.drawable.ic_symbol_view_list)
|
||||||
|
} else {
|
||||||
|
painterResource(R.drawable.ic_symbol_grid_view)
|
||||||
|
},
|
||||||
|
contentDescription = if (uiState.isGridMode) {
|
||||||
|
stringResource(R.string.gallery_show_list)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.gallery_show_grid)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
menuContent = {},
|
||||||
|
)
|
||||||
|
customItem(
|
||||||
|
buttonGroupContent = {
|
||||||
|
IconButton(onClick = { viewModel.showCreateSubfolderDialog() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_create_new_folder),
|
||||||
|
contentDescription = stringResource(R.string.gallery_create_subfolder),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
menuContent = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = oneCameraTopAppBarColors(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = uiState.isLoading,
|
||||||
|
onRefresh = viewModel::refresh,
|
||||||
|
|
||||||
|
indicator = {},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
when {
|
||||||
|
|
||||||
|
uiState.selectedPhoto != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
!uiState.viewPreferencesLoaded -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
uiState.isLoading && uiState.photos.isEmpty() && uiState.subfolders.isEmpty() -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
uiState.photos.isEmpty() && uiState.subfolders.isEmpty() -> {
|
||||||
|
EmptyGallery(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(OneCameraScreenPadding)
|
||||||
|
.widthIn(max = 560.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
GalleryGrid(
|
||||||
|
state = gridState,
|
||||||
|
folders = sortedSubfolders,
|
||||||
|
photos = sortedPhotos,
|
||||||
|
isGridMode = uiState.isGridMode,
|
||||||
|
minCellSize = 132.dp,
|
||||||
|
|
||||||
|
contentPadding = PaddingValues(OneCameraScreenPadding),
|
||||||
|
|
||||||
|
onFolderClick = {
|
||||||
|
viewModel.prefetchFolderForNavigation(it.uri.toString())
|
||||||
|
onNavigateToSubfolder(it.uri.toString())
|
||||||
|
},
|
||||||
|
onFolderCompress = { viewModel.zipFolderAndShare(it) },
|
||||||
|
onFolderCompressJpeg85 = { viewModel.zipFolderCompressedJpegsAndShare(it) },
|
||||||
|
onFolderMove = { viewModel.showMoveFolderSheet(it) },
|
||||||
|
onFolderRename = { viewModel.showRenameSubfolderDialog(it) },
|
||||||
|
onFolderDelete = { viewModel.showDeleteSubfolderDialog(it) },
|
||||||
|
onPhotoClick = {
|
||||||
|
|
||||||
|
viewModel.selectPhoto(it)
|
||||||
|
},
|
||||||
|
onPhotoLongClick = { viewModel.togglePhotoSelection(it) },
|
||||||
|
onPhotoMove = { viewModel.showMovePhotoSheet(it) },
|
||||||
|
onPhotoRename = { viewModel.showRenameFileDialog(it) },
|
||||||
|
|
||||||
|
onPhotoCompressFolder = { viewModel.zipPhotoAndShare(it) },
|
||||||
|
onPhotoCompressJpeg85 = { viewModel.zipPhotoCompressedJpegAndShare(it) },
|
||||||
|
onPhotoShare = { viewModel.shareArchive(it) },
|
||||||
|
onPhotoDelete = { viewModel.showDeleteDialog(it) },
|
||||||
|
showItemActions = !uiState.isSelectionMode,
|
||||||
|
selectedPhotoUris = uiState.selectedPhotoUris,
|
||||||
|
onDragSelectStart = { viewModel.beginDragSelection(it) },
|
||||||
|
onDragSelectUpdate = { viewModel.updateDragSelection(it) },
|
||||||
|
onDragSelectEnd = { viewModel.endDragSelection() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.widthIn(max = OneCameraGalleryMaxWidth)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !uiState.isSelectionMode,
|
||||||
|
enter = fadeIn(tween(250)),
|
||||||
|
exit = fadeOut(tween(150)),
|
||||||
|
) {
|
||||||
|
DraggableCameraFab(
|
||||||
|
xFraction = uiState.cameraFabXFraction,
|
||||||
|
yFraction = uiState.cameraFabYFraction,
|
||||||
|
onPositionChange = viewModel::saveCameraFabPosition,
|
||||||
|
onClick = { onNavigateToCamera(uiState.currentFolderUri ?: folderUri) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionActionsBar(
|
||||||
|
visible = uiState.isSelectionMode,
|
||||||
|
onZip = viewModel::zipSelectedPhotosAndShare,
|
||||||
|
onMove = viewModel::showMoveSelectedSheet,
|
||||||
|
onDelete = viewModel::deleteSelectedPhotos,
|
||||||
|
onShare = viewModel::shareSelectedPhotos,
|
||||||
|
)
|
||||||
|
|
||||||
|
uiState.selectedPhoto?.let { photo ->
|
||||||
|
FullscreenPhotoViewerOverlay(
|
||||||
|
photos = sortedGalleryPhotos,
|
||||||
|
initialPhoto = photo,
|
||||||
|
onDismiss = { viewModel.clearSelectedPhoto() },
|
||||||
|
onDelete = { viewModel.showDeleteDialog(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun FullscreenPhotoViewerOverlay(
|
||||||
|
photos: List<Photo>,
|
||||||
|
initialPhoto: Photo,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDelete: (Photo) -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler { onDismiss() }
|
||||||
|
FullscreenPhotoPagerContent(
|
||||||
|
photos = photos,
|
||||||
|
initialPhoto = initialPhoto,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
onDelete = onDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun FullscreenPhotoPagerContent(
|
||||||
|
photos: List<Photo>,
|
||||||
|
initialPhoto: Photo,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onDelete: (Photo) -> Unit,
|
||||||
|
) {
|
||||||
|
val initialPage by remember(photos, initialPhoto.uri) {
|
||||||
|
derivedStateOf {
|
||||||
|
photos.indexOfFirst { it.uri == initialPhoto.uri }.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pagerState = rememberPagerState(initialPage = initialPage) { photos.size.coerceAtLeast(1) }
|
||||||
|
|
||||||
|
var isZoomed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
isZoomed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.semantics { contentDescription = "Photo viewer" }
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
key = { page -> photos.getOrNull(page)?.uri ?: page },
|
||||||
|
userScrollEnabled = !isZoomed,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) { page ->
|
||||||
|
photos.getOrNull(page)?.let { photo ->
|
||||||
|
ZoomablePhoto(
|
||||||
|
photo = photo,
|
||||||
|
onZoomChange = { zoomed -> isZoomed = zoomed },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_close),
|
||||||
|
contentDescription = stringResource(R.string.action_close),
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
photos.getOrNull(pagerState.currentPage)?.let(onDelete)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_delete),
|
||||||
|
contentDescription = stringResource(R.string.action_delete),
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (photos.size > 1) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.gallery_page_count,
|
||||||
|
pagerState.currentPage + 1,
|
||||||
|
photos.size,
|
||||||
|
),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZoomablePhoto(
|
||||||
|
photo: Photo,
|
||||||
|
onZoomChange: (Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val zoomState = rememberCoilZoomState()
|
||||||
|
|
||||||
|
val fullImageRequest = remember(context, photo.uri) {
|
||||||
|
val maxDim = maxOf(
|
||||||
|
context.resources.displayMetrics.widthPixels,
|
||||||
|
context.resources.displayMetrics.heightPixels,
|
||||||
|
)
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(photo.uri)
|
||||||
|
.size(coil3.size.Size(maxDim, maxDim))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFullReady by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val animationsEnabled = LocalOneCameraAnimationsEnabled.current
|
||||||
|
val fadeMs = if (animationsEnabled) 200 else 0
|
||||||
|
val fullAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (isFullReady) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = fadeMs),
|
||||||
|
label = "fullAlpha",
|
||||||
|
)
|
||||||
|
val scale by remember { derivedStateOf { zoomState.zoomable.transform.scaleX } }
|
||||||
|
|
||||||
|
LaunchedEffect(photo.uri) {
|
||||||
|
|
||||||
|
zoomState.zoomable.setReadMode(null)
|
||||||
|
zoomState.zoomable.reset()
|
||||||
|
zoomState.subsampling.setDisabled(true)
|
||||||
|
zoomState.zoomable.setDisabledGestureTypes(ALL_GESTURE_TYPES)
|
||||||
|
onZoomChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isFullReady) {
|
||||||
|
if (isFullReady) {
|
||||||
|
zoomState.zoomable.setDisabledGestureTypes(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(scale) {
|
||||||
|
val zoomed = scale > 1.01f
|
||||||
|
|
||||||
|
onZoomChange(zoomed)
|
||||||
|
zoomState.subsampling.setDisabled(!zoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
|
||||||
|
if (fullAlpha < 1f) {
|
||||||
|
AsyncImage(
|
||||||
|
model = remember(photo.uri, context, fadeMs) {
|
||||||
|
thumbnailDraftRequest(context, photo.uri, crossfadeMillis = fadeMs)
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CoilZoomAsyncImage(
|
||||||
|
model = fullImageRequest,
|
||||||
|
contentDescription = photo.name,
|
||||||
|
onLoading = { isFullReady = false },
|
||||||
|
onSuccess = { isFullReady = true },
|
||||||
|
onError = { isFullReady = false },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alpha(fullAlpha),
|
||||||
|
zoomState = zoomState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ALL_GESTURE_TYPES =
|
||||||
|
GestureType.ONE_FINGER_DRAG or
|
||||||
|
GestureType.TWO_FINGER_SCALE or
|
||||||
|
GestureType.ONE_FINGER_SCALE or
|
||||||
|
GestureType.DOUBLE_TAP_SCALE or
|
||||||
|
GestureType.MOUSE_WHEEL_SCALE or
|
||||||
|
GestureType.KEYBOARD_SCALE or
|
||||||
|
GestureType.KEYBOARD_DRAG
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyGallery(modifier: Modifier = Modifier) {
|
||||||
|
OneCameraEmptyState(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_photo_library),
|
||||||
|
title = stringResource(R.string.gallery_empty_title),
|
||||||
|
body = stringResource(R.string.gallery_empty_body),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
package com.example.onecamera.ui.gallery
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import com.example.onecamera.core.performance.OneCameraTrace
|
||||||
|
import com.example.onecamera.data.PhotoEventBus
|
||||||
|
import com.example.onecamera.data.preferences.UserPreferencesRepository
|
||||||
|
import com.example.onecamera.data.storage.ExternalMediaObserver
|
||||||
|
import com.example.onecamera.domain.model.GalleryFileKind
|
||||||
|
import com.example.onecamera.domain.model.FolderNode
|
||||||
|
import com.example.onecamera.domain.model.MovePhotoResult
|
||||||
|
import com.example.onecamera.domain.model.Photo
|
||||||
|
import com.example.onecamera.domain.model.mergeLoadedSubfolderStats
|
||||||
|
import com.example.onecamera.domain.usecase.CreateFolderUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.DeleteEntryUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.ListFolderTreeUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.ListGalleryUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.MovePhotoUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.RenameEntryUseCase
|
||||||
|
import com.example.onecamera.domain.usecase.ZipFolderUseCase
|
||||||
|
import com.example.onecamera.ui.components.evictThumbnails
|
||||||
|
import com.example.onecamera.ui.components.NAVIGATION_PREFETCH_THUMBNAIL_COUNT
|
||||||
|
import com.example.onecamera.ui.components.thumbnailWarmupRequests
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface GalleryDialog {
|
||||||
|
data object CreateSubfolder : GalleryDialog
|
||||||
|
data class DeletePhoto(val photo: Photo) : GalleryDialog
|
||||||
|
data class RenameFile(val file: Photo) : GalleryDialog
|
||||||
|
data class RenameSubfolder(val folder: FolderNode) : GalleryDialog
|
||||||
|
data class DeleteSubfolder(val folder: FolderNode) : GalleryDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MovePhotoSheetState(
|
||||||
|
val file: Photo? = null,
|
||||||
|
val files: List<Photo> = emptyList(),
|
||||||
|
val folder: FolderNode? = null,
|
||||||
|
val folders: List<FolderNode> = emptyList(),
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val isMoving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val title: String = when {
|
||||||
|
folder != null -> folder.name
|
||||||
|
files.size > 1 -> "${files.size} files selected"
|
||||||
|
files.size == 1 -> files.first().name
|
||||||
|
file != null -> file.name
|
||||||
|
else -> "Move"
|
||||||
|
}
|
||||||
|
|
||||||
|
val isFolderMove: Boolean = folder != null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ShareFileRequest(
|
||||||
|
val uri: Uri,
|
||||||
|
val name: String,
|
||||||
|
val mimeType: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class GalleryUiState(
|
||||||
|
val folderName: String = "",
|
||||||
|
val currentFolderUri: String? = null,
|
||||||
|
val rootFolderUri: String? = null,
|
||||||
|
val subfolders: List<FolderNode> = emptyList(),
|
||||||
|
val photos: List<Photo> = emptyList(),
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val selectedPhoto: Photo? = null,
|
||||||
|
val selectedPhotoUris: Set<String> = emptySet(),
|
||||||
|
val dialog: GalleryDialog? = null,
|
||||||
|
val movePhotoSheet: MovePhotoSheetState? = null,
|
||||||
|
val shareFileRequest: ShareFileRequest? = null,
|
||||||
|
val shareMultipleRequest: List<ShareFileRequest>? = null,
|
||||||
|
val cameraFabXFraction: Float = 1f,
|
||||||
|
val cameraFabYFraction: Float = 1f,
|
||||||
|
val sortAscending: Boolean = true,
|
||||||
|
val isGridMode: Boolean = true,
|
||||||
|
|
||||||
|
val viewPreferencesLoaded: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val isSelectionMode: Boolean = selectedPhotoUris.isNotEmpty()
|
||||||
|
val selectedPhotos: List<Photo> = photos.filter { it.uri.toString() in selectedPhotoUris }
|
||||||
|
val sortedSubfolders: List<FolderNode> = if (sortAscending) {
|
||||||
|
subfolders.sortedBy { it.name.lowercase() }
|
||||||
|
} else {
|
||||||
|
subfolders.sortedByDescending { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
val sortedPhotos: List<Photo> = if (sortAscending) {
|
||||||
|
photos.sortedBy { it.name.lowercase() }
|
||||||
|
} else {
|
||||||
|
photos.sortedByDescending { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
val selectablePhotoUris: Set<String> = photos
|
||||||
|
.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
.map { it.uri.toString() }
|
||||||
|
.toSet()
|
||||||
|
val allPhotosSelected: Boolean = selectablePhotoUris.isNotEmpty() && selectedPhotoUris.containsAll(selectablePhotoUris)
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class GalleryViewModel @Inject constructor(
|
||||||
|
private val preferencesRepository: UserPreferencesRepository,
|
||||||
|
private val listGalleryUseCase: ListGalleryUseCase,
|
||||||
|
private val listFolderTreeUseCase: ListFolderTreeUseCase,
|
||||||
|
private val movePhotoUseCase: MovePhotoUseCase,
|
||||||
|
private val zipFolderUseCase: ZipFolderUseCase,
|
||||||
|
private val createFolderUseCase: CreateFolderUseCase,
|
||||||
|
private val renameEntryUseCase: RenameEntryUseCase,
|
||||||
|
private val deleteEntryUseCase: DeleteEntryUseCase,
|
||||||
|
private val photoEventBus: PhotoEventBus,
|
||||||
|
private val externalMediaObserver: ExternalMediaObserver,
|
||||||
|
private val photoRepository: com.example.onecamera.domain.repository.PhotoRepository,
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<GalleryUiState>
|
||||||
|
field: MutableStateFlow<GalleryUiState> = MutableStateFlow(GalleryUiState())
|
||||||
|
|
||||||
|
private var forceReload = false
|
||||||
|
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.userPreferencesFlow.collect { prefs ->
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
rootFolderUri = prefs.rootFolderUri,
|
||||||
|
cameraFabXFraction = prefs.cameraFabXFraction,
|
||||||
|
cameraFabYFraction = prefs.cameraFabYFraction,
|
||||||
|
|
||||||
|
sortAscending = prefs.gallerySortAscending,
|
||||||
|
isGridMode = prefs.galleryGridMode,
|
||||||
|
viewPreferencesLoaded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
photoEventBus.photoSaved.collect { event ->
|
||||||
|
val currentUri = uiState.value.currentFolderUri?.toUri()
|
||||||
|
if (currentUri == event.folderUri) {
|
||||||
|
val savedPhoto = event.photo
|
||||||
|
if (savedPhoto != null) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = listOf(savedPhoto) + uiState.value.photos.filterNot { it.uri == savedPhoto.uri },
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
forceReload = true
|
||||||
|
uiState.value.currentFolderUri?.let { loadPhotos(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
externalMediaObserver.changes.collect {
|
||||||
|
photoRepository.invalidateCaches()
|
||||||
|
forceReload = true
|
||||||
|
uiState.value.currentFolderUri?.let { loadPhotos(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPhotos(folderUriString: String) {
|
||||||
|
val state = uiState.value
|
||||||
|
if (
|
||||||
|
folderUriString == state.currentFolderUri &&
|
||||||
|
!forceReload &&
|
||||||
|
loadJob?.isActive == true
|
||||||
|
) {
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
folderUriString == state.currentFolderUri &&
|
||||||
|
!forceReload &&
|
||||||
|
(!state.isLoading || state.photos.isNotEmpty() || state.subfolders.isNotEmpty())
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forceReload = false
|
||||||
|
|
||||||
|
loadJob?.cancel()
|
||||||
|
loadJob = viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
currentFolderUri = folderUriString,
|
||||||
|
isLoading = true,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
OneCameraTrace.asyncSection(OneCameraTrace.GALLERY_LOAD) {
|
||||||
|
listGalleryUseCase(folderUriString.toUri())
|
||||||
|
}
|
||||||
|
}.onSuccess { content ->
|
||||||
|
|
||||||
|
val previousPhotoUris = uiState.value.photos.map { it.uri }.toSet()
|
||||||
|
val currentPhotoUris = content.photos.map { it.uri }.toSet()
|
||||||
|
evictThumbnails(
|
||||||
|
SingletonImageLoader.get(appContext),
|
||||||
|
previousPhotoUris - currentPhotoUris,
|
||||||
|
)
|
||||||
|
|
||||||
|
val mergedSubfolders = mergeLoadedSubfolderStats(
|
||||||
|
content.subfolders,
|
||||||
|
uiState.value.subfolders,
|
||||||
|
)
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
folderName = content.folderName,
|
||||||
|
photos = content.photos,
|
||||||
|
subfolders = mergedSubfolders,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (content.subfolders.isNotEmpty()) {
|
||||||
|
loadSubfolderStats(mergedSubfolders)
|
||||||
|
}
|
||||||
|
prefetchThumbnails(content.photos)
|
||||||
|
}.onFailure { error ->
|
||||||
|
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = error.message ?: "Failed to load photos",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
forceReload = true
|
||||||
|
uiState.value.currentFolderUri?.let { loadPhotos(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeRefresh(folderUriString: String) {
|
||||||
|
forceReload = true
|
||||||
|
loadPhotos(folderUriString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSubfolderStats(folders: List<FolderNode>) {
|
||||||
|
folders.forEach { folder ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
photoRepository.subtreeStats(folder.uri)
|
||||||
|
}.onSuccess { stats ->
|
||||||
|
val current = uiState.value.subfolders
|
||||||
|
val idx = current.indexOfFirst { it.uri == folder.uri }
|
||||||
|
if (idx >= 0) {
|
||||||
|
val old = current[idx]
|
||||||
|
val gone = old.previewPhotoUris.toSet() - stats.previews.toSet()
|
||||||
|
if (gone.isNotEmpty()) {
|
||||||
|
evictThumbnails(SingletonImageLoader.get(appContext), gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (old.photoCount != stats.count ||
|
||||||
|
old.previewPhotoUris != stats.previews ||
|
||||||
|
old.firstPhotoUri != stats.previews.firstOrNull()
|
||||||
|
) {
|
||||||
|
val updated = current.toMutableList()
|
||||||
|
updated[idx] = old.copy(
|
||||||
|
photoCount = stats.count,
|
||||||
|
firstPhotoUri = stats.previews.firstOrNull(),
|
||||||
|
previewPhotoUris = stats.previews,
|
||||||
|
statsLoaded = true,
|
||||||
|
)
|
||||||
|
uiState.value = uiState.value.copy(subfolders = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefetchThumbnailUris(stats.previews)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefetchThumbnails(photos: List<Photo>) {
|
||||||
|
val imageLoader = SingletonImageLoader.get(appContext)
|
||||||
|
photos
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
.take(NAVIGATION_PREFETCH_THUMBNAIL_COUNT)
|
||||||
|
.forEach { photo ->
|
||||||
|
thumbnailWarmupRequests(appContext, photo.uri).forEach(imageLoader::enqueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prefetchFolderForNavigation(folderUriString: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
listGalleryUseCase(folderUriString.toUri())
|
||||||
|
}.onSuccess { content ->
|
||||||
|
prefetchThumbnails(content.photos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefetchThumbnailUris(uris: List<Uri>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
val imageLoader = SingletonImageLoader.get(appContext)
|
||||||
|
uris.forEach { uri ->
|
||||||
|
thumbnailWarmupRequests(appContext, uri).forEach(imageLoader::enqueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPhoto(photo: Photo) {
|
||||||
|
if (uiState.value.isSelectionMode && photo.kind == GalleryFileKind.Photo) {
|
||||||
|
togglePhotoSelection(photo)
|
||||||
|
} else if (photo.kind == GalleryFileKind.Photo) {
|
||||||
|
uiState.value = uiState.value.copy(selectedPhoto = photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePhotoSelection(photo: Photo) {
|
||||||
|
if (photo.kind != GalleryFileKind.Photo) return
|
||||||
|
val key = photo.uri.toString()
|
||||||
|
val selected = uiState.value.selectedPhotoUris
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
selectedPhotoUris = if (key in selected) selected - key else selected + key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAllPhotos() {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
selectedPhotoUris = uiState.value.selectablePhotoUris,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelection() {
|
||||||
|
dragSelectionBaseline = emptySet()
|
||||||
|
uiState.value = uiState.value.copy(selectedPhotoUris = emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dragSelectionBaseline: Set<String> = emptySet()
|
||||||
|
|
||||||
|
fun beginDragSelection(anchorUri: String) {
|
||||||
|
dragSelectionBaseline = uiState.value.selectedPhotoUris + anchorUri
|
||||||
|
uiState.value = uiState.value.copy(selectedPhotoUris = dragSelectionBaseline)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDragSelection(rangeUris: Set<String>) {
|
||||||
|
val selectable = uiState.value.selectablePhotoUris
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
selectedPhotoUris = dragSelectionBaseline + rangeUris.intersect(selectable),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endDragSelection() {
|
||||||
|
dragSelectionBaseline = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipSelectedPhotosAndShare() {
|
||||||
|
val currentFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
val folderName = uiState.value.folderName.ifBlank { "photos" }
|
||||||
|
val files = uiState.value.selectedPhotos
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
val compressJpegs = preferencesRepository.userPreferencesFlow.first().zipJpegCompression
|
||||||
|
val outcome = if (compressJpegs) {
|
||||||
|
zipFolderUseCase.compressedSelectedJpegs(currentFolderUri, folderName, files)
|
||||||
|
} else {
|
||||||
|
zipFolderUseCase.selectedFiles(currentFolderUri, folderName, files)
|
||||||
|
}
|
||||||
|
if (outcome.success && outcome.archive != null) {
|
||||||
|
val archive = outcome.archive
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = listOf(archive) + uiState.value.photos.filterNot { it.uri == archive.uri },
|
||||||
|
selectedPhotoUris = emptySet(),
|
||||||
|
shareFileRequest = archive.toShareFileRequest(),
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(currentFolderUri, archive)
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = outcome.errorMessage ?: "Could not create ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSortOrder() {
|
||||||
|
uiState.value = uiState.value.copy(sortAscending = !uiState.value.sortAscending)
|
||||||
|
persistViewPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleDisplayMode() {
|
||||||
|
uiState.value = uiState.value.copy(isGridMode = !uiState.value.isGridMode)
|
||||||
|
persistViewPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistViewPreferences() {
|
||||||
|
val state = uiState.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setGalleryViewPreferences(
|
||||||
|
sortAscending = state.sortAscending,
|
||||||
|
isGridMode = state.isGridMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelectedPhoto() {
|
||||||
|
uiState.value = uiState.value.copy(selectedPhoto = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDeleteDialog(photo: Photo) {
|
||||||
|
uiState.value = uiState.value.copy(dialog = GalleryDialog.DeletePhoto(photo))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRenameFileDialog(file: Photo) {
|
||||||
|
uiState.value = uiState.value.copy(dialog = GalleryDialog.RenameFile(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDialog() {
|
||||||
|
uiState.value = uiState.value.copy(dialog = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMovePhotoSheet(photo: Photo) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = MovePhotoSheetState(file = photo),
|
||||||
|
)
|
||||||
|
loadMoveDestinations(
|
||||||
|
onMissingRoot = {
|
||||||
|
MovePhotoSheetState(
|
||||||
|
file = photo,
|
||||||
|
isLoading = false,
|
||||||
|
error = "Select a root folder in Settings first.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLoaded = { folders ->
|
||||||
|
uiState.value.movePhotoSheet?.copy(
|
||||||
|
folders = folders,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMoveSelectedSheet() {
|
||||||
|
val files = uiState.value.selectedPhotos
|
||||||
|
if (files.isEmpty()) return
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = MovePhotoSheetState(files = files),
|
||||||
|
)
|
||||||
|
loadMoveDestinations(
|
||||||
|
onMissingRoot = {
|
||||||
|
MovePhotoSheetState(
|
||||||
|
files = files,
|
||||||
|
isLoading = false,
|
||||||
|
error = "Select a root folder in Settings first.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLoaded = { folders ->
|
||||||
|
uiState.value.movePhotoSheet?.copy(
|
||||||
|
folders = folders,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMoveFolderSheet(folder: FolderNode) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = MovePhotoSheetState(folder = folder),
|
||||||
|
)
|
||||||
|
loadMoveDestinations(
|
||||||
|
onMissingRoot = {
|
||||||
|
MovePhotoSheetState(
|
||||||
|
folder = folder,
|
||||||
|
isLoading = false,
|
||||||
|
error = "Select a root folder in Settings first.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLoaded = { destinationFolders ->
|
||||||
|
val movingPath = folder.path
|
||||||
|
uiState.value.movePhotoSheet?.copy(
|
||||||
|
folders = destinationFolders.filterNot { destination ->
|
||||||
|
destination.uri == folder.uri ||
|
||||||
|
destination.path == movingPath ||
|
||||||
|
destination.path.startsWith("$movingPath/")
|
||||||
|
},
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMoveDestinations(
|
||||||
|
onMissingRoot: () -> MovePhotoSheetState,
|
||||||
|
onLoaded: (List<FolderNode>) -> MovePhotoSheetState?,
|
||||||
|
) {
|
||||||
|
val rootUri = uiState.value.rootFolderUri?.toUri()
|
||||||
|
if (rootUri == null) {
|
||||||
|
uiState.value = uiState.value.copy(movePhotoSheet = onMissingRoot())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
listFolderTreeUseCase(rootUri)
|
||||||
|
}.onSuccess { folders ->
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = onLoaded(folders) ?: uiState.value.movePhotoSheet?.copy(
|
||||||
|
folders = folders,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = uiState.value.movePhotoSheet?.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = error.message ?: "Failed to load folder tree",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissMovePhotoSheet() {
|
||||||
|
uiState.value = uiState.value.copy(movePhotoSheet = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun movePhoto(photo: Photo, targetFolder: FolderNode) {
|
||||||
|
movePhotos(listOf(photo), targetFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveSelectedPhotos(targetFolder: FolderNode) {
|
||||||
|
movePhotos(uiState.value.selectedPhotos, targetFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun movePhotos(files: List<Photo>, targetFolder: FolderNode) {
|
||||||
|
val currentFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
if (files.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = uiState.value.movePhotoSheet?.copy(isMoving = true, error = null),
|
||||||
|
)
|
||||||
|
val failures = mutableListOf<String>()
|
||||||
|
files.forEach { file ->
|
||||||
|
when (val result = movePhotoUseCase(file.uri, currentFolderUri, targetFolder.uri, file.name)) {
|
||||||
|
is MovePhotoResult.Success -> Unit
|
||||||
|
is MovePhotoResult.PartialSuccess -> failures += result.message
|
||||||
|
is MovePhotoResult.Failure -> failures += "${file.name}: ${result.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.isEmpty()) {
|
||||||
|
val movedUris = files.map { it.uri }.toSet()
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = uiState.value.photos.filterNot { it.uri in movedUris },
|
||||||
|
selectedPhotoUris = uiState.value.selectedPhotoUris - movedUris.map { it.toString() }.toSet(),
|
||||||
|
movePhotoSheet = null,
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(targetFolder.uri)
|
||||||
|
photoEventBus.onPhotoSaved(currentFolderUri)
|
||||||
|
} else {
|
||||||
|
if (failures.size < files.size) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
selectedPhotoUris = emptySet(),
|
||||||
|
movePhotoSheet = null,
|
||||||
|
)
|
||||||
|
refresh()
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = uiState.value.movePhotoSheet?.copy(
|
||||||
|
isMoving = false,
|
||||||
|
error = failures.first(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveFolder(folder: FolderNode, targetFolder: FolderNode) {
|
||||||
|
val currentFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = uiState.value.movePhotoSheet?.copy(isMoving = true, error = null),
|
||||||
|
)
|
||||||
|
when (val result = movePhotoUseCase.folder(folder.uri, currentFolderUri, targetFolder.uri, folder.name)) {
|
||||||
|
is MovePhotoResult.Success -> {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = null,
|
||||||
|
subfolders = uiState.value.subfolders.filterNot { it.uri == folder.uri },
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(targetFolder.uri)
|
||||||
|
photoEventBus.onPhotoSaved(currentFolderUri)
|
||||||
|
}
|
||||||
|
is MovePhotoResult.PartialSuccess -> {
|
||||||
|
uiState.value = uiState.value.copy(movePhotoSheet = null, error = result.message)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
is MovePhotoResult.Failure -> {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
movePhotoSheet = uiState.value.movePhotoSheet?.copy(
|
||||||
|
isMoving = false,
|
||||||
|
error = result.message,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipPhotoAndShare(photo: Photo) {
|
||||||
|
val currentFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
zipSinglePhoto(photo, currentFolderUri, compressJpegs = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipPhotoCompressedJpegAndShare(photo: Photo) {
|
||||||
|
val currentFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
zipSinglePhoto(photo, currentFolderUri, compressJpegs = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zipSinglePhoto(photo: Photo, targetFolderUri: Uri, compressJpegs: Boolean) {
|
||||||
|
val archiveBase = photo.name.substringBeforeLast('.').ifBlank { photo.name }
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
val outcome = if (compressJpegs) {
|
||||||
|
zipFolderUseCase.compressedSelectedJpegs(targetFolderUri, archiveBase, listOf(photo))
|
||||||
|
} else {
|
||||||
|
zipFolderUseCase.selectedFiles(targetFolderUri, archiveBase, listOf(photo))
|
||||||
|
}
|
||||||
|
if (outcome.success && outcome.archive != null) {
|
||||||
|
val archive = outcome.archive
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = listOf(archive) + uiState.value.photos.filterNot { it.uri == archive.uri },
|
||||||
|
shareFileRequest = archive.toShareFileRequest(),
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(targetFolderUri, archive)
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = outcome.errorMessage ?: "Could not create ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipFolderAndShare(folder: FolderNode) {
|
||||||
|
val targetFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
zipFolderAndShare(folder.uri, folder.name, targetFolderUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zipFolderCompressedJpegsAndShare(folder: FolderNode) {
|
||||||
|
val targetFolderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
zipFolderCompressedJpegsAndShare(folder.uri, folder.name, targetFolderUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zipFolderAndShare(
|
||||||
|
sourceFolderUri: Uri,
|
||||||
|
folderName: String,
|
||||||
|
targetFolderUri: Uri = sourceFolderUri,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
val outcome = zipFolderUseCase(sourceFolderUri, targetFolderUri, folderName)
|
||||||
|
if (outcome.success && outcome.archive != null) {
|
||||||
|
val archive = outcome.archive
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = listOf(archive) + uiState.value.photos.filterNot { it.uri == archive.uri },
|
||||||
|
shareFileRequest = archive.toShareFileRequest(),
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(targetFolderUri, archive)
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = outcome.errorMessage ?: "Could not create ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zipFolderCompressedJpegsAndShare(
|
||||||
|
sourceFolderUri: Uri,
|
||||||
|
folderName: String,
|
||||||
|
targetFolderUri: Uri,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
val outcome = zipFolderUseCase.compressedFolderJpegs(sourceFolderUri, targetFolderUri, folderName)
|
||||||
|
if (outcome.success && outcome.archive != null) {
|
||||||
|
val archive = outcome.archive
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = listOf(archive) + uiState.value.photos.filterNot { it.uri == archive.uri },
|
||||||
|
shareFileRequest = archive.toShareFileRequest(),
|
||||||
|
)
|
||||||
|
photoEventBus.onPhotoSaved(targetFolderUri, archive)
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = outcome.errorMessage ?: "Could not create ZIP archive.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareArchive(photo: Photo) {
|
||||||
|
if (photo.kind == GalleryFileKind.Archive) {
|
||||||
|
uiState.value = uiState.value.copy(shareFileRequest = photo.toShareFileRequest())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareSelectedPhotos() {
|
||||||
|
val files = uiState.value.selectedPhotos.filter { it.kind == GalleryFileKind.Photo }
|
||||||
|
if (files.isEmpty()) return
|
||||||
|
if (files.size == 1) {
|
||||||
|
uiState.value = uiState.value.copy(shareFileRequest = files.first().toShareFileRequest())
|
||||||
|
} else {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
shareMultipleRequest = files.map { it.toShareFileRequest() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearShareRequest() {
|
||||||
|
uiState.value = uiState.value.copy(shareFileRequest = null, shareMultipleRequest = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reportShareFailure(message: String) {
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
error = message,
|
||||||
|
shareFileRequest = null,
|
||||||
|
shareMultipleRequest = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePhoto(photo: Photo) {
|
||||||
|
deletePhotos(listOf(photo), failureMessage = "Failed to delete file")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSelectedPhotos() {
|
||||||
|
deletePhotos(uiState.value.selectedPhotos, failureMessage = "Failed to delete some files")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deletePhotos(files: List<Photo>, failureMessage: String) {
|
||||||
|
if (files.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
dismissDialog()
|
||||||
|
val deletedUris = mutableSetOf<Uri>()
|
||||||
|
files.forEach { file ->
|
||||||
|
if (deleteEntryUseCase(file.uri)) {
|
||||||
|
deletedUris += file.uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedUris.isNotEmpty()) {
|
||||||
|
val selectedPhoto = uiState.value.selectedPhoto
|
||||||
|
uiState.value = uiState.value.copy(
|
||||||
|
photos = uiState.value.photos.filterNot { it.uri in deletedUris },
|
||||||
|
selectedPhoto = if (selectedPhoto?.uri in deletedUris) null else selectedPhoto,
|
||||||
|
selectedPhotoUris = uiState.value.selectedPhotoUris - deletedUris.map { it.toString() }.toSet(),
|
||||||
|
)
|
||||||
|
uiState.value.currentFolderUri?.toUri()?.let { photoEventBus.onPhotoSaved(it) }
|
||||||
|
}
|
||||||
|
if (deletedUris.size < files.size) {
|
||||||
|
uiState.value = uiState.value.copy(error = failureMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameFile(file: Photo, newName: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val renamed = renameEntryUseCase(file.uri, newName.withOriginalExtensionIfMissing(file.name))
|
||||||
|
dismissDialog()
|
||||||
|
if (!renamed) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to rename file")
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCreateSubfolderDialog() {
|
||||||
|
uiState.value = uiState.value.copy(dialog = GalleryDialog.CreateSubfolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSubfolder(name: String) {
|
||||||
|
val folderUri = uiState.value.currentFolderUri?.toUri() ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val created = createFolderUseCase(folderUri, name)
|
||||||
|
dismissDialog()
|
||||||
|
if (created == null) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to create folder")
|
||||||
|
}
|
||||||
|
forceReload = true
|
||||||
|
loadPhotos(folderUri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showRenameSubfolderDialog(folder: FolderNode) {
|
||||||
|
uiState.value = uiState.value.copy(dialog = GalleryDialog.RenameSubfolder(folder))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameSubfolder(folder: FolderNode, newName: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val renamed = renameEntryUseCase.folder(folder.uri, newName)
|
||||||
|
dismissDialog()
|
||||||
|
if (!renamed) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to rename folder")
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDeleteSubfolderDialog(folder: FolderNode) {
|
||||||
|
uiState.value = uiState.value.copy(dialog = GalleryDialog.DeleteSubfolder(folder))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSubfolder(folder: FolderNode) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val deleted = deleteEntryUseCase(folder.uri)
|
||||||
|
dismissDialog()
|
||||||
|
if (!deleted) {
|
||||||
|
uiState.value = uiState.value.copy(error = "Failed to delete folder")
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
uiState.value = uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCameraFabPosition(xFraction: Float, yFraction: Float) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preferencesRepository.setCameraFabPosition(xFraction, yFraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Photo.toShareFileRequest(): ShareFileRequest {
|
||||||
|
return ShareFileRequest(
|
||||||
|
uri = uri,
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType.ifBlank { "application/octet-stream" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.withOriginalExtensionIfMissing(originalName: String): String {
|
||||||
|
val trimmed = trim()
|
||||||
|
if (trimmed.isBlank() || '.' in trimmed.substringAfterLast('/')) return trimmed
|
||||||
|
val extension = originalName.substringAfterLast('.', missingDelimiterValue = "")
|
||||||
|
return if (extension.isBlank()) trimmed else "$trimmed.$extension"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package com.example.onecamera.ui.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.example.onecamera.R
|
||||||
|
import com.example.onecamera.ui.components.OneCameraContentMaxWidth
|
||||||
|
import com.example.onecamera.ui.components.OneCameraIconBadge
|
||||||
|
import com.example.onecamera.ui.components.OneCameraScreenPadding
|
||||||
|
import com.example.onecamera.ui.components.oneCameraTopAppBarColors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val preferences by viewModel.preferences.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val versionName = remember(context) { context.appVersionName() }
|
||||||
|
|
||||||
|
val folderPicker = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
it,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
viewModel.setRootFolderUri(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.action_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = oneCameraTopAppBarColors(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.widthIn(max = OneCameraContentMaxWidth)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.TopCenter),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
start = OneCameraScreenPadding,
|
||||||
|
top = innerPadding.calculateTopPadding() + 8.dp,
|
||||||
|
end = OneCameraScreenPadding,
|
||||||
|
bottom = innerPadding.calculateBottomPadding() + 24.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_storage_section)) {
|
||||||
|
SettingsActionItem(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_folder),
|
||||||
|
headline = stringResource(R.string.settings_root_folder_title),
|
||||||
|
supportingText = preferences.rootFolderUri
|
||||||
|
?.toUri()
|
||||||
|
?.displayName()
|
||||||
|
?: stringResource(R.string.settings_root_folder_not_selected),
|
||||||
|
onClick = { folderPicker.launch(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_export_section)) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_folder_zip),
|
||||||
|
headline = stringResource(R.string.settings_zip_compression_title),
|
||||||
|
supportingText = stringResource(R.string.settings_zip_compression_supporting),
|
||||||
|
checked = preferences.zipJpegCompression,
|
||||||
|
onCheckedChange = viewModel::setZipJpegCompression,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_appearance_section)) {
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_animation),
|
||||||
|
headline = stringResource(R.string.settings_screen_transitions_title),
|
||||||
|
supportingText = stringResource(R.string.settings_screen_transitions_supporting),
|
||||||
|
checked = preferences.screenTransitionsEnabled,
|
||||||
|
onCheckedChange = viewModel::setScreenTransitionsEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SettingsSection(title = stringResource(R.string.settings_about_section)) {
|
||||||
|
SettingsInfoItem(
|
||||||
|
icon = painterResource(R.drawable.ic_symbol_info),
|
||||||
|
headline = stringResource(R.string.settings_version_title),
|
||||||
|
supportingText = versionName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSection(
|
||||||
|
title: String,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column(content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsActionItem(
|
||||||
|
icon: Painter,
|
||||||
|
headline: String,
|
||||||
|
supportingText: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
OneCameraIconBadge(icon)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_symbol_keyboard_arrow_right),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.clickable(role = Role.Button, onClick = onClick),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = headline,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsInfoItem(
|
||||||
|
icon: Painter,
|
||||||
|
headline: String,
|
||||||
|
supportingText: String,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
OneCameraIconBadge(icon)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = headline,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSwitchItem(
|
||||||
|
icon: Painter,
|
||||||
|
headline: String,
|
||||||
|
supportingText: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
|
||||||
|
verticalAlignment = CenterVertically,
|
||||||
|
leadingContent = {
|
||||||
|
OneCameraIconBadge(icon)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.clickable(role = Role.Switch) { onCheckedChange(!checked) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = headline,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.displayName(): String {
|
||||||
|
return lastPathSegment
|
||||||
|
?.substringAfterLast(':')
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
?: toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun android.content.Context.appVersionName(): String {
|
||||||
|
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
packageManager.getPackageInfo(
|
||||||
|
packageName,
|
||||||
|
PackageManager.PackageInfoFlags.of(0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageManager.getPackageInfo(packageName, 0)
|
||||||
|
}
|
||||||
|
return packageInfo.versionName.orEmpty()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.example.onecamera.ui.settings
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.onecamera.data.preferences.UserPreferences
|
||||||
|
import com.example.onecamera.data.preferences.UserPreferencesRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val preferencesRepository: UserPreferencesRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val preferences: StateFlow<UserPreferences> = preferencesRepository.userPreferencesFlow
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserPreferences())
|
||||||
|
|
||||||
|
fun setRootFolderUri(uri: Uri) {
|
||||||
|
viewModelScope.launch { preferencesRepository.setRootFolderUri(uri.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setZipJpegCompression(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { preferencesRepository.setZipJpegCompression(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setScreenTransitionsEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { preferencesRepository.setScreenTransitionsEnabled(enabled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.onecamera.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.onecamera.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val OneCameraShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(8.dp),
|
||||||
|
small = RoundedCornerShape(12.dp),
|
||||||
|
medium = RoundedCornerShape(16.dp),
|
||||||
|
large = RoundedCornerShape(24.dp),
|
||||||
|
extraLarge = RoundedCornerShape(28.dp),
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.onecamera.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun OneCameraTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
shapes = OneCameraShapes,
|
||||||
|
|
||||||
|
motionScheme = MotionScheme.expressive(),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.onecamera.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M 0 0 h 108 v 108 h -108 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="3.375"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 43.453125 32.90625 L 64.546875 32.90625 a 10.546875 10.546875 0 0 1 10.546875 10.546875 L 75.09375 64.546875 a 10.546875 10.546875 0 0 1 -10.546875 10.546875 L 43.453125 75.09375 a 10.546875 10.546875 0 0 1 -10.546875 -10.546875 L 32.90625 43.453125 a 10.546875 10.546875 0 0 1 10.546875 -10.546875 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:strokeColor="#000000"
|
||||||
|
android:strokeWidth="3.375"
|
||||||
|
android:pathData="M 41.34375 54 a 12.65625 12.65625 0 1 0 25.3125 0 a 12.65625 12.65625 0 1 0 -25.3125 0 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#d94e47"
|
||||||
|
android:pathData="M 68.71875 38.390625 a 2.53125 2.53125 0 1 0 5.0625 0 a 2.53125 2.53125 0 1 0 -5.0625 0 Z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="3.375"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M 43.453125 32.90625 L 64.546875 32.90625 a 10.546875 10.546875 0 0 1 10.546875 10.546875 L 75.09375 64.546875 a 10.546875 10.546875 0 0 1 -10.546875 10.546875 L 43.453125 75.09375 a 10.546875 10.546875 0 0 1 -10.546875 -10.546875 L 32.90625 43.453125 a 10.546875 10.546875 0 0 1 10.546875 -10.546875 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="3.375"
|
||||||
|
android:pathData="M 41.34375 54 a 12.65625 12.65625 0 1 0 25.3125 0 a 12.65625 12.65625 0 1 0 -25.3125 0 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M 67.5 38.390625 a 2.109375 2.109375 0 1 0 4.21875 0 a 2.109375 2.109375 0 1 0 -4.21875 0 Z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M360-80q-58 0-109-22t-89-60q-38-38-60-89T80-360q0-81 42-148t110-102q20-39 49.5-68.5T350-728q33-68 101-110t149-42q58 0 109 22t89 60q38 38 60 89t22 109q0 85-42 150T728-350q-20 39-49.5 68.5T610-232q-35 68-102 110T360-80Zm0-80q33 0 63.5-10t56.5-30q-58 0-109-22t-89-60q-38-38-60-89t-22-109q-20 26-30 56.5T160-360q0 42 16 78t43 63q27 27 63 43t78 16Zm120-120q33 0 64.5-10t57.5-30q-59 0-110-22.5T403-403q-38-38-60.5-89T320-602q-20 26-30 57.5T280-480q0 42 15.5 78t43.5 63q27 28 63 43.5t78 15.5Zm120-120q18 0 34.5-3t33.5-9q22-60 6.5-115.5T621-621q-38-38-93.5-53.5T412-668q-6 17-9 33.5t-3 34.5q0 42 15.5 78t43.5 63q27 28 63 43.5t78 15.5Zm160-78q20-26 30-57.5t10-64.5q0-42-15.5-78T741-741q-27-28-63-43.5T600-800q-35 0-65.5 10T478-760q59 0 110 22.5t89 60.5q38 38 60.5 89T760-478Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-337 160-160 160 160 160-160 40 40v-183H200v263l40 40Zm-40 257h560v-264l-40-40-160 160-160-160-160 160-40-40v184Zm0 0v-264 80-376 560Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M792-56 686-160H260q-92 0-156-64T40-380q0-77 47.5-137T210-594q3-8 6-15.5t6-16.5L56-792l56-56 736 736-56 56ZM260-240h346L284-562q-2 11-3 21t-1 21h-20q-58 0-99 41t-41 99q0 58 41 99t99 41Zm185-161Zm419 191-58-56q17-14 25.5-32.5T840-340q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-27 0-52 6.5T380-693l-58-58q35-24 74.5-36.5T480-800q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 39-15 72.5T864-210ZM593-479Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M560-320h80v-80h80v-80h-80v-80h-80v80h-80v80h80v80ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m488-400-65 65 56 56 161-161-161-161-56 56 65 65H320v80h168ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M640-480v-80h80v80h-80Zm0 80h-80v-80h80v80Zm0 80v-80h80v80h-80ZM447-640l-80-80H160v480h400v-80h80v80h160v-400H640v80h-80v-80H447ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80v-480 480Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M120-520v-320h320v320H120Zm0 400v-320h320v320H120Zm400-400v-320h320v320H520Zm0 400v-320h320v320H520ZM200-600h160v-160H200v160Zm400 0h160v-160H600v160Zm0 400h160v-160H600v160Zm-400 0h160v-160H200v160Zm400-400Zm0 240Zm-240 0Zm0-240Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M480-260q75 0 127.5-52.5T660-440q0-75-52.5-127.5T480-620q-75 0-127.5 52.5T300-440q0 75 52.5 127.5T480-260Zm0-80q-42 0-71-29t-29-71q0-42 29-71t71-29q42 0 71 29t29 71q0 42-29 71t-71 29ZM160-120q-33 0-56.5-23.5T80-200v-480q0-33 23.5-56.5T160-760h126l74-80h240l74 80h126q33 0 56.5 23.5T880-680v480q0 33-23.5 56.5T800-120H160Zm0-80h640v-480H638l-73-80H395l-73 80H160v480Zm320-240Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M360-400h400L622-580l-92 120-62-80-108 140Zm-40 160q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320Zm0-80h480v-480H320v480ZM160-80q-33 0-56.5-23.5T80-160v-560h80v560h560v80H160Zm160-720v480-480Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M480-280q83 0 141.5-58.5T680-480q0-83-58.5-141.5T480-680q-83 0-141.5 58.5T280-480q0 83 58.5 141.5T480-280Zm0 200q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm480-280q17 0 28.5-11.5T720-760q0-17-11.5-28.5T680-800q-17 0-28.5 11.5T640-760q0 17 11.5 28.5T680-720Zm0 520ZM200-480Zm480-280Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<group android:translateY="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M360-240h440v-107H360v107ZM160-613h120v-107H160v107Zm0 187h120v-107H160v107Zm0 186h120v-107H160v107Zm200-186h440v-107H360v107Zm0-187h440v-107H360v107ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3,5h4v2L3,7zM3,10h10v2L3,12zM3,15h16v2L3,17z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3,5h16v2L3,7zM3,10h10v2L3,12zM3,15h4v2L3,17z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 982 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user