feat: download section

This commit is contained in:
rhunk
2023-08-05 19:34:31 +02:00
parent e6e75123f8
commit 7b6b0bfd70
18 changed files with 386 additions and 93 deletions

View File

@ -91,13 +91,16 @@ android {
dependencies { dependencies {
implementation(project(":core")) implementation(project(":core"))
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.material.ripple)
implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.material)
implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)
implementation(libs.gson) implementation(libs.gson)
implementation(libs.coil.compose)
implementation(libs.coil.video)
debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")

View File

@ -16,16 +16,15 @@ import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.DownloadMediaType import me.rhunk.snapenhance.download.data.DownloadMediaType
import me.rhunk.snapenhance.download.data.DownloadMetadata 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.DownloadRequest
import me.rhunk.snapenhance.download.data.DownloadStage import me.rhunk.snapenhance.download.data.DownloadStage
import me.rhunk.snapenhance.download.data.InputMedia import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair 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.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import java.io.File import java.io.File
@ -60,7 +59,7 @@ class DownloadProcessor (
) { ) {
private val translation by lazy { private val translation by lazy {
SharedContext.translation.getCategory("download_processor") remoteSideContext.translation.getCategory("download_processor")
} }
private val gson by lazy { private val gson by lazy {
@ -118,7 +117,7 @@ class DownloadProcessor (
} }
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { private suspend fun saveMediaToGallery(inputFile: File, downloadObject: DownloadObject) {
if (coroutineContext.job.isCancelled) return if (coroutineContext.job.isCancelled) return
runCatching { runCatching {
@ -128,12 +127,12 @@ class DownloadProcessor (
return 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())) val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get()))
?: throw Exception("Failed to open output folder") ?: throw Exception("Failed to open output folder")
val outputFileFolder = pendingDownload.metadata.outputPath.let { val outputFileFolder = downloadObject.metadata.outputPath.let {
if (it.contains("/")) { if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name) ?: folder.createDirectory(name)!! folder.findFile(name) ?: folder.createDirectory(name)!!
@ -150,8 +149,8 @@ class DownloadProcessor (
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
} }
pendingDownload.outputFile = outputFile.uri.toString() downloadObject.outputFile = outputFile.uri.toString()
pendingDownload.downloadStage = DownloadStage.SAVED downloadObject.downloadStage = DownloadStage.SAVED
runCatching { runCatching {
val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE")
@ -167,7 +166,7 @@ class DownloadProcessor (
}.onFailure { exception -> }.onFailure { exception ->
Logger.error(exception) Logger.error(exception)
callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message) 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 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 -> downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!! val media = downloadedMedias[inputMedia]!!
if (!downloadRequest.isDashPlaylist) { if (!downloadRequest.isDashPlaylist) {
saveMediaToGallery(media.file, pendingDownloadObject) saveMediaToGallery(media.file, downloadObjectObject)
media.file.delete() media.file.delete()
return return
} }
@ -261,12 +260,12 @@ class DownloadProcessor (
output = outputFile, output = outputFile,
startTime = dashOptions.offsetTime, startTime = dashOptions.offsetTime,
duration = dashOptions.duration) duration = dashOptions.duration)
saveMediaToGallery(outputFile, pendingDownloadObject) saveMediaToGallery(outputFile, downloadObjectObject)
}.onFailure { exception -> }.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure if (coroutineContext.job.isCancelled) return@onFailure
Logger.error(exception) Logger.error(exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingDownloadObject.downloadStage = DownloadStage.FAILED downloadObjectObject.downloadStage = DownloadStage.FAILED
} }
dashPlaylistFile.delete() dashPlaylistFile.delete()
@ -297,11 +296,11 @@ class DownloadProcessor (
return@launch return@launch
} }
val pendingDownloadObject = PendingDownload( val downloadObjectObject = DownloadObject(
metadata = downloadMetadata metadata = downloadMetadata
).apply { downloadTaskManager = remoteSideContext.downloadTaskManager } ).apply { downloadTaskManager = remoteSideContext.downloadTaskManager }
pendingDownloadObject.also { downloadObjectObject.also {
remoteSideContext.downloadTaskManager.addTask(it) remoteSideContext.downloadTaskManager.addTask(it)
}.apply { }.apply {
job = coroutineContext.job job = coroutineContext.job
@ -345,7 +344,7 @@ class DownloadProcessor (
val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension)
runCatching { runCatching {
callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension)) callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension))
pendingDownloadObject.downloadStage = DownloadStage.MERGING downloadObjectObject.downloadStage = DownloadStage.MERGING
MediaDownloaderHelper.mergeOverlayFile( MediaDownloaderHelper.mergeOverlayFile(
media = renamedMedia, media = renamedMedia,
@ -353,12 +352,12 @@ class DownloadProcessor (
output = mergedOverlay output = mergedOverlay
) )
saveMediaToGallery(mergedOverlay, pendingDownloadObject) saveMediaToGallery(mergedOverlay, downloadObjectObject)
}.onFailure { exception -> }.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure if (coroutineContext.job.isCancelled) return@onFailure
Logger.error(exception) Logger.error(exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED downloadObjectObject.downloadStage = DownloadStage.MERGE_FAILED
} }
mergedOverlay.delete() mergedOverlay.delete()
@ -367,9 +366,9 @@ class DownloadProcessor (
return@launch return@launch
} }
downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) downloadRemoteMedia(downloadObjectObject, downloadedMedias, downloadRequest)
}.onFailure { exception -> }.onFailure { exception ->
pendingDownloadObject.downloadStage = DownloadStage.FAILED downloadObjectObject.downloadStage = DownloadStage.FAILED
Logger.error(exception) Logger.error(exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message) callbackOnFailure(translation["failed_generic_toast"], exception.message)
} }

View File

@ -71,6 +71,8 @@ class Navigation(
Icon(Icons.Filled.ArrowBack, contentDescription = null) Icon(Icons.Filled.ArrowBack, contentDescription = null)
} }
} }
}, actions = {
currentSection.TopBarActions(this)
}) })
} }

View File

@ -1,5 +1,6 @@
package me.rhunk.snapenhance.ui.manager package me.rhunk.snapenhance.ui.manager
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Download 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.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.sections.HomeSection import me.rhunk.snapenhance.ui.manager.sections.HomeSection
import me.rhunk.snapenhance.ui.manager.sections.NotImplemented 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 me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
import kotlin.reflect.KClass import kotlin.reflect.KClass
@ -26,7 +27,7 @@ enum class EnumSection(
DOWNLOADS( DOWNLOADS(
route = "downloads", route = "downloads",
icon = Icons.Filled.Download, icon = Icons.Filled.Download,
section = DownloadSection::class section = DownloadsSection::class
), ),
FEATURES( FEATURES(
route = "features", route = "features",
@ -70,6 +71,9 @@ open class Section {
@Composable @Composable
open fun Content() { NotImplemented() } open fun Content() { NotImplemented() }
@Composable
open fun TopBarActions(rowScope: RowScope) {}
open fun build(navGraphBuilder: NavGraphBuilder) { open fun build(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable(enumSection.route) { navGraphBuilder.composable(enumSection.route) {
Content() Content()

View File

@ -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()
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.OpenInNew 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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Switch 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
@ -238,7 +237,7 @@ class FeaturesSection : Section() {
.height(50.dp) .height(50.dp)
.width(1.dp) .width(1.dp)
.background( .background(
color = MaterialTheme.colors.onBackground.copy(alpha = 0.12f), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
shape = RoundedCornerShape(5.dp) shape = RoundedCornerShape(5.dp)
)) ))
} }
@ -329,8 +328,8 @@ class FeaturesSection : Section() {
} }
}, },
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
containerColor = MaterialTheme.colors.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colors.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
Icon( Icon(

View File

@ -5,9 +5,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -87,7 +87,7 @@ class MappingsScreen : SetupScreen() {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding().size(30.dp), modifier = Modifier.padding().size(30.dp),
strokeWidth = 3.dp, strokeWidth = 3.dp,
color = MaterialTheme.colors.onPrimary color = MaterialTheme.colorScheme.onPrimary
) )
} else { } else {
Text(text = context.translation["setup.mappings.generate_button"]) Text(text = context.translation["setup.mappings.generate_button"])

View File

@ -10,10 +10,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -86,7 +86,6 @@ class PickLanguageScreen : SetupScreen(){
modifier = Modifier modifier = Modifier
.padding(10.dp) .padding(10.dp)
.fillMaxWidth(), .fillMaxWidth(),
elevation = 8.dp,
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) { ) {
LazyColumn( LazyColumn(

View File

@ -4,15 +4,17 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.download.data.DownloadMetadata 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.download.data.DownloadStage
import me.rhunk.snapenhance.ui.download.MediaFilter import me.rhunk.snapenhance.ui.download.MediaFilter
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.getIntOrNull
import me.rhunk.snapenhance.util.getStringOrNull
class DownloadTaskManager { class DownloadTaskManager {
private lateinit var taskDatabase: SQLiteDatabase private lateinit var taskDatabase: SQLiteDatabase
private val pendingTasks = mutableMapOf<Int, PendingDownload>() private val pendingTasks = mutableMapOf<Int, DownloadObject>()
private val cachedTasks = mutableMapOf<Int, PendingDownload>() private val cachedTasks = mutableMapOf<Int, DownloadObject>()
@SuppressLint("Range") @SuppressLint("Range")
fun init(context: Context) { 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 (?, ?, ?, ?, ?, ?, ?)", taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)",
arrayOf( arrayOf(
task.metadata.mediaIdentifier, task.metadata.mediaIdentifier,
@ -53,7 +55,7 @@ class DownloadTaskManager {
return task.downloadId 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 = ?", taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?",
arrayOf( arrayOf(
task.metadata.mediaIdentifier, task.metadata.mediaIdentifier,
@ -107,13 +109,13 @@ class DownloadTaskManager {
pendingTasks.remove(id) pendingTasks.remove(id)
} }
fun removeTask(task: PendingDownload) { fun removeTask(task: DownloadObject) {
removeTask(task.downloadId) removeTask(task.downloadId)
} }
fun queryAllTasks(filter: MediaFilter): Map<Int, PendingDownload> { fun queryFirstTasks(filter: MediaFilter): Map<Int, DownloadObject> {
val isPendingFilter = filter == MediaFilter.PENDING 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) }) tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) })
if (isPendingFilter) { if (isPendingFilter) {
@ -130,7 +132,7 @@ class DownloadTaskManager {
} }
@SuppressLint("Range") @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) { if (filter == MediaFilter.PENDING) {
return emptyMap() return emptyMap()
} }
@ -139,27 +141,27 @@ class DownloadTaskManager {
"SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?", "SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?",
arrayOf( arrayOf(
from.toString(), from.toString(),
filter.mediaDisplayType.let { if (it == null) "%" else "%$it" }, if (filter.shouldIgnoreFilter) "%" else "%${filter.key}",
amount.toString() amount.toString()
) )
) )
val result = sortedMapOf<Int, PendingDownload>() val result = sortedMapOf<Int, DownloadObject>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val task = PendingDownload( val task = DownloadObject(
downloadId = cursor.getInt(cursor.getColumnIndex("id")), downloadId = cursor.getIntOrNull("id")!!,
outputFile = cursor.getString(cursor.getColumnIndex("outputFile")), outputFile = cursor.getStringOrNull("outputFile"),
metadata = DownloadMetadata( metadata = DownloadMetadata(
outputPath = cursor.getString(cursor.getColumnIndex("outputPath")), outputPath = cursor.getStringOrNull("outputPath")!!,
mediaIdentifier = cursor.getString(cursor.getColumnIndex("hash")), mediaIdentifier = cursor.getStringOrNull("hash"),
mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")), mediaDisplayType = cursor.getStringOrNull("mediaDisplayType"),
mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")), mediaDisplaySource = cursor.getStringOrNull("mediaDisplaySource"),
iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) iconUrl = cursor.getStringOrNull("iconUrl")
) )
).apply { ).apply {
downloadTaskManager = this@DownloadTaskManager 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 is not saved, it means the app was killed before the download was finished
if (downloadStage != DownloadStage.SAVED) { if (downloadStage != DownloadStage.SAVED) {
downloadStage = DownloadStage.FAILED downloadStage = DownloadStage.FAILED

View File

@ -1,10 +1,9 @@
package me.rhunk.snapenhance.download.data package me.rhunk.snapenhance.download.data
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.download.DownloadTaskManager
data class PendingDownload( data class DownloadObject(
var downloadId: Int = 0, var downloadId: Int = 0,
var outputFile: String? = null, var outputFile: String? = null,
val metadata : DownloadMetadata val metadata : DownloadMetadata

View File

@ -242,7 +242,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
pathSuffix = authorUsername, pathSuffix = authorUsername,
mediaIdentifier = "$conversationId$senderId${conversationMessage.server_message_id}", mediaIdentifier = "$conversationId$senderId${conversationMessage.server_message_id}",
mediaDisplaySource = authorUsername, mediaDisplaySource = authorUsername,
mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, mediaDisplayType = MediaFilter.CHAT_MEDIA.key,
friendInfo = author friendInfo = author
), mediaInfoMap) ), mediaInfoMap)
@ -282,7 +282,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
pathSuffix = authorName, pathSuffix = authorName,
mediaIdentifier = paramMap["MEDIA_ID"].toString(), mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaDisplaySource = authorName, mediaDisplaySource = authorName,
mediaDisplayType = MediaFilter.STORY.mediaDisplayType, mediaDisplayType = MediaFilter.STORY.key,
friendInfo = author friendInfo = author
), mediaInfoMap) ), mediaInfoMap)
return return
@ -311,7 +311,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
downloadOperaMedia(provideDownloadManagerClient( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Spotlight", pathSuffix = "Spotlight",
mediaIdentifier = paramMap["SNAP_ID"].toString(), mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = MediaFilter.SPOTLIGHT.mediaDisplayType, mediaDisplayType = MediaFilter.SPOTLIGHT.key,
mediaDisplaySource = paramMap["TIME_STAMP"].toString() mediaDisplaySource = paramMap["TIME_STAMP"].toString()
), mediaInfoMap) ), mediaInfoMap)
return return
@ -476,7 +476,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
pathSuffix = authorName, pathSuffix = authorName,
mediaIdentifier = "${message.client_conversation_id}${message.sender_id}${message.server_message_id}", mediaIdentifier = "${message.client_conversation_id}${message.sender_id}${message.server_message_id}",
mediaDisplaySource = authorName, mediaDisplaySource = authorName,
mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, mediaDisplayType = MediaFilter.CHAT_MEDIA.key,
friendInfo = friendInfo friendInfo = friendInfo
).downloadSingleMedia( ).downloadSingleMedia(
Base64.UrlSafe.encode(urlProto), Base64.UrlSafe.encode(urlProto),

View File

@ -26,7 +26,7 @@ import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.core.R import me.rhunk.snapenhance.core.R
import me.rhunk.snapenhance.data.FileType 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.download.data.DownloadStage
import me.rhunk.snapenhance.util.snap.PreviewUtils import me.rhunk.snapenhance.util.snap.PreviewUtils
import java.io.File import java.io.File
@ -37,7 +37,7 @@ import kotlin.coroutines.coroutineContext
class DownloadListAdapter( class DownloadListAdapter(
private val activity: DownloadManagerActivity, private val activity: DownloadManagerActivity,
private val downloadList: MutableList<PendingDownload> private val downloadList: MutableList<DownloadObject>
): Adapter<DownloadListAdapter.ViewHolder>() { ): Adapter<DownloadListAdapter.ViewHolder>() {
private val coroutineScope = CoroutineScope(Dispatchers.IO) private val coroutineScope = CoroutineScope(Dispatchers.IO)
private val previewJobs = mutableMapOf<Int, Job>() private val previewJobs = mutableMapOf<Int, Job>()
@ -68,7 +68,7 @@ class DownloadListAdapter(
} }
@SuppressLint("Recycle") @SuppressLint("Recycle")
private suspend fun handlePreview(download: PendingDownload, holder: ViewHolder) { private suspend fun handlePreview(download: DownloadObject, holder: ViewHolder) {
download.outputFile?.let { download.outputFile?.let {
val uri = Uri.parse(it) val uri = Uri.parse(it)
runCatching { 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.status.text = download.downloadStage.toString()
holder.view.background = holder.view.context.getDrawable(R.drawable.download_manager_item_background) 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 -> pendingDownload.metadata.iconUrl?.let { url ->
thread(start = true) { thread(start = true) {

View File

@ -19,13 +19,13 @@ import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.BuildConfig
import me.rhunk.snapenhance.core.R import me.rhunk.snapenhance.core.R
import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.download.data.DownloadObject
class DownloadManagerActivity : Activity() { class DownloadManagerActivity : Activity() {
lateinit var translation: LocaleWrapper lateinit var translation: LocaleWrapper
private val backCallbacks = mutableListOf<() -> Unit>() private val backCallbacks = mutableListOf<() -> Unit>()
private val fetchedDownloadTasks = mutableListOf<PendingDownload>() private val fetchedDownloadTasks = mutableListOf<DownloadObject>()
private var listFilter = MediaFilter.NONE private var listFilter = MediaFilter.NONE
private val preferences by lazy { private val preferences by lazy {
@ -42,7 +42,7 @@ class DownloadManagerActivity : Activity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun updateListContent() { private fun updateListContent() {
fetchedDownloadTasks.clear() 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)) { with(findViewById<RecyclerView>(R.id.download_list)) {
adapter?.notifyDataSetChanged() adapter?.notifyDataSetChanged()

View File

@ -1,17 +1,17 @@
package me.rhunk.snapenhance.ui.download package me.rhunk.snapenhance.ui.download
enum class MediaFilter( enum class MediaFilter(
val mediaDisplayType: String? = null val key: String,
val shouldIgnoreFilter: Boolean = false
) { ) {
NONE, NONE("none", true),
PENDING, PENDING("pending", true),
CHAT_MEDIA("Chat Media"), CHAT_MEDIA("chat_media"),
STORY("Story"), STORY("story"),
SPOTLIGHT("Spotlight"); SPOTLIGHT("spotlight");
fun matches(source: String?): Boolean { fun matches(source: String?): Boolean {
if (mediaDisplayType == null) return true
if (source == null) return false if (source == null) return false
return source.contains(mediaDisplayType, ignoreCase = true) return source.contains(key, ignoreCase = true)
} }
} }

View File

@ -48,9 +48,7 @@ object PreviewUtils {
setDataSource(file.absolutePath) setDataSource(file.absolutePath)
}.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
} else { } else {
BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options())
inSampleSize = 1
})
} }
} }

View File

@ -1,11 +1,12 @@
[versions] [versions]
agp = "8.2.0-alpha14" agp = "8.2.0-alpha14"
androidx-material = "1.6.0-alpha02" coil-compose = "2.4.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "1.8.22" kotlin = "1.8.22"
kotlinx-coroutines-android = "1.7.2" kotlinx-coroutines-android = "1.7.2"
kotlin-reflect = "1.8.22" 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" navigation-compose = "2.7.0-rc01"
recyclerview = "1.3.1" recyclerview = "1.3.1"
gson = "2.10.1" gson = "2.10.1"
@ -19,9 +20,12 @@ material3 = "1.1.1"
[libraries] [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-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" } 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" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin-reflect" } kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin-reflect" }