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