Initial commit

This commit is contained in:
OneWay committed 2026-10-13 10:17:03 +08:00
commit 9910ba6917
132 files changed
+53909

No files matched your search

+21
View File
@@ -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.
+73
View File
@@ -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> &bull;
<a href="#capture-and-storage">Capture &amp; storage</a> &bull;
<a href="#responsiveness">Responsiveness</a> &bull;
<a href="#tech-stack">Tech stack</a> &bull;
<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)
+108
View File
@@ -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)
}
+15
View File
@@ -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>
+10
View File
@@ -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.**
+43
View File
@@ -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
}
@@ -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