commit 7b6b0bfd700f928870eb719c531fc1ced4f12c48
parent e6e75123f85f21e4405769ff97c7712505be533e
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat,  5 Aug 2023 19:34:31 +0200

feat: download section

Diffstat:
Mapp/build.gradle.kts | 5++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt | 39+++++++++++++++++++--------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt | 2++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt | 8++++++--
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/download/DownloadSection.kt | 13-------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt | 9++++-----
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt | 4++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt | 5++---
Rcore/src/main/res/drawable/bitmoji_blank.xml -> app/src/main/res/drawable/bitmoji_blank.xml | 0
Mcore/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt | 42++++++++++++++++++++++--------------------
Acore/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadObject.kt | 32++++++++++++++++++++++++++++++++
Dcore/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt | 33---------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 8++++----
Mcore/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt | 10+++++-----
Mcore/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt | 6+++---
Mcore/src/main/kotlin/me/rhunk/snapenhance/ui/download/MediaFilter.kt | 16++++++++--------
Mcore/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt | 4+---
Mgradle/libs.versions.toml | 10+++++++---
19 files changed, 418 insertions(+), 125 deletions(-)

diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -91,13 +91,16 @@ android { dependencies { implementation(project(":core")) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.ripple) implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material3) - implementation(libs.androidx.material) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.documentfile) implementation(libs.gson) + implementation(libs.coil.compose) + implementation(libs.coil.video) debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -16,16 +16,15 @@ import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.download.data.DownloadMediaType import me.rhunk.snapenhance.download.data.DownloadMetadata +import me.rhunk.snapenhance.download.data.DownloadObject import me.rhunk.snapenhance.download.data.DownloadRequest import me.rhunk.snapenhance.download.data.DownloadStage import me.rhunk.snapenhance.download.data.InputMedia import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair -import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import java.io.File @@ -60,7 +59,7 @@ class DownloadProcessor ( ) { private val translation by lazy { - SharedContext.translation.getCategory("download_processor") + remoteSideContext.translation.getCategory("download_processor") } private val gson by lazy { @@ -118,7 +117,7 @@ class DownloadProcessor ( } @SuppressLint("UnspecifiedRegisterReceiverFlag") - private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { + private suspend fun saveMediaToGallery(inputFile: File, downloadObject: DownloadObject) { if (coroutineContext.job.isCancelled) return runCatching { @@ -128,12 +127,12 @@ class DownloadProcessor ( return } - val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension + val fileName = downloadObject.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get())) ?: throw Exception("Failed to open output folder") - val outputFileFolder = pendingDownload.metadata.outputPath.let { + val outputFileFolder = downloadObject.metadata.outputPath.let { if (it.contains("/")) { it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> folder.findFile(name) ?: folder.createDirectory(name)!! @@ -150,8 +149,8 @@ class DownloadProcessor ( inputStream.copyTo(outputStream) } - pendingDownload.outputFile = outputFile.uri.toString() - pendingDownload.downloadStage = DownloadStage.SAVED + downloadObject.outputFile = outputFile.uri.toString() + downloadObject.downloadStage = DownloadStage.SAVED runCatching { val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") @@ -167,7 +166,7 @@ class DownloadProcessor ( }.onFailure { exception -> Logger.error(exception) callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message) - pendingDownload.downloadStage = DownloadStage.FAILED + downloadObject.downloadStage = DownloadStage.FAILED } } @@ -226,13 +225,13 @@ class DownloadProcessor ( downloadedMedias } - private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { + private suspend fun downloadRemoteMedia(downloadObjectObject: DownloadObject, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { downloadRequest.inputMedias.first().let { inputMedia -> val mediaType = inputMedia.type val media = downloadedMedias[inputMedia]!! if (!downloadRequest.isDashPlaylist) { - saveMediaToGallery(media.file, pendingDownloadObject) + saveMediaToGallery(media.file, downloadObjectObject) media.file.delete() return } @@ -261,12 +260,12 @@ class DownloadProcessor ( output = outputFile, startTime = dashOptions.offsetTime, duration = dashOptions.duration) - saveMediaToGallery(outputFile, pendingDownloadObject) + saveMediaToGallery(outputFile, downloadObjectObject) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure Logger.error(exception) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) - pendingDownloadObject.downloadStage = DownloadStage.FAILED + downloadObjectObject.downloadStage = DownloadStage.FAILED } dashPlaylistFile.delete() @@ -297,11 +296,11 @@ class DownloadProcessor ( return@launch } - val pendingDownloadObject = PendingDownload( + val downloadObjectObject = DownloadObject( metadata = downloadMetadata ).apply { downloadTaskManager = remoteSideContext.downloadTaskManager } - pendingDownloadObject.also { + downloadObjectObject.also { remoteSideContext.downloadTaskManager.addTask(it) }.apply { job = coroutineContext.job @@ -345,7 +344,7 @@ class DownloadProcessor ( val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) runCatching { callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension)) - pendingDownloadObject.downloadStage = DownloadStage.MERGING + downloadObjectObject.downloadStage = DownloadStage.MERGING MediaDownloaderHelper.mergeOverlayFile( media = renamedMedia, @@ -353,12 +352,12 @@ class DownloadProcessor ( output = mergedOverlay ) - saveMediaToGallery(mergedOverlay, pendingDownloadObject) + saveMediaToGallery(mergedOverlay, downloadObjectObject) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure Logger.error(exception) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) - pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED + downloadObjectObject.downloadStage = DownloadStage.MERGE_FAILED } mergedOverlay.delete() @@ -367,9 +366,9 @@ class DownloadProcessor ( return@launch } - downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) + downloadRemoteMedia(downloadObjectObject, downloadedMedias, downloadRequest) }.onFailure { exception -> - pendingDownloadObject.downloadStage = DownloadStage.FAILED + downloadObjectObject.downloadStage = DownloadStage.FAILED Logger.error(exception) callbackOnFailure(translation["failed_generic_toast"], exception.message) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt @@ -71,6 +71,8 @@ class Navigation( Icon(Icons.Filled.ArrowBack, contentDescription = null) } } + }, actions = { + currentSection.TopBarActions(this) }) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.ui.manager +import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Download @@ -14,7 +15,7 @@ import androidx.navigation.compose.composable import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.ui.manager.sections.HomeSection import me.rhunk.snapenhance.ui.manager.sections.NotImplemented -import me.rhunk.snapenhance.ui.manager.sections.download.DownloadSection +import me.rhunk.snapenhance.ui.manager.sections.downloads.DownloadsSection import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection import kotlin.reflect.KClass @@ -26,7 +27,7 @@ enum class EnumSection( DOWNLOADS( route = "downloads", icon = Icons.Filled.Download, - section = DownloadSection::class + section = DownloadsSection::class ), FEATURES( route = "features", @@ -70,6 +71,9 @@ open class Section { @Composable open fun Content() { NotImplemented() } + @Composable + open fun TopBarActions(rowScope: RowScope) {} + open fun build(navGraphBuilder: NavGraphBuilder) { navGraphBuilder.composable(enumSection.route) { Content() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/download/DownloadSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/download/DownloadSection.kt @@ -1,12 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.download - -import androidx.compose.runtime.Composable -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.manager.sections.NotImplemented - -class DownloadSection : Section() { - @Composable - override fun Content() { - NotImplemented() - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -0,0 +1,296 @@ +package me.rhunk.snapenhance.ui.manager.sections.downloads + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.ImageLoader +import coil.compose.rememberAsyncImagePainter +import coil.decode.VideoFrameDecoder +import coil.memory.MemoryCache +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.download.data.DownloadObject +import me.rhunk.snapenhance.ui.download.MediaFilter +import me.rhunk.snapenhance.ui.manager.Section + +class DownloadsSection : Section() { + private val loadedDownloads = mutableStateOf(mapOf<Int, DownloadObject>()) + private var currentFilter = mutableStateOf(MediaFilter.NONE) + + private val imageLoader by lazy { + ImageLoader.Builder(context.androidContext) + .dispatcher(Dispatchers.IO) + .memoryCache { + MemoryCache.Builder(context.androidContext) + .maxSizePercent(0.25) + .build() + }.components { add(VideoFrameDecoder.Factory()) }.build() + } + + override fun onResumed() { + super.onResumed() + loadByFilter(currentFilter.value) + } + + private fun loadByFilter(filter: MediaFilter) { + this.currentFilter.value = filter + synchronized(loadedDownloads) { + loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter) + } + } + + private fun lazyLoadFromIndex(lastIndex: Int) { + synchronized(loadedDownloads) { + loadedDownloads.value = loadedDownloads.value.toMutableMap().also { + val lastVisible = loadedDownloads.value.values.elementAt(lastIndex) + it += context.downloadTaskManager.queryTasks( + from = lastVisible.downloadId, + filter = currentFilter.value + ) + } + } + } + + @Composable + private fun FilterList() { + val coroutineScope = rememberCoroutineScope() + val showMenu = remember { mutableStateOf(false) } + IconButton(onClick = { showMenu.value = !showMenu.value}) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null + ) + } + + DropdownMenu(expanded = showMenu.value, onDismissRequest = { showMenu.value = false }) { + MediaFilter.values().toList().forEach { filter -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + modifier = Modifier.padding(end = 16.dp), + selected = (currentFilter.value == filter), + onClick = null + ) + Text(filter.name, modifier = Modifier.weight(1f)) + } + }, + onClick = { + coroutineScope.launch { + loadByFilter(filter) + showMenu.value = false + } + } + ) + } + } + } + + @Composable + override fun TopBarActions(rowScope: RowScope) { + FilterList() + } + + @Composable + private fun DownloadItem(download: DownloadObject) { + Card( + modifier = Modifier + .padding(6.dp) + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + ) { + Box(modifier = Modifier.height(120.dp)) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context.androidContext) + .data(download.outputFile) + .memoryCacheKey(download.outputFile) + .build(), + imageLoader = imageLoader + ), + modifier = Modifier + .matchParentSize() + .blur(5.dp), + contentDescription = null, + contentScale = ContentScale.FillWidth + ) + + Row( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp) + .fillMaxWidth() + .fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ){ + //info card + Row( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.background, + shape = MaterialTheme.shapes.medium + ) + .padding(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context.androidContext) + .data(download.metadata.iconUrl) + .fallback(R.drawable.bitmoji_blank) + .memoryCacheKey(download.metadata.iconUrl) + .build(), + imageLoader = imageLoader + ), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredWidthIn(min = 0.dp, max = 48.dp) + .height(48.dp) + .clip(MaterialTheme.shapes.medium) + ) + + Column( + modifier = Modifier + .padding(start = 10.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = download.metadata.mediaDisplayType ?: "", + overflow = TextOverflow.Ellipsis, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = download.metadata.mediaDisplaySource ?: "", + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + //action buttons + Row( + modifier = Modifier + .padding(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledIconButton( + onClick = { + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null + ) + } + //open + FilledIconButton(onClick = { + val fileType = runCatching { + context.androidContext.contentResolver.openInputStream(Uri.parse(download.outputFile))?.use { input -> + FileType.fromInputStream(input) + } + }.getOrNull() ?: FileType.UNKNOWN + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse(download.outputFile), fileType.mimeType) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + context.androidContext.startActivity(intent) + }) { + Icon( + imageVector = Icons.Default.OpenInNew, + contentDescription = null + ) + } + } + } + } + } + } + + @Composable + override fun Content() { + val scrollState = rememberLazyListState() + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize() + ) { + items(loadedDownloads.value.size) { index -> + DownloadItem(loadedDownloads.value.values.elementAt(index)) + } + + item { + Spacer(Modifier.height(20.dp)) + if (loadedDownloads.value.isEmpty()) { + Text(text = "No downloads", fontSize = 20.sp, modifier = Modifier + .fillMaxWidth() + .padding(10.dp), textAlign = TextAlign.Center) + } + LaunchedEffect(true) { + val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect + lazyLoadFromIndex(lastItemIndex) + scrollState.animateScrollToItem(lastItemIndex) + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.OpenInNew @@ -30,6 +29,7 @@ import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Switch @@ -46,7 +46,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation @@ -238,7 +237,7 @@ class FeaturesSection : Section() { .height(50.dp) .width(1.dp) .background( - color = MaterialTheme.colors.onBackground.copy(alpha = 0.12f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), shape = RoundedCornerShape(5.dp) )) } @@ -329,8 +328,8 @@ class FeaturesSection : Section() { } }, modifier = Modifier.padding(10.dp), - containerColor = MaterialTheme.colors.primary, - contentColor = MaterialTheme.colors.onPrimary, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, shape = RoundedCornerShape(16.dp), ) { Icon( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -87,7 +87,7 @@ class MappingsScreen : SetupScreen() { CircularProgressIndicator( modifier = Modifier.padding().size(30.dp), strokeWidth = 3.dp, - color = MaterialTheme.colors.onPrimary + color = MaterialTheme.colorScheme.onPrimary ) } else { Text(text = context.translation["setup.mappings.generate_button"]) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt @@ -10,10 +10,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -86,7 +86,6 @@ class PickLanguageScreen : SetupScreen(){ modifier = Modifier .padding(10.dp) .fillMaxWidth(), - elevation = 8.dp, shape = MaterialTheme.shapes.medium ) { LazyColumn( diff --git a/core/src/main/res/drawable/bitmoji_blank.xml b/app/src/main/res/drawable/bitmoji_blank.xml diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -4,15 +4,17 @@ import android.annotation.SuppressLint import android.content.Context import android.database.sqlite.SQLiteDatabase import me.rhunk.snapenhance.download.data.DownloadMetadata -import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.data.DownloadObject import me.rhunk.snapenhance.download.data.DownloadStage import me.rhunk.snapenhance.ui.download.MediaFilter import me.rhunk.snapenhance.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.util.getIntOrNull +import me.rhunk.snapenhance.util.getStringOrNull class DownloadTaskManager { private lateinit var taskDatabase: SQLiteDatabase - private val pendingTasks = mutableMapOf<Int, PendingDownload>() - private val cachedTasks = mutableMapOf<Int, PendingDownload>() + private val pendingTasks = mutableMapOf<Int, DownloadObject>() + private val cachedTasks = mutableMapOf<Int, DownloadObject>() @SuppressLint("Range") fun init(context: Context) { @@ -33,7 +35,7 @@ class DownloadTaskManager { } } - fun addTask(task: PendingDownload): Int { + fun addTask(task: DownloadObject): Int { taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", arrayOf( task.metadata.mediaIdentifier, @@ -53,7 +55,7 @@ class DownloadTaskManager { return task.downloadId } - fun updateTask(task: PendingDownload) { + fun updateTask(task: DownloadObject) { taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", arrayOf( task.metadata.mediaIdentifier, @@ -107,13 +109,13 @@ class DownloadTaskManager { pendingTasks.remove(id) } - fun removeTask(task: PendingDownload) { + fun removeTask(task: DownloadObject) { removeTask(task.downloadId) } - fun queryAllTasks(filter: MediaFilter): Map<Int, PendingDownload> { + fun queryFirstTasks(filter: MediaFilter): Map<Int, DownloadObject> { val isPendingFilter = filter == MediaFilter.PENDING - val tasks = mutableMapOf<Int, PendingDownload>() + val tasks = mutableMapOf<Int, DownloadObject>() tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) }) if (isPendingFilter) { @@ -130,7 +132,7 @@ class DownloadTaskManager { } @SuppressLint("Range") - fun queryTasks(from: Int, amount: Int = 20, filter: MediaFilter = MediaFilter.NONE): Map<Int, PendingDownload> { + fun queryTasks(from: Int, amount: Int = 30, filter: MediaFilter = MediaFilter.NONE): Map<Int, DownloadObject> { if (filter == MediaFilter.PENDING) { return emptyMap() } @@ -139,27 +141,27 @@ class DownloadTaskManager { "SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?", arrayOf( from.toString(), - filter.mediaDisplayType.let { if (it == null) "%" else "%$it" }, + if (filter.shouldIgnoreFilter) "%" else "%${filter.key}", amount.toString() ) ) - val result = sortedMapOf<Int, PendingDownload>() + val result = sortedMapOf<Int, DownloadObject>() while (cursor.moveToNext()) { - val task = PendingDownload( - downloadId = cursor.getInt(cursor.getColumnIndex("id")), - outputFile = cursor.getString(cursor.getColumnIndex("outputFile")), + val task = DownloadObject( + downloadId = cursor.getIntOrNull("id")!!, + outputFile = cursor.getStringOrNull("outputFile"), metadata = DownloadMetadata( - outputPath = cursor.getString(cursor.getColumnIndex("outputPath")), - mediaIdentifier = cursor.getString(cursor.getColumnIndex("hash")), - mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")), - mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")), - iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) + outputPath = cursor.getStringOrNull("outputPath")!!, + mediaIdentifier = cursor.getStringOrNull("hash"), + mediaDisplayType = cursor.getStringOrNull("mediaDisplayType"), + mediaDisplaySource = cursor.getStringOrNull("mediaDisplaySource"), + iconUrl = cursor.getStringOrNull("iconUrl") ) ).apply { downloadTaskManager = this@DownloadTaskManager - downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage"))) + downloadStage = DownloadStage.valueOf(cursor.getStringOrNull("downloadStage")!!) //if downloadStage is not saved, it means the app was killed before the download was finished if (downloadStage != DownloadStage.SAVED) { downloadStage = DownloadStage.FAILED diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadObject.kt @@ -0,0 +1,32 @@ +package me.rhunk.snapenhance.download.data + +import kotlinx.coroutines.Job +import me.rhunk.snapenhance.download.DownloadTaskManager + +data class DownloadObject( + var downloadId: Int = 0, + var outputFile: String? = null, + val metadata : DownloadMetadata +) { + lateinit var downloadTaskManager: DownloadTaskManager + var job: Job? = null + + var changeListener = { _: DownloadStage, _: DownloadStage -> } + private var _stage: DownloadStage = DownloadStage.PENDING + var downloadStage: DownloadStage + get() = synchronized(this) { + _stage + } + set(value) = synchronized(this) { + changeListener(_stage, value) + _stage = value + downloadTaskManager.updateTask(this) + } + + fun isJobActive() = job?.isActive == true + + fun cancel() { + downloadStage = DownloadStage.CANCELLED + job?.cancel() + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt b/core/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt @@ -1,33 +0,0 @@ -package me.rhunk.snapenhance.download.data - -import kotlinx.coroutines.Job -import me.rhunk.snapenhance.SharedContext -import me.rhunk.snapenhance.download.DownloadTaskManager - -data class PendingDownload( - var downloadId: Int = 0, - var outputFile: String? = null, - val metadata : DownloadMetadata -) { - lateinit var downloadTaskManager: DownloadTaskManager - var job: Job? = null - - var changeListener = { _: DownloadStage, _: DownloadStage -> } - private var _stage: DownloadStage = DownloadStage.PENDING - var downloadStage: DownloadStage - get() = synchronized(this) { - _stage - } - set(value) = synchronized(this) { - changeListener(_stage, value) - _stage = value - downloadTaskManager.updateTask(this) - } - - fun isJobActive() = job?.isActive == true - - fun cancel() { - downloadStage = DownloadStage.CANCELLED - job?.cancel() - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -242,7 +242,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam pathSuffix = authorUsername, mediaIdentifier = "$conversationId$senderId${conversationMessage.server_message_id}", mediaDisplaySource = authorUsername, - mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, + mediaDisplayType = MediaFilter.CHAT_MEDIA.key, friendInfo = author ), mediaInfoMap) @@ -282,7 +282,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam pathSuffix = authorName, mediaIdentifier = paramMap["MEDIA_ID"].toString(), mediaDisplaySource = authorName, - mediaDisplayType = MediaFilter.STORY.mediaDisplayType, + mediaDisplayType = MediaFilter.STORY.key, friendInfo = author ), mediaInfoMap) return @@ -311,7 +311,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam downloadOperaMedia(provideDownloadManagerClient( pathSuffix = "Spotlight", mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaDisplayType = MediaFilter.SPOTLIGHT.mediaDisplayType, + mediaDisplayType = MediaFilter.SPOTLIGHT.key, mediaDisplaySource = paramMap["TIME_STAMP"].toString() ), mediaInfoMap) return @@ -476,7 +476,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam pathSuffix = authorName, mediaIdentifier = "${message.client_conversation_id}${message.sender_id}${message.server_message_id}", mediaDisplaySource = authorName, - mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, + mediaDisplayType = MediaFilter.CHAT_MEDIA.key, friendInfo = friendInfo ).downloadSingleMedia( Base64.UrlSafe.encode(urlProto), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt @@ -26,7 +26,7 @@ import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.core.R import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.data.DownloadObject import me.rhunk.snapenhance.download.data.DownloadStage import me.rhunk.snapenhance.util.snap.PreviewUtils import java.io.File @@ -37,7 +37,7 @@ import kotlin.coroutines.coroutineContext class DownloadListAdapter( private val activity: DownloadManagerActivity, - private val downloadList: MutableList<PendingDownload> + private val downloadList: MutableList<DownloadObject> ): Adapter<DownloadListAdapter.ViewHolder>() { private val coroutineScope = CoroutineScope(Dispatchers.IO) private val previewJobs = mutableMapOf<Int, Job>() @@ -68,7 +68,7 @@ class DownloadListAdapter( } @SuppressLint("Recycle") - private suspend fun handlePreview(download: PendingDownload, holder: ViewHolder) { + private suspend fun handlePreview(download: DownloadObject, holder: ViewHolder) { download.outputFile?.let { val uri = Uri.parse(it) runCatching { @@ -124,7 +124,7 @@ class DownloadListAdapter( } } - private fun updateViewHolder(download: PendingDownload, holder: ViewHolder) { + private fun updateViewHolder(download: DownloadObject, holder: ViewHolder) { holder.status.text = download.downloadStage.toString() holder.view.background = holder.view.context.getDrawable(R.drawable.download_manager_item_background) @@ -163,7 +163,7 @@ class DownloadListAdapter( } } - holder.bitmojiIcon.setImageResource(R.drawable.bitmoji_blank) + // holder.bitmojiIcon.setImageResource(R.drawable.bitmoji_blank) pendingDownload.metadata.iconUrl?.let { url -> thread(start = true) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt @@ -19,13 +19,13 @@ import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.R -import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.data.DownloadObject class DownloadManagerActivity : Activity() { lateinit var translation: LocaleWrapper private val backCallbacks = mutableListOf<() -> Unit>() - private val fetchedDownloadTasks = mutableListOf<PendingDownload>() + private val fetchedDownloadTasks = mutableListOf<DownloadObject>() private var listFilter = MediaFilter.NONE private val preferences by lazy { @@ -42,7 +42,7 @@ class DownloadManagerActivity : Activity() { @SuppressLint("NotifyDataSetChanged") private fun updateListContent() { fetchedDownloadTasks.clear() - fetchedDownloadTasks.addAll(SharedContext.downloadTaskManager.queryAllTasks(filter = listFilter).values) + fetchedDownloadTasks.addAll(SharedContext.downloadTaskManager.queryFirstTasks(filter = listFilter).values) with(findViewById<RecyclerView>(R.id.download_list)) { adapter?.notifyDataSetChanged() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/download/MediaFilter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/download/MediaFilter.kt @@ -1,17 +1,17 @@ package me.rhunk.snapenhance.ui.download enum class MediaFilter( - val mediaDisplayType: String? = null + val key: String, + val shouldIgnoreFilter: Boolean = false ) { - NONE, - PENDING, - CHAT_MEDIA("Chat Media"), - STORY("Story"), - SPOTLIGHT("Spotlight"); + NONE("none", true), + PENDING("pending", true), + CHAT_MEDIA("chat_media"), + STORY("story"), + SPOTLIGHT("spotlight"); fun matches(source: String?): Boolean { - if (mediaDisplayType == null) return true if (source == null) return false - return source.contains(mediaDisplayType, ignoreCase = true) + return source.contains(key, ignoreCase = true) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt @@ -48,9 +48,7 @@ object PreviewUtils { setDataSource(file.absolutePath) }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) } else { - BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { - inSampleSize = 1 - }) + BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options()) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -1,11 +1,12 @@ [versions] agp = "8.2.0-alpha14" -androidx-material = "1.6.0-alpha02" +coil-compose = "2.4.0" junit = "4.13.2" kotlin = "1.8.22" kotlinx-coroutines-android = "1.7.2" kotlin-reflect = "1.8.22" -material-icons-extended = "1.6.0-alpha03" +material-icons-core = "1.4.3" +material-icons-extended = "1.6.0-alpha02" navigation-compose = "2.7.0-rc01" recyclerview = "1.3.1" gson = "2.10.1" @@ -19,9 +20,12 @@ material3 = "1.1.1" [libraries] -androidx-material = { module = "androidx.compose.material:material", version.ref = "androidx-material" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "material-icons-core" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" } +androidx-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "material-icons-core" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } +coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin-reflect" }