commit 3e3424fea3afd009b316ab24ef09c0eb2485a8a9 parent d6128a8849f3df3cc1af0a72e2846303786a3c73 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 10 Feb 2024 19:00:58 +0100 refactor(app): navigation Diffstat:
34 files changed, 4061 insertions(+), 4200 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt @@ -15,15 +15,9 @@ import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.common.ui.AppMaterialTheme class MainActivity : ComponentActivity() { - private lateinit var sections: Map<EnumSection, Section> private lateinit var navController: NavHostController private lateinit var managerContext: RemoteSideContext - override fun onPostResume() { - super.onPostResume() - sections.values.forEach { it.onResumed() } - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (::navController.isInitialized.not()) return @@ -40,37 +34,30 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val startDestination = intent.getStringExtra("route")?.let { EnumSection.fromRoute(it) } ?: EnumSection.HOME managerContext = SharedContextHolder.remote(this).apply { activity = this@MainActivity checkForRequirements() } - sections = EnumSection.entries.associateWith { - it.section.java.constructors.first().newInstance() as Section - }.onEach { (section, instance) -> - with(instance) { - enumSection = section - context = managerContext - init() - } - } + val routes = Routes(managerContext) + routes.getRoutes().forEach { it.init() } setContent { navController = rememberNavController() - val navigation = remember { Navigation(managerContext, sections, navController) } + val navigation = remember { + Navigation(managerContext, navController, routes.also { + it.navController = navController + }) + } + val startDestination = remember { intent.getStringExtra("route") ?: routes.home.routeInfo.id } + AppMaterialTheme { Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { navigation.TopBar() }, - bottomBar = { navigation.NavBar() }, - floatingActionButton = { navigation.Fab() } - ) { innerPadding -> - navigation.NavigationHost( - innerPadding = innerPadding, - startDestination = startDestination - ) - } + bottomBar = { navigation.BottomBar() }, + floatingActionButton = { navigation.FloatingActionButton() } + ) { innerPadding -> navigation.Content(innerPadding, startDestination) } } } } 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 @@ -10,64 +10,40 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.navigation import me.rhunk.snapenhance.RemoteSideContext - +@OptIn(ExperimentalMaterial3Api::class) class Navigation( private val context: RemoteSideContext, - private val sections: Map<EnumSection, Section>, - private val navHostController: NavHostController -){ - @Composable - fun NavigationHost( - startDestination: EnumSection, - innerPadding: PaddingValues - ) { - NavHost( - navHostController, - startDestination = startDestination.route, - Modifier.padding(innerPadding), - enterTransition = { fadeIn(tween(100)) }, - exitTransition = { fadeOut(tween(100)) } - ) { - sections.forEach { (_, instance) -> - instance.navController = navHostController - instance.build(this) - } - } + private val navController: NavHostController, + val routes: Routes = Routes(context).also { + it.navController = navController } - - private fun getCurrentSection(navDestination: NavDestination) = sections.firstNotNullOf { (section, instance) -> - if (navDestination.hierarchy.any { it.route == section.route }) { - instance - } else { - null - } - } - - @OptIn(ExperimentalMaterial3Api::class) +){ @Composable fun TopBar() { - val navBackStackEntry by navHostController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination ?: return - val currentSection = getCurrentSection(currentDestination) + val navBackStackEntry by navController.currentBackStackEntryAsState() + + val canGoBack = remember (navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry)?.let { + !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) + } == true } TopAppBar(title = { - currentSection.Title() + routes.getCurrentRoute(navBackStackEntry)?.title?.invoke() ?: Text(text = routes.getCurrentRoute(navBackStackEntry)?.routeInfo?.translatedKey ?: "Unknown Page") }, navigationIcon = { - val backButtonAnimation by animateFloatAsState(if (currentSection.canGoBack()) 1f else 0f, + val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButtonAnimation" ) @@ -78,64 +54,87 @@ class Navigation( .height(48.dp) ) { IconButton( - onClick = { navHostController.popBackStack() } + onClick = { + if (canGoBack) { + navController.popBackStack() + } + } ) { Icon(Icons.Filled.ArrowBack, contentDescription = null) } } }, actions = { - currentSection.TopBarActions(this) + routes.getCurrentRoute(navBackStackEntry)?.topBarActions?.invoke(this) }) } @Composable - fun Fab() { - val navBackStackEntry by navHostController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination ?: return - val currentSection = getCurrentSection(currentDestination) + fun BottomBar() { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.primary } } - currentSection.FloatingActionButton() - } - - @Composable - fun NavBar() { NavigationBar { - val navBackStackEntry by navHostController.currentBackStackEntryAsState() - val currentDestination = navBackStackEntry?.destination - sections.keys.forEach { section -> - fun selected() = currentDestination?.hierarchy?.any { it.route == section.route } == true - + val currentRoute = routes.getCurrentRoute(navBackStackEntry) + primaryRoutes.forEach { route -> NavigationBarItem( alwaysShowLabel = false, - modifier = Modifier - .fillMaxHeight(), + modifier = Modifier.fillMaxHeight(), icon = { - Icon( - imageVector = section.icon, - contentDescription = null - ) + Icon(imageVector = route.routeInfo.icon, contentDescription = null) }, - label = { Text( textAlign = TextAlign.Center, softWrap = false, fontSize = 12.sp, modifier = Modifier.wrapContentWidth(unbounded = true), - text = if (selected()) context.translation["manager.routes.${section.route}"] else "", + text = if (currentRoute == route) context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] else "", ) }, - selected = selected(), + selected = currentRoute == route, onClick = { - navHostController.navigate(section.route) { - popUpTo(navHostController.graph.findStartDestination().id) { - saveState = true + route.navigateReset() + } + ) + } + } + } + + @Composable + fun FloatingActionButton() { + val navBackStackEntry by navController.currentBackStackEntryAsState() + routes.getCurrentRoute(navBackStackEntry)?.floatingActionButton?.invoke() + } + + @Composable + fun Content(paddingValues: PaddingValues, startDestination: String) { + NavHost( + navController = navController, + startDestination = startDestination, + Modifier.padding(paddingValues), + enterTransition = { fadeIn(tween(100)) }, + exitTransition = { fadeOut(tween(100)) } + ) { + routes.getRoutes().filter { it.parentRoute == null }.forEach { route -> + val children = routes.getRoutes().filter { it.parentRoute == route } + if (children.isEmpty()) { + composable(route.routeInfo.id) { + route.content.invoke(it) + } + route.customComposables.invoke(this) + } else { + navigation("main_" + route.routeInfo.id, route.routeInfo.id) { + composable("main_" + route.routeInfo.id) { + route.content.invoke(it) + } + children.forEach { child -> + composable(child.routeInfo.id) { + child.content.invoke(it) } - launchSingleTop = true - restoreState = true } + route.customComposables.invoke(this) } - ) + } } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt @@ -0,0 +1,134 @@ +package me.rhunk.snapenhance.ui.manager + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Stars +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.ui.manager.pages.TasksRoot +import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot +import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs +import me.rhunk.snapenhance.ui.manager.pages.home.HomeRoot +import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings +import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRoot +import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories +import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope +import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview +import me.rhunk.snapenhance.ui.manager.pages.social.SocialRoot + + +data class RouteInfo( + val id: String, + val key: String = id, + val icon: ImageVector = Icons.Default.Home, + val primary: Boolean = false, +) { + var translatedKey: String? = null + val childIds = mutableListOf<String>() +} + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class Routes( + private val context: RemoteSideContext, +) { + lateinit var navController: NavController + private val routes = mutableListOf<Route>() + + val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRoot()) + + val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRoot()) + + val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot()) + val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home) + val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home) + + val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot()) + val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social) + val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social) + val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social) + + val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRoot()) + + open class Route { + open val init: () -> Unit = { } + open val title: @Composable (() -> Unit)? = null + open val topBarActions: @Composable RowScope.() -> Unit = {} + open val floatingActionButton: @Composable () -> Unit = {} + open val content: @Composable (NavBackStackEntry) -> Unit = {} + open val customComposables: NavGraphBuilder.() -> Unit = {} + + var parentRoute: Route? = null + private set + + lateinit var context: RemoteSideContext + lateinit var routeInfo: RouteInfo + lateinit var routes: Routes + + private fun replaceArguments(id: String, args: Map<String, String>) = args.takeIf { it.isNotEmpty() }?.let { + args.entries.fold(id) { acc, (key, value) -> + acc.replace("{$key}", value) + } + } ?: id + + fun navigate(args: MutableMap<String, String>.() -> Unit = {}) { + routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() })) + } + + fun navigateReset(args: MutableMap<String, String>.() -> Unit = {}) { + routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() })) { + popUpTo(routes.navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + + fun parent(route: Route): Route { + assert(route.routeInfo.primary) { "Parent route must be a primary route" } + parentRoute = route + return this + } + } + + val currentRoute: Route? + get() = routes.firstOrNull { route -> + navController.currentBackStackEntry?.destination?.hierarchy?.any { it.route == route.routeInfo.id } ?: false + } + + val currentDestination: String? + get() = navController.currentBackStackEntry?.destination?.route + + fun getCurrentRoute(navBackStackEntry: NavBackStackEntry?): Route? { + if (navBackStackEntry == null) return null + + return navBackStackEntry.destination.hierarchy.firstNotNullOfOrNull { destination -> + routes.firstOrNull { route -> + route.routeInfo.id == destination.route || route.routeInfo.childIds.contains(destination.route) + } + } + } + + fun getRoutes(): List<Route> = routes + + private fun route(routeInfo: RouteInfo, route: Route): Route { + route.apply { + this.routeInfo = routeInfo + routes = this@Routes + context = this@Routes.context + this.routeInfo.translatedKey = context.translation.getOrNull("manager.routes.${route.routeInfo.key.substringBefore("/")}") + } + routes.add(route) + return route + } +}+ \ No newline at end of file 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,91 +0,0 @@ -package me.rhunk.snapenhance.ui.manager - -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.ui.manager.sections.NotImplemented -import me.rhunk.snapenhance.ui.manager.sections.TasksSection -import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection -import me.rhunk.snapenhance.ui.manager.sections.home.HomeSection -import me.rhunk.snapenhance.ui.manager.sections.scripting.ScriptsSection -import me.rhunk.snapenhance.ui.manager.sections.social.SocialSection -import kotlin.reflect.KClass - -enum class EnumSection( - val route: String, - val icon: ImageVector, - val section: KClass<out Section> = NotImplemented::class -) { - TASKS( - route = "tasks", - icon = Icons.Filled.TaskAlt, - section = TasksSection::class - ), - FEATURES( - route = "features", - icon = Icons.Filled.Stars, - section = FeaturesSection::class - ), - HOME( - route = "home", - icon = Icons.Filled.Home, - section = HomeSection::class - ), - SOCIAL( - route = "social", - icon = Icons.Filled.Group, - section = SocialSection::class - ), - SCRIPTS( - route = "scripts", - icon = Icons.Filled.DataObject, - section = ScriptsSection::class - ); - - companion object { - fun fromRoute(route: String): EnumSection { - return entries.first { it.route == route } - } - } -} - - - -open class Section { - lateinit var enumSection: EnumSection - lateinit var context: RemoteSideContext - lateinit var navController: NavController - - val currentRoute get() = navController.currentBackStackEntry?.destination?.route - - open fun init() {} - open fun onResumed() {} - - open fun sectionTopBarName(): String = context.translation["manager.routes.${enumSection.route}"] - open fun canGoBack(): Boolean = false - - @Composable - open fun Title() { Text(text = sectionTopBarName()) } - - @Composable - open fun Content() { NotImplemented() } - - @Composable - open fun TopBarActions(rowScope: RowScope) {} - - @Composable - open fun FloatingActionButton() {} - - open fun build(navGraphBuilder: NavGraphBuilder) { - navGraphBuilder.composable(enumSection.route) { - Content() - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt @@ -0,0 +1,452 @@ +package me.rhunk.snapenhance.ui.manager.pages + + import android.content.Intent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap + import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.Lifecycle + import androidx.navigation.NavBackStackEntry + import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.common.data.download.createNewFilePath +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.download.FFMpegProcessor +import me.rhunk.snapenhance.task.PendingTask +import me.rhunk.snapenhance.task.PendingTaskListener +import me.rhunk.snapenhance.task.Task +import me.rhunk.snapenhance.task.TaskStatus +import me.rhunk.snapenhance.task.TaskType + import me.rhunk.snapenhance.ui.manager.Routes + import me.rhunk.snapenhance.ui.util.OnLifecycleEvent +import java.io.File +import java.util.UUID +import kotlin.math.absoluteValue + +class TasksRoot : Routes.Route() { + private var activeTasks by mutableStateOf(listOf<PendingTask>()) + private lateinit var recentTasks: MutableList<Task> + private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() + + private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { + scope.launch(Dispatchers.IO) { + activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() + } + } + + private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) { + val firstTask = selection.first().first + + val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) + val pendingTask = context.taskManager.createPendingTask( + Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash) + ) + pendingTask.status = TaskStatus.RUNNING + fetchActiveTasks() + + context.coroutineScope.launch { + val filesToMerge = mutableListOf<File>() + + selection.forEach { (task, documentFile) -> + val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also { + it.deleteOnExit() + } + + runCatching { + pendingTask.updateProgress("Copying ${documentFile.name}") + context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> + //copy with progress + val length = documentFile.length().toFloat() + tempFile.outputStream().use { outputStream -> + val buffer = ByteArray(16 * 1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt()) + } + outputStream.flush() + filesToMerge.add(tempFile) + } + } + }.onFailure { + pendingTask.fail("Failed to copy file $documentFile to $tempFile") + filesToMerge.forEach { it.delete() } + return@launch + } + } + + val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also { + it.deleteOnExit() + } + + runCatching { + context.shortToast("Merging ${filesToMerge.size} files") + FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute( + FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge, mergedFile) + ) + DownloadProcessor(context, object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String) { + context.log.verbose("Merged files to $outputPath") + } + }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata( + mediaIdentifier = taskHash, + outputPath = createNewFilePath( + context.config.root, + taskHash, + downloadSource = MediaDownloadSource.MERGED, + mediaAuthor = firstTask.author, + creationTimestamp = System.currentTimeMillis() + ), + mediaAuthor = firstTask.author, + downloadSource = MediaDownloadSource.MERGED.translate(context.translation), + iconUrl = null + )) + }.onFailure { + context.log.error("Failed to merge files", it) + pendingTask.fail(it.message ?: "Failed to merge files") + }.onSuccess { + pendingTask.success() + } + filesToMerge.forEach { it.delete() } + mergedFile.delete() + }.also { + pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() })) + } + } + + override val topBarActions: @Composable() (RowScope.() -> Unit) = { + var showConfirmDialog by remember { mutableStateOf(false) } + + if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { + taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.let { documentFile -> + IconButton(onClick = { + runCatching { + context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(documentFile.uri, documentFile.type) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + }) + taskSelection.clear() + }.onFailure { + context.log.error("Failed to open file ${taskSelection.first().second}", it) + } + }) { + Icon(Icons.Filled.OpenInNew, contentDescription = "Open") + } + } + } + + if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { + IconButton(onClick = { + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }) { + Icon(Icons.Filled.Merge, contentDescription = "Merge") + } + } + + IconButton(onClick = { + showConfirmDialog = true + }) { + Icon(Icons.Filled.Delete, contentDescription = "Clear tasks") + } + + if (showConfirmDialog) { + var alsoDeleteFiles by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + title = { + if (taskSelection.isNotEmpty()) { + Text("Remove ${taskSelection.size} tasks?") + } else { + Text("Remove all tasks?") + } + }, + text = { + Column { + if (taskSelection.isNotEmpty()) { + Text("Are you sure you want to remove selected tasks?") + Row ( + modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable { + alsoDeleteFiles = !alsoDeleteFiles + }, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = alsoDeleteFiles, onCheckedChange = { + alsoDeleteFiles = it + }) + Text("Also delete files") + } + } else { + Text("Are you sure you want to remove all tasks?") + } + } + }, + confirmButton = { + Button( + onClick = { + showConfirmDialog = false + + if (taskSelection.isNotEmpty()) { + taskSelection.forEach { (task, documentFile) -> + context.taskManager.removeTask(task) + recentTasks.remove(task) + if (alsoDeleteFiles) { + documentFile?.delete() + } + } + activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } + taskSelection.clear() + } else { + context.taskManager.clearAllTasks() + recentTasks.clear() + activeTasks.forEach { + runCatching { + it.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task $it", throwable) + } + } + activeTasks = listOf() + context.taskManager.getActiveTasks().clear() + } + } + ) { + Text("Yes") + } + }, + dismissButton = { + Button( + onClick = { + showConfirmDialog = false + } + ) { + Text("No") + } + } + ) + } + } + + @Composable + private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { + var taskStatus by remember { mutableStateOf(task.status) } + var taskProgressLabel by remember { mutableStateOf<String?>(null) } + var taskProgress by remember { mutableIntStateOf(-1) } + val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } + var documentFile by remember { mutableStateOf<DocumentFile?>(null) } + var isDocumentFileReadable by remember { mutableStateOf(true) } + + LaunchedEffect(taskStatus.key) { + launch(Dispatchers.IO) { + documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch) + isDocumentFileReadable = documentFile?.canRead() ?: false + } + } + + val listener = remember { PendingTaskListener( + onStateChange = { + taskStatus = it + }, + onProgress = { label, progress -> + taskProgressLabel = label + taskProgress = progress + } + ) } + + LaunchedEffect(Unit) { + pendingTask?.addListener(listener) + } + + DisposableEffect(Unit) { + onDispose { + pendingTask?.removeListener(listener) + } + } + + OutlinedCard(modifier = modifier.clickable { + if (isSelected) { + taskSelection.removeIf { it.first == task } + return@clickable + } + taskSelection.add(task to documentFile) + }.let { + if (isSelected) { + it + .border(2.dp, MaterialTheme.colorScheme.primary) + .clip(MaterialTheme.shapes.medium) + } else it + }) { + Row( + modifier = Modifier.padding(15.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(end = 15.dp) + ) { + documentFile?.let { file -> + val mimeType = file.type ?: "" + when { + !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") + mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") + mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") + mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") + else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") + } + } ?: run { + when (task.type) { + TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") + TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") + } + } + } + Column( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(task.title, style = MaterialTheme.typography.bodyMedium) + task.author?.takeIf { it != "null" }?.let { + Spacer(modifier = Modifier.width(5.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Text(task.hash, style = MaterialTheme.typography.labelSmall) + Column( + modifier = Modifier.padding(top = 5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + if (taskStatus.isFinalStage()) { + if (taskStatus != TaskStatus.SUCCESS) { + Text("$taskStatus", style = MaterialTheme.typography.bodySmall) + } + } else { + taskProgressLabel?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + if (taskProgress != -1) { + LinearProgressIndicator( + progress = taskProgress.toFloat() / 100f, + strokeCap = StrokeCap.Round + ) + } else { + task.extra?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + Column { + if (pendingTask != null && !taskStatus.isFinalStage()) { + FilledIconButton(onClick = { + runCatching { + pendingTask.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task $pendingTask", throwable) + } + }) { + Icon(Icons.Filled.Close, contentDescription = "Cancel") + } + } else { + when (taskStatus) { + TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary) + TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error) + TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error) + else -> {} + } + } + } + } + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() + recentTasks = remember { mutableStateListOf() } + var lastFetchedTaskId: Long? by remember { mutableStateOf(null) } + + fun fetchNewRecentTasks() { + scope.launch(Dispatchers.IO) { + val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE) + if (tasks.isNotEmpty()) { + lastFetchedTaskId = tasks.keys.last() + val activeTaskIds = activeTasks.map { it.taskId } + recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) + } + } + } + + LaunchedEffect(Unit) { + fetchActiveTasks(this) + } + + DisposableEffect(Unit) { + onDispose { + taskSelection.clear() + } + } + + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + fetchActiveTasks(scope) + } + } + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize() + ) { + item { + if (activeTasks.isEmpty() && recentTasks.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + context.translation["manager.sections.tasks.no_tasks"].let { + Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary) + Text(it, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + items(activeTasks, key = { it.taskId }) {pendingTask -> + TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask) + } + items(recentTasks, key = { it.hash }) { task -> + TaskCard(modifier = Modifier.padding(8.dp), task) + } + item { + Spacer(modifier = Modifier.height(20.dp)) + LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { + fetchNewRecentTasks() + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt @@ -0,0 +1,4 @@ +package me.rhunk.snapenhance.ui.manager.pages.features + +typealias ClickCallback = (Boolean) -> Unit +typealias RegisterClickCallback = (ClickCallback) -> ClickCallback+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt @@ -0,0 +1,575 @@ +package me.rhunk.snapenhance.ui.manager.pages.features + +import android.content.Intent +import android.net.Uri +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.config.* +import me.rhunk.snapenhance.ui.manager.MainActivity +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.* + +@OptIn(ExperimentalMaterial3Api::class) +class FeaturesRoot : Routes.Route() { + private val alertDialogs by lazy { AlertDialogs(context.translation) } + + companion object { + const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}" + const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}" + } + + private var activityLauncherHelper: ActivityLauncherHelper? = null + private lateinit var rememberScaffoldState: BottomSheetScaffoldState + + private val allContainers by lazy { + val containers = mutableMapOf<String, PropertyPair<*>>() + fun queryContainerRecursive(container: ConfigContainer) { + container.properties.forEach { + if (it.key.dataType.type == DataProcessors.Type.CONTAINER) { + containers[it.key.name] = PropertyPair(it.key, it.value) + queryContainerRecursive(it.value.get() as ConfigContainer) + } + } + } + queryContainerRecursive(context.config.root) + containers + } + + private val allProperties by lazy { + val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() + allContainers.values.forEach { + val container = it.value.get() as ConfigContainer + container.properties.forEach { property -> + properties[property.key] = property.value + } + } + properties + } + + private fun navigateToMainRoot() { + routes.navController.navigate(routeInfo.id, NavOptions.Builder() + .setPopUpTo(routes.navController.graph.findStartDestination().id, false) + .setLaunchSingleTop(true) + .build() + ) + } + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { + activityLauncherHelper?.let(block) ?: run { + //open manager if activity launcher is null + val intent = Intent(context.androidContext, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("route", routeInfo.id) + context.androidContext.startActivity(intent) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + Container(context.config.root) + } + + override val customComposables: NavGraphBuilder.() -> Unit = { + routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE)) + + composable(FEATURE_CONTAINER_ROUTE, enterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100)) + }, exitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) + }) { backStackEntry -> + backStackEntry.arguments?.getString("name")?.let { containerName -> + allContainers[containerName]?.let { + Container(it.value.get() as ConfigContainer) + } + } + } + + composable(SEARCH_FEATURE_ROUTE) { backStackEntry -> + backStackEntry.arguments?.getString("keyword")?.let { keyword -> + val properties = allProperties.filter { + it.key.name.contains(keyword, ignoreCase = true) || + context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || + context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) + }.map { PropertyPair(it.key, it.value) } + + PropertiesView(properties) + } + } + } + + @Composable + private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { + var showDialog by remember { mutableStateOf(false) } + var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } + + fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true } + + if (showDialog) { + Dialog( + properties = DialogProperties( + usePlatformDefaultWidth = false + ), + onDismissRequest = { showDialog = false }, + ) { + dialogComposable() + } + } + + val propertyValue = property.value + + if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { + IconButton(onClick = registerClickCallback { + activityLauncher { + chooseFolder { uri -> + propertyValue.setAny(uri) + } + } + }.let { { it.invoke(true) } }) { + Icon(Icons.Filled.FolderOpen, contentDescription = null) + } + return + } + + when (val dataType = remember { property.key.dataType.type }) { + DataProcessors.Type.BOOLEAN -> { + var state by remember { mutableStateOf(propertyValue.get() as Boolean) } + Switch( + checked = state, + onCheckedChange = registerClickCallback { + state = state.not() + propertyValue.setAny(state) + } + ) + } + + DataProcessors.Type.MAP_COORDINATES -> { + registerDialogOnClickCallback() + dialogComposable = { + alertDialogs.ChooseLocationDialog(property) { + showDialog = false + } + } + + Text( + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.widthIn(0.dp, 120.dp), + text = (propertyValue.get() as Pair<*, *>).let { + "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}" + } + ) + } + + DataProcessors.Type.STRING_UNIQUE_SELECTION -> { + registerDialogOnClickCallback() + + dialogComposable = { + alertDialogs.UniqueSelectionDialog(property) + } + + Text( + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.widthIn(0.dp, 120.dp), + text = (propertyValue.getNullable() as? String ?: "null").let { + property.key.propertyOption(context.translation, it) + } + ) + } + + DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { + dialogComposable = { + when (dataType) { + DataProcessors.Type.STRING_MULTIPLE_SELECTION -> { + alertDialogs.MultipleSelectionDialog(property) + } + DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { + alertDialogs.KeyboardInputDialog(property) { showDialog = false } + } + else -> {} + } + } + + registerDialogOnClickCallback().let { { it.invoke(true) } }.also { + if (dataType == DataProcessors.Type.INTEGER || + dataType == DataProcessors.Type.FLOAT) { + FilledIconButton(onClick = it) { + Text( + text = propertyValue.get().toString(), + modifier = Modifier.wrapContentWidth(), + overflow = TextOverflow.Ellipsis + ) + } + } else { + IconButton(onClick = it) { + Icon(Icons.Filled.OpenInNew, contentDescription = null) + } + } + } + } + DataProcessors.Type.CONTAINER -> { + val container = propertyValue.get() as ConfigContainer + + registerClickCallback { + routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) + } + + if (!container.hasGlobalState) return + + var state by remember { mutableStateOf(container.globalState ?: false) } + + Box( + modifier = Modifier + .padding(end = 15.dp), + ) { + + Box(modifier = Modifier + .height(50.dp) + .width(1.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + shape = RoundedCornerShape(5.dp) + )) + } + + Switch( + checked = state, + onCheckedChange = { + state = state.not() + container.globalState = state + } + ) + } + } + + } + + @Composable + private fun PropertyCard(property: PropertyPair<*>) { + var clickCallback by remember { mutableStateOf<ClickCallback?>(null) } + val noticeColorMap = mapOf( + FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87), + FeatureNotice.BAN_RISK.key to Color(0xFFFF8585), + FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87), + FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .clickable { + clickCallback?.invoke(true) + } + .padding(all = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + property.key.params.icon?.let { iconName -> + //TODO: find a better way to load icons + val icon: ImageVector? = remember(iconName) { + runCatching { + val cl = Class.forName("androidx.compose.material.icons.filled.${iconName}Kt") + val method = cl.declaredMethods.first() + method.invoke(null, Icons.Filled) as ImageVector + }.getOrNull() + } + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 10.dp) + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f, fill = true) + .padding(all = 10.dp) + ) { + Text( + text = context.translation[property.key.propertyName()], + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = context.translation[property.key.propertyDescription()], + fontSize = 12.sp, + lineHeight = 15.sp + ) + property.key.params.notices.also { + if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp)) + }.forEach { + Text( + text = context.translation["features.notices.${it.key}"], + color = noticeColorMap[it.key] ?: Color(0xFFFFFB87), + fontSize = 12.sp, + lineHeight = 15.sp + ) + } + } + Row( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(all = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PropertyAction(property, registerClickCallback = { callback -> + clickCallback = callback + callback + }) + } + } + } + } + + @Composable + private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) { + var searchValue by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + var currentSearchJob by remember { mutableStateOf<Job?>(null) } + + rowScope.apply { + TextField( + value = searchValue, + onValueChange = { keyword -> + searchValue = keyword + if (keyword.isEmpty()) { + navigateToMainRoot() + return@TextField + } + currentSearchJob?.cancel() + scope.launch { + delay(150) + routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(routeInfo.id, false) + .build() + ) + }.also { currentSearchJob = it } + }, + + keyboardActions = KeyboardActions(onDone = { + focusRequester.freeFocus() + }), + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f, fill = true) + .padding(end = 10.dp) + .height(70.dp), + singleLine = true, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary + ) + ) + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ + var showSearchBar by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + if (showSearchBar) { + FeatureSearchBar(this, focusRequester) + LaunchedEffect(true) { + focusRequester.requestFocus() + } + } + + IconButton(onClick = { + showSearchBar = showSearchBar.not() + if (!showSearchBar && routes.currentDestination == SEARCH_FEATURE_ROUTE) { + navigateToMainRoot() + } + }) { + Icon( + imageVector = if (showSearchBar) Icons.Filled.Close + else Icons.Filled.Search, + contentDescription = null + ) + } + + if (showSearchBar) return@topBarActions + + var showExportDropdownMenu by remember { mutableStateOf(false) } + var showResetConfirmationDialog by remember { mutableStateOf(false) } + + if (showResetConfirmationDialog) { + Dialog(onDismissRequest = { showResetConfirmationDialog = false }) { + alertDialogs.ConfirmDialog( + title = "Reset config", + message = "Are you sure you want to reset the config?", + onConfirm = { + context.config.reset() + context.shortToast("Config successfully reset!") + }, + onDismiss = { showResetConfirmationDialog = false } + ) + } + } + + val actions = remember { + mapOf( + "Export" to { + activityLauncher { + saveFile("config.json", "application/json") { uri -> + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + context.config.writeConfig() + context.config.exportToString().byteInputStream().copyTo(it) + context.shortToast("Config exported successfully!") + } + } + } + }, + "Import" to { + activityLauncher { + openFile("application/json") { uri -> + context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { + runCatching { + context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) + }.onFailure { + context.longToast("Failed to import config ${it.message}") + return@use + } + context.shortToast("Config successfully loaded!") + } + } + } + }, + "Reset" to { showResetConfirmationDialog = true } + ) + } + + if (context.activity != null) { + IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null + ) + } + } + + if (showExportDropdownMenu) { + DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) { + actions.forEach { (name, action) -> + DropdownMenuItem( + text = { + Text(text = name) + }, + onClick = { + action() + showExportDropdownMenu = false + } + ) + } + } + } + } + + @Composable + private fun PropertiesView( + properties: List<PropertyPair<*>> + ) { + rememberScaffoldState = rememberBottomSheetScaffoldState() + Scaffold( + snackbarHost = { SnackbarHost(rememberScaffoldState.snackbarHostState) }, + modifier = Modifier.fillMaxSize(), + content = { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxHeight() + .padding(innerPadding), + //save button space + contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp), + verticalArrangement = Arrangement.Top + ) { + items(properties) { + PropertyCard(it) + } + } + } + ) + } + + override val floatingActionButton: @Composable () -> Unit = { + val scope = rememberCoroutineScope() + FloatingActionButton( + onClick = { + context.config.writeConfig() + scope.launch { + rememberScaffoldState.snackbarHostState.showSnackbar("Saved") + } + }, + modifier = Modifier.padding(10.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Rounded.Save, + contentDescription = null + ) + } + } + + + @Composable + private fun Container( + configContainer: ConfigContainer + ) { + val properties = remember { + configContainer.properties.map { PropertyPair(it.key, it.value) } + } + + PropertiesView(properties) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt @@ -0,0 +1,252 @@ +package me.rhunk.snapenhance.ui.manager.pages.home + +import android.net.Uri +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown +import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.LogReader +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator +import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState +import me.rhunk.snapenhance.ui.util.saveFile + +class HomeLogs : Routes.Route() { + private val logListState by lazy { LazyListState(0) } + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var showDropDown by remember { mutableStateOf(false) } + + IconButton(onClick = { + showDropDown = true + }) { + Icon(Icons.Filled.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showDropDown, + onDismissRequest = { showDropDown = false }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + DropdownMenuItem(onClick = { + context.log.clearLogs() + navigate() + showDropDown = false + }, text = { + Text( + text = context.translation["manager.sections.home.logs.clear_logs_button"] + ) + }) + + DropdownMenuItem(onClick = { + activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri -> + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + runCatching { + context.log.exportLogsToZip(it) + context.longToast("Saved logs to $uri") + }.onFailure { + context.longToast("Failed to save logs to $uri!") + context.log.error("Failed to save logs to $uri!", it) + } + } + } + showDropDown = false + }, text = { + Text( + text = context.translation["manager.sections.home.logs.export_logs_button"] + ) + }) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current + var lineCount by remember { mutableIntStateOf(0) } + var logReader by remember { mutableStateOf<LogReader?>(null) } + var isRefreshing by remember { mutableStateOf(false) } + + fun refreshLogs() { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + logReader = context.log.newReader { + lineCount++ + } + lineCount = logReader!!.lineCount + }.onFailure { + context.longToast("Failed to read logs!") + } + delay(300) + isRefreshing = false + withContext(Dispatchers.Main) { + logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@withContext) + } + } + } + + val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = { + refreshLogs() + }) + + LaunchedEffect(Unit) { + isRefreshing = true + refreshLogs() + } + + Box( + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .horizontalScroll(ScrollState(0)), + state = logListState + ) { + item { + if (lineCount == 0 && logReader != null) { + Text( + text = "No logs found!", + modifier = Modifier.padding(16.dp), + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + } + items(lineCount) { index -> + val logLine = remember(index) { logReader?.getLogLine(index) } ?: return@items + var expand by remember { mutableStateOf(false) } + + Box(modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + coroutineScope.launch { + clipboardManager.setText(AnnotatedString(logLine.message)) + } + }, + onTap = { + expand = !expand + } + ) + }) { + + Row( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = 30.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!expand) { + Icon( + imageVector = when (logLine.logLevel) { + LogLevel.DEBUG -> Icons.Outlined.BugReport + LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report + LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info + LogLevel.WARN -> Icons.Outlined.Warning + }, + contentDescription = null, + ) + + Text( + text = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag, + modifier = Modifier.padding(start = 4.dp), + fontWeight = FontWeight.Light, + fontSize = 10.sp, + ) + + Text( + text = logLine.dateTime, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + fontSize = 10.sp + ) + } + + Text( + text = logLine.message.trimIndent(), + fontSize = 10.sp, + maxLines = if (expand) Int.MAX_VALUE else 6, + overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis, + softWrap = !expand, + ) + } + } + } + } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + + override val floatingActionButton: @Composable () -> Unit = { + val coroutineScope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } } + val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } } + FilledIconButton( + onClick = { + coroutineScope.launch { + logListState.scrollToItem(0) + } + }, + enabled = firstVisibleItem != 0 + ) { + Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null) + } + + FilledIconButton( + onClick = { + coroutineScope.launch { + logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch) + } + }, + enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1 + ) { + Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt @@ -0,0 +1,277 @@ +package me.rhunk.snapenhance.ui.manager.pages.home + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.manager.data.InstallationSummary +import me.rhunk.snapenhance.ui.manager.data.Updater +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.OnLifecycleEvent + +class HomeRoot : Routes.Route() { + companion object { + val cardMargin = 10.dp + } + + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + private fun SummaryCards(installationSummary: InstallationSummary) { + val summaryInfo = remember { + mapOf( + "Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"), + "Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"), + "Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"), + "Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"), + "Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"), + "Device" to installationSummary.platformInfo.device, + "Android Version" to installationSummary.platformInfo.androidVersion, + "System ABI" to installationSummary.platformInfo.systemAbi + ) + } + + Card( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp), + ) { + summaryInfo.forEach { (title, value) -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 5.dp), + ) { + Text( + text = title, + fontSize = 12.sp, + fontWeight = FontWeight.Light, + ) + Text( + fontSize = 14.sp, + text = value, + lineHeight = 20.sp + ) + } + } + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + IconButton(onClick = { + routes.homeLogs.navigate() + }) { + Icon(Icons.Filled.BugReport, contentDescription = null) + } + IconButton(onClick = { + routes.settings.navigate() + }) { + Icon(Icons.Filled.Settings, contentDescription = null) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + val avenirNextFontFamily = remember { + FontFamily( + Font(R.font.avenir_next_medium, FontWeight.Medium) + ) + } + + var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) } + + Column( + modifier = Modifier + .verticalScroll(ScrollState(0)) + ) { + + Image( + painter = painterResource(id = R.drawable.launcher_icon_monochrome), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + contentScale = ContentScale.FillHeight, + modifier = Modifier + .fillMaxWidth() + .scale(1.8f) + .height(90.dp) + ) + + Text( + text = remember { intArrayOf(101,99,110,97,104,110,69,112,97,110,83).map { it.toChar() }.joinToString("").reversed() }, + fontSize = 30.sp, + fontFamily = avenirNextFontFamily, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Text( + text = "v" + BuildConfig.VERSION_NAME + " \u00b7 by rhunk", + fontSize = 12.sp, + fontFamily = avenirNextFontFamily, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Text( + text = "An xposed module made to enhance your Snapchat experience", + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally), + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(32.dp).clickable { + context.activity?.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse( + intArrayOf(101,99,110,97,104,110,69,112,97,110,83,47,107,110,117,104,114,47,109,111,99,46,98,117,104,116,105,103,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed() + ) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } + ) + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(32.dp).clickable { + context.activity?.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse( + intArrayOf(101,99,110,97,104,110,101,112,97,110,115,47,101,109,46,116,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed() + ) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } + ) + } + Spacer(modifier = Modifier.height(20.dp)) + + if (latestUpdate != null) { + OutlinedCard( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ){ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 15.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "SnapEnhance Update", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + Text( + fontSize = 12.sp, + text = "Version ${latestUpdate?.versionName} is available!", + lineHeight = 20.sp + ) + } + Button(onClick = { + context.activity?.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(latestUpdate?.releaseUrl) + } + ) + }, modifier = Modifier.height(40.dp)) { + Text(text = "Download") + } + } + } + } + + val coroutineScope = rememberCoroutineScope() + var installationSummary by remember { mutableStateOf(null as InstallationSummary?) } + + fun updateInstallationSummary(scope: CoroutineScope) { + scope.launch(Dispatchers.IO) { + runCatching { + installationSummary = context.installationSummary + }.onFailure { + context.longToast("SnapEnhance failed to load installation summary: ${it.message}") + } + runCatching { + if (!BuildConfig.DEBUG) { + latestUpdate = Updater.checkForLatestRelease() + } + }.onFailure { + context.longToast("SnapEnhance failed to check for updates: ${it.message}") + } + } + } + + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + updateInstallationSummary(coroutineScope) + } + } + + LaunchedEffect(Unit) { + updateInstallationSummary(coroutineScope) + } + + installationSummary?.let { SummaryCards(installationSummary = it) } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt @@ -0,0 +1,242 @@ +package me.rhunk.snapenhance.ui.manager.pages.home + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.setup.Requirements +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.saveFile + +class HomeSettings : Routes.Route() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + private val dialogs by lazy { AlertDialogs(context.translation) } + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + private fun RowTitle(title: String) { + Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + + @Composable + private fun RowAction(title: String, requireConfirmation: Boolean = false, action: () -> Unit) { + var confirmationDialog by remember { + mutableStateOf(false) + } + + fun takeAction() { + if (requireConfirmation) { + confirmationDialog = true + } else { + action() + } + } + + if (requireConfirmation && confirmationDialog) { + Dialog(onDismissRequest = { confirmationDialog = false }) { + dialogs.ConfirmDialog(title = "Are you sure?", onConfirm = { + action() + confirmationDialog = false + }, onDismiss = { + confirmationDialog = false + }) + } + } + + ShiftedRow( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .clickable { + takeAction() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, modifier = Modifier.padding(start = 26.dp)) + IconButton(onClick = { takeAction() }) { + Icon( + imageVector = Icons.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } + + private fun launchActionIntent(action: EnumAction) { + val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) + intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key) + context.androidContext.startActivity(intent) + } + + @Composable + private fun ShiftedRow( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit + ) { + Row( + modifier = modifier.padding(start = 26.dp), + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment + ) { content(this) } + } + + @OptIn(ExperimentalMaterial3Api::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(ScrollState(0)) + ) { + RowTitle(title = "Actions") + EnumAction.entries.forEach { enumAction -> + RowAction(title = context.translation["actions.${enumAction.key}"]) { + launchActionIntent(enumAction) + } + } + RowAction(title = "Regenerate Mappings") { + context.checkForRequirements(Requirements.MAPPINGS) + } + RowAction(title = "Change Language") { + context.checkForRequirements(Requirements.LANGUAGE) + } + RowTitle(title = "Message Logger") + ShiftedRow { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + var storedMessagesCount by remember { mutableIntStateOf(0) } + var storedStoriesCount by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + storedMessagesCount = context.messageLogger.getStoredMessageCount() + storedStoriesCount = context.messageLogger.getStoredStoriesCount() + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(text = "$storedMessagesCount messages") + Text(text = "$storedStoriesCount stories") + } + Button(onClick = { + runCatching { + activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> + context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream -> + context.messageLogger.databaseFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + }.onFailure { + context.log.error("Failed to export database", it) + context.longToast("Failed to export database! ${it.localizedMessage}") + } + }) { + Text(text = "Export") + } + Button(onClick = { + runCatching { + context.messageLogger.purgeAll() + storedMessagesCount = 0 + storedStoriesCount = 0 + }.onFailure { + context.log.error("Failed to clear messages", it) + context.longToast("Failed to clear messages! ${it.localizedMessage}") + }.onSuccess { + context.shortToast("Done!") + } + }) { + Text(text = "Clear") + } + } + } + } + + RowTitle(title = "Clear App Files") + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + var selectedFileType by remember { mutableStateOf(BridgeFileType.entries.first()) } + Box( + modifier = Modifier + .weight(1f) + .padding(start = 26.dp) + ) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + TextField( + value = selectedFileType.displayName, + onValueChange = {}, + readOnly = true, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + BridgeFileType.entries.forEach { fileType -> + DropdownMenuItem(onClick = { + expanded = false + selectedFileType = fileType + }, text = { + Text(text = fileType.displayName) + }) + } + } + } + } + Button(onClick = { + runCatching { + selectedFileType.resolve(context.androidContext).delete() + }.onFailure { + context.log.error("Failed to clear file", it) + context.longToast("Failed to clear file! ${it.localizedMessage}") + }.onSuccess { + context.shortToast("Done!") + } + }) { + Text(text = "Clear") + } + } + Spacer(modifier = Modifier.height(50.dp)) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt @@ -0,0 +1,301 @@ +package me.rhunk.snapenhance.ui.manager.pages.scripting + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.LibraryBooks +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface +import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager +import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.chooseFolder +import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator +import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh +import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState + +class ScriptingRoot : Routes.Route() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + fun ModuleItem(script: ModuleInfo) { + var enabled by remember { + mutableStateOf(context.modDatabase.isScriptEnabled(script.name)) + } + var openSettings by remember { + mutableStateOf(false) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + elevation = CardDefaults.cardElevation() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + openSettings = !openSettings + } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + Text(text = script.displayName ?: script.name, fontSize = 20.sp,) + Text(text = script.description ?: "No description", fontSize = 14.sp,) + } + IconButton(onClick = { openSettings = !openSettings }) { + Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",) + } + Switch( + checked = enabled, + onCheckedChange = { isChecked -> + context.modDatabase.setScriptEnabled(script.name, isChecked) + enabled = isChecked + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name)!! + context.scriptManager.unloadScript(modulePath) + if (isChecked) { + context.scriptManager.loadScript(modulePath) + context.scriptManager.runtime.getModuleByName(script.name) + ?.callFunction("module.onSnapEnhanceLoad") + context.shortToast("Loaded script ${script.name}") + } else { + context.shortToast("Unloaded script ${script.name}") + } + }.onFailure { throwable -> + enabled = !isChecked + ("Failed to ${if (isChecked) "enable" else "disable"} script").let { + context.log.error(it, throwable) + context.shortToast(it) + } + } + } + ) + } + + if (openSettings) { + ScriptSettings(script) + } + } + } + + override val floatingActionButton: @Composable () -> Unit = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End, + ) { + ExtendedFloatingActionButton( + onClick = { + + }, + icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, + text = { + Text(text = "Import from URL") + }, + ) + ExtendedFloatingActionButton( + onClick = { + context.scriptManager.getScriptsFolder()?.let { + context.androidContext.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = it.uri + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } + }, + icon= { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") }, + text = { + Text(text = "Open Scripts Folder") + }, + ) + } + } + + + @Composable + fun ScriptSettings(script: ModuleInfo) { + val settingsInterface = remember { + val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null + (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS) + } + + if (settingsInterface == null) { + Text( + text = "This module does not have any settings", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + } else { + ScriptInterface(interfaceBuilder = settingsInterface) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { + var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) } + var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } + val coroutineScope = rememberCoroutineScope() + + var refreshing by remember { + mutableStateOf(false) + } + + fun syncScripts() { + runCatching { + scriptingFolder = context.scriptManager.getScriptsFolder() + context.scriptManager.sync() + scriptModules = context.modDatabase.getScripts() + }.onFailure { + context.log.error("Failed to sync scripts", it) + } + } + + LaunchedEffect(Unit) { + refreshing = true + withContext(Dispatchers.IO) { + syncScripts() + refreshing = false + } + } + + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { + refreshing = true + syncScripts() + coroutineScope.launch { + delay(300) + refreshing = false + } + }) + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + if (scriptingFolder == null) { + Text( + text = "No scripts folder selected", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + activityLauncherHelper.chooseFolder { + context.config.root.scripting.moduleFolder.set(it) + context.config.writeConfig() + coroutineScope.launch { + syncScripts() + } + } + }) { + Text(text = "Select folder") + } + } else if (scriptModules.isEmpty()) { + Text( + text = "No scripts found", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + } + } + items(scriptModules.size) { index -> + ModuleItem(scriptModules[index]) + } + item { + Spacer(modifier = Modifier.height(200.dp)) + } + } + + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + + var scriptingWarning by remember { + mutableStateOf(context.sharedPreferences.run { + getBoolean("scripting_warning", true).also { + edit().putBoolean("scripting_warning", false).apply() + } + }) + } + + if (scriptingWarning) { + var timeout by remember { + mutableIntStateOf(10) + } + + LaunchedEffect(Unit) { + while (timeout > 0) { + delay(1000) + timeout-- + } + } + + AlertDialog(onDismissRequest = { + if (timeout == 0) { + scriptingWarning = false + } + }, title = { + Text(text = context.translation["manager.dialogs.scripting_warning.title"]) + }, text = { + Text(text = context.translation["manager.dialogs.scripting_warning.content"]) + }, confirmButton = { + TextButton( + onClick = { + scriptingWarning = false + }, + enabled = timeout == 0 + ) { + Text(text = "OK " + if (timeout > 0) "($timeout)" else "") + } + }) + } + } + + override val topBarActions: @Composable() (RowScope.() -> Unit) = { + IconButton(onClick = { + context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { + data = "https://github.com/SnapEnhance/docs".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }) + }) { + Icon(imageVector = Icons.Default.LibraryBooks, contentDescription = "Documentation") + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt @@ -0,0 +1,259 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper + +class AddFriendDialog( + private val context: RemoteSideContext, + private val socialRoot: SocialRoot, +) { + + private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} + + @Composable + private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { + var currentState by remember { mutableStateOf(getCurrentState()) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + currentState = !currentState + onState(currentState) + } + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + fontSize = 15.sp, + modifier = Modifier + .weight(1f) + .onGloballyPositioned { + currentState = getCurrentState() + } + ) + + Checkbox( + checked = currentState, + onCheckedChange = { + currentState = it + onState(currentState) + } + ) + } + } + + @Composable + private fun DialogHeader(searchKeyword: MutableState<String>) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + text = translation["title"], + fontSize = 23.sp, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier + .align(alignment = Alignment.CenterHorizontally) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = searchKeyword.value, + onValueChange = { searchKeyword.value = it }, + label = { + Text(text = translation["search_hint"]) + }, + modifier = Modifier + .weight(1f) + .padding(end = 10.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + leadingIcon = { + Icon(Icons.Filled.Search, contentDescription = "Search") + } + ) + } + } + + + @Composable + fun Content(dismiss: () -> Unit = { }) { + var cachedFriends by remember { mutableStateOf(null as List<MessagingFriendInfo>?) } + var cachedGroups by remember { mutableStateOf(null as List<MessagingGroupInfo>?) } + + val coroutineScope = rememberCoroutineScope() + + var timeoutJob: Job? = null + var hasFetchError by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + context.modDatabase.receiveMessagingDataCallback = { friends, groups -> + cachedFriends = friends + cachedGroups = groups + timeoutJob?.cancel() + hasFetchError = false + } + SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also { + runCatching { + context.androidContext.sendBroadcast(it) + }.onFailure { + context.log.error("Failed to send broadcast", it) + hasFetchError = true + } + } + timeoutJob = coroutineScope.launch { + withContext(Dispatchers.IO) { + delay(10000) + hasFetchError = true + } + } + } + + Dialog( + onDismissRequest = { + timeoutJob?.cancel() + dismiss() + }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + colors = CardDefaults.elevatedCardColors(), + modifier = Modifier + .fillMaxSize() + .fillMaxWidth() + .padding(all = 20.dp) + ) { + if (cachedGroups == null || cachedFriends == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (hasFetchError) { + Text( + text = translation["fetch_error"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) + ) + return@Card + } + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + return@Card + } + + val searchKeyword = remember { mutableStateOf("") } + + val filteredGroups = cachedGroups!!.takeIf { searchKeyword.value.isNotBlank() }?.filter { + it.name.contains(searchKeyword.value, ignoreCase = true) + } ?: cachedGroups!! + + val filteredFriends = cachedFriends!!.takeIf { searchKeyword.value.isNotBlank() }?.filter { + it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) || + it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true + } ?: cachedFriends!! + + DialogHeader(searchKeyword) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp) + ) { + item { + if (filteredGroups.isEmpty()) return@item + Text(text = translation["category_groups"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) + ) + } + + items(filteredGroups.size) { + val group = filteredGroups[it] + ListCardEntry( + name = group.name, + getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null } + ) { state -> + if (state) { + context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId) + } else { + context.modDatabase.deleteGroup(group.conversationId) + } + socialRoot.updateScopeLists() + } + } + + item { + if (filteredFriends.isEmpty()) return@item + Text(text = translation["category_friends"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) + ) + } + + items(filteredFriends.size) { + val friend = filteredFriends[it] + + ListCardEntry( + name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername, + getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null } + ) { state -> + if (state) { + context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId) + } else { + context.modDatabase.deleteFriend(friend.userId) + } + socialRoot.updateScopeLists() + } + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt @@ -0,0 +1,277 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.navigation.NavBackStackEntry +import coil.annotation.ExperimentalCoilApi +import coil.disk.DiskCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.StoryData +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.Dialog +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.text.DateFormat +import java.util.Date +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.math.absoluteValue + +class LoggedStories : Routes.Route() { + @OptIn(ExperimentalCoilApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> + val userId = navBackStackEntry.arguments?.getString("id") ?: return@content + + val stories = remember { + mutableStateListOf<StoryData>() + } + val friendInfo = remember { + context.modDatabase.getFriendInfo(userId) + } + val httpClient = remember { OkHttpClient() } + var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } + + var selectedStory by remember { mutableStateOf<StoryData?>(null) } + var coilCacheFile by remember { mutableStateOf<File?>(null) } + + selectedStory?.let { story -> + Dialog(onDismissRequest = { + selectedStory = null + }) { + Card( + modifier = Modifier + .padding(4.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = "Posted on ${story.postedAt.let { + DateFormat.getDateTimeInstance().format(Date(it)) + }}") + Text(text = "Created at ${story.createdAt.let { + DateFormat.getDateTimeInstance().format(Date(it)) + }}") + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { + context.androidContext.externalCacheDir?.let { cacheDir -> + val cacheFile = coilCacheFile ?: run { + context.shortToast("Failed to get file") + return@Button + } + val targetFile = File(cacheDir, cacheFile.name) + cacheFile.copyTo(targetFile, overwrite = true) + context.androidContext.startActivity(Intent().apply { + action = Intent.ACTION_VIEW + setDataAndType( + FileProvider.getUriForFile( + context.androidContext, + "me.rhunk.snapenhance.fileprovider", + targetFile + ), + FileType.fromFile(targetFile).mimeType + ) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + }) { + Text(text = "Open") + } + + Button(onClick = { + val mediaAuthor = friendInfo?.mutableUsername ?: userId + val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16) + + DownloadProcessor( + remoteSideContext = context, + callback = object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String?) { + context.shortToast("Downloaded to $outputPath") + } + + override fun onFailure(message: String?, throwable: String?) { + context.shortToast("Failed to download $message") + } + } + ).enqueue(DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = story.url, + type = DownloadMediaType.REMOTE_MEDIA, + encryption = story.key?.let { it to story.iv!! }?.toKeyPair() + ) + ) + ), DownloadMetadata( + mediaIdentifier = uniqueHash, + outputPath = createNewFilePath( + context.config.root, + uniqueHash, + MediaDownloadSource.STORY_LOGGER, + mediaAuthor, + story.createdAt + ), + iconUrl = null, + mediaAuthor = friendInfo?.mutableUsername ?: userId, + downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation), + )) + }) { + Text(text = "Download") + } + } + } + } + } + } + + if (stories.isEmpty()) { + Text(text = "No stories found", Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(100.dp), + contentPadding = PaddingValues(8.dp), + ) { + items(stories) { story -> + var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) } + val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) } + + fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean { + runCatching { + val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() + + snapshot.data.toFile().inputStream().use { inputStream -> + MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> + mediaList[type] = splitInputStream.readBytes() + } + } + + val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false + val overlay = mediaList[SplitMediaAssetType.OVERLAY] + + var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + + overlay?.also { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + imageBitmap = bitmap?.asImageBitmap() + return true + } + return false + } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + withTimeout(10000L) { + context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let { + openDiskCacheSnapshot(it) + it.close() + return@withTimeout + } + + runCatching { + val response = httpClient.newCall(Request( + url = story.url.toHttpUrl() + )).execute() + response.body.byteStream().use { + val decrypted = story.key?.let { _ -> + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv)) + CipherInputStream(it, cipher) + } ?: it + + context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply { + data.toFile().outputStream().use { fos -> + decrypted.copyTo(fos) + } + commitAndOpenSnapshot()?.use { snapshot -> + openDiskCacheSnapshot(snapshot) + snapshot.close() + } + } + } + }.onFailure { + context.log.error("Failed to load story", it) + } + } + } + } + + Column( + modifier = Modifier + .padding(8.dp) + .clickable { + selectedStory = story + coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use { + it?.data?.toFile() + } + } + .heightIn(min = 128.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + imageBitmap?.let { + Card { + Image( + bitmap = it, + modifier = Modifier.fillMaxSize(), + contentDescription = null, + ) + } + } ?: run { + CircularProgressIndicator() + } + } + } + item { + LaunchedEffect(Unit) { + context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result -> + stories.addAll(result.values) + result.keys.minOrNull()?.let { + lastStoryTimestamp = it + } + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt @@ -0,0 +1,383 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import android.content.Intent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.currentBackStackEntryAsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.BitmojiImage +import me.rhunk.snapenhance.ui.util.Dialog +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class ManageScope: Routes.Route() { + private val dialogs by lazy { AlertDialogs(context.translation) } + private val translation by lazy { context.translation.getCategory("manager.sections.social") } + + private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) { + when (scope) { + SocialScope.FRIEND -> context.modDatabase.deleteFriend(id) + SocialScope.GROUP -> context.modDatabase.deleteGroup(id) + } + context.modDatabase.executeAsync { + coroutineScope.launch { + routes.navController.popBackStack() + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ + val navBackStackEntry by routes.navController.currentBackStackEntryAsState() + var deleteConfirmDialog by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + if (deleteConfirmDialog) { + val scope = navBackStackEntry?.arguments?.getString("scope")?.let { SocialScope.getByName(it) } ?: return@topBarActions + val id = navBackStackEntry?.arguments?.getString("id")!! + + Dialog(onDismissRequest = { + deleteConfirmDialog = false + }) { + remember { AlertDialogs(context.translation) }.ConfirmDialog( + title = "Are you sure you want to delete this ${scope.key.lowercase()}?", + onDismiss = { deleteConfirmDialog = false }, + onConfirm = { + deleteScope(scope, id, coroutineScope); deleteConfirmDialog = false + } + ) + } + } + + IconButton( + onClick = { deleteConfirmDialog = true }, + ) { + Icon( + imageVector = Icons.Rounded.DeleteForever, + contentDescription = null + ) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> + val scope = SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) + val id = navBackStackEntry.arguments?.getString("id")!! + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + when (scope) { + SocialScope.FRIEND -> Friend(id) + SocialScope.GROUP -> Group(id) + } + + Spacer(modifier = Modifier.height(16.dp)) + + val rules = context.modDatabase.getRules(id) + + SectionTitle(translation["rules_title"]) + + ContentCard { + //manager anti features etc + MessagingRuleType.entries.forEach { ruleType -> + var ruleEnabled by remember { + mutableStateOf(rules.any { it.key == ruleType.key }) + } + + val ruleState = context.config.root.rules.getRuleState(ruleType) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(all = 4.dp) + ) { + Text( + text = if (ruleType.listMode && ruleState != null) { + context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"] + } else context.translation["rules.properties.${ruleType.key}.name"], + modifier = Modifier.weight(1f).padding(start = 5.dp, end = 5.dp) + ) + Switch(checked = ruleEnabled, + enabled = if (ruleType.listMode) ruleState != null else true, + onCheckedChange = { + context.modDatabase.setRule(id, ruleType.key, it) + ruleEnabled = it + }) + } + } + } + } + } + + @Composable + private fun ContentCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Card( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + .then(modifier) + ) { + content() + } + } + } + + @Composable + private fun SectionTitle(title: String) { + Text( + text = title, + maxLines = 1, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .offset(x = 20.dp) + .padding(bottom = 10.dp) + ) + } + + //need to display all units? + private fun computeStreakETA(timestamp: Long): String { + val now = System.currentTimeMillis() + val stringBuilder = StringBuilder() + val diff = timestamp - now + val seconds = diff / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + val days = hours / 24 + if (days > 0) { + stringBuilder.append("$days day ") + return stringBuilder.toString() + } + if (hours > 0) { + stringBuilder.append("$hours hours ") + return stringBuilder.toString() + } + if (minutes > 0) { + stringBuilder.append("$minutes minutes ") + return stringBuilder.toString() + } + if (seconds > 0) { + stringBuilder.append("$seconds seconds ") + return stringBuilder.toString() + } + return "Expired" + } + + @OptIn(ExperimentalEncodingApi::class) + @Composable + private fun Friend(id: String) { + //fetch the friend from the database + val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { + Text(text = translation["not_found"]) + return + } + + val streaks = remember { + context.modDatabase.getFriendStreaks(id) + } + + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie( + friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D + ) + BitmojiImage(context = context, url = bitmojiUrl, size = 100) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = friend.displayName ?: friend.mutableUsername, + maxLines = 1, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = friend.mutableUsername, + maxLines = 1, + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (context.config.root.experimental.storyLogger.get()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + ) { + Button(onClick = { + routes.loggedStories.navigate { + put("id", id) + } + }) { + Text("Show Logged Stories") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + Column { + //streaks + streaks?.let { + var shouldNotify by remember { mutableStateOf(it.notify) } + SectionTitle(translation["streaks_title"]) + ContentCard { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = translation.format( + "streaks_length_text", "length" to streaks.length.toString() + ), maxLines = 1 + ) + Text( + text = translation.format( + "streaks_expiration_text", + "eta" to computeStreakETA(streaks.expirationTimestamp) + ), maxLines = 1 + ) + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = translation["reminder_button"], + maxLines = 1, + modifier = Modifier.padding(end = 10.dp) + ) + Switch(checked = shouldNotify, onCheckedChange = { + context.modDatabase.setFriendStreaksNotify(id, it) + shouldNotify = it + }) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + // e2ee section + + if (context.config.root.experimental.e2eEncryption.globalState == true) { + SectionTitle(translation["e2ee_title"]) + var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))} + var importDialog by remember { mutableStateOf(false) } + + if (importDialog) { + Dialog( + onDismissRequest = { importDialog = false } + ) { + dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey -> + importDialog = false + runCatching { + val key = Base64.decode(newKey) + if (key.size != 32) { + context.longToast("Invalid key size (must be 32 bytes)") + return@runCatching + } + + context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) + context.longToast("Successfully imported key") + hasSecretKey = true + }.onFailure { + context.longToast("Failed to import key: ${it.message}") + context.log.error("Failed to import key", it) + } + }) + } + } + + ContentCard { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (hasSecretKey) { + OutlinedButton(onClick = { + val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton) + //TODO: fingerprint auth + context.activity!!.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, secretKey) + type = "text/plain" + }, "").apply { + putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( + Intent().apply { + putExtra(Intent.EXTRA_TEXT, secretKey) + putExtra(Intent.EXTRA_SUBJECT, secretKey) + }) + ) + }) + }) { + Text( + text = "Export Base64", + maxLines = 1 + ) + } + } + + OutlinedButton(onClick = { importDialog = true }) { + Text( + text = "Import Base64", + maxLines = 1 + ) + } + } + } + } + } + } + + @Composable + private fun Group(id: String) { + //fetch the group from the database + val group = remember { context.modDatabase.getGroupInfo(id) } ?: run { + Text(text = translation["not_found"]) + return + } + + + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = translation.format( + "participants_text", "count" to group.participantsCount.toString() + ), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light + ) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt @@ -0,0 +1,536 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.BookmarkAdded +import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.RemoveRedEye +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.* +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener +import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.messaging.MessagingConstraints +import me.rhunk.snapenhance.messaging.MessagingTask +import me.rhunk.snapenhance.messaging.MessagingTaskConstraint +import me.rhunk.snapenhance.messaging.MessagingTaskType +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.Dialog +import java.util.SortedMap + +class MessagingPreview: Routes.Route() { + private lateinit var coroutineScope: CoroutineScope + private lateinit var messagingBridge: MessagingBridge + private lateinit var previewScrollState: LazyListState + + private val myUserId by lazy { messagingBridge.myUserId } + private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") } + + private var messages = sortedMapOf<Long, Message>() + private var messageSize by mutableIntStateOf(0) + private var conversationId by mutableStateOf<String?>(null) + private val selectedMessages = mutableStateListOf<Long>() // client message id + + private fun toggleSelectedMessage(messageId: Long) { + if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId) + else selectedMessages.add(messageId) + } + + @Composable + private fun ActionButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + ) { + DropdownMenuItem( + onClick = onClick, + text = { + Row( + modifier = Modifier.padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null + ) + Text(text = text) + } + } + ) + } + + @Composable + private fun ConstraintsSelectionDialog( + onChoose: (Array<ContentType>) -> Unit, + onDismiss: () -> Unit + ) { + val selectedTypes = remember { mutableStateListOf<ContentType>() } + var selectAllState by remember { mutableStateOf(false) } + val availableTypes = remember { arrayOf( + ContentType.CHAT, + ContentType.NOTE, + ContentType.SNAP, + ContentType.STICKER, + ContentType.EXTERNAL_MEDIA + ) } + + fun toggleContentType(contentType: ContentType) { + if (selectAllState) return + if (selectedTypes.contains(contentType)) { + selectedTypes.remove(contentType) + } else { + selectedTypes.add(contentType) + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(15.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text("Choose content types to process") + Spacer(modifier = Modifier.height(5.dp)) + availableTypes.forEach { contentType -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp) + .pointerInput(Unit) { + detectTapGestures(onTap = { toggleContentType(contentType) }) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selectedTypes.contains(contentType), + enabled = !selectAllState, + onCheckedChange = { toggleContentType(contentType) } + ) + Text(text = contentType.toString()) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Switch(checked = selectAllState, onCheckedChange = { + selectAllState = it + }) + Text(text = "Select all") + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { onDismiss() }) { + Text("Cancel") + } + Button(onClick = { + onChoose(if (selectAllState) ContentType.entries.toTypedArray() + else selectedTypes.toTypedArray()) + }) { + Text("Continue") + } + } + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + var taskSelectionDropdown by remember { mutableStateOf(false) } + var selectConstraintsDialog by remember { mutableStateOf(false) } + var activeTask by remember { mutableStateOf(null as MessagingTask?) } + var activeJob by remember { mutableStateOf(null as Job?) } + val processMessageCount = remember { mutableIntStateOf(0) } + + fun runCurrentTask() { + activeJob = coroutineScope.launch(Dispatchers.IO) { + activeTask?.run() + withContext(Dispatchers.Main) { + activeTask = null + activeJob = null + } + }.also { job -> + job.invokeOnCompletion { + if (it != null) { + context.log.verbose("Failed to process messages: ${it.message}") + return@invokeOnCompletion + } + context.longToast("Processed ${processMessageCount.intValue} messages") + } + } + } + + fun launchMessagingTask(taskType: MessagingTaskType, constraints: List<MessagingTaskConstraint> = listOf(), onSuccess: (Message) -> Unit = {}) { + taskSelectionDropdown = false + processMessageCount.intValue = 0 + activeTask = MessagingTask( + messagingBridge, conversationId!!, taskType, constraints, + overrideClientMessageIds = selectedMessages.takeIf { it.isNotEmpty() }?.toList(), + processedMessageCount = processMessageCount, + onSuccess = onSuccess, + onFailure = { message, reason -> + context.log.verbose("Failed to process message ${message.clientMessageId}: $reason") + } + ) + selectedMessages.clear() + } + + if (selectConstraintsDialog && activeTask != null) { + Dialog(onDismissRequest = { + selectConstraintsDialog = false + activeTask = null + }) { + ConstraintsSelectionDialog( + onChoose = { contentTypes -> + launchMessagingTask( + taskType = activeTask!!.taskType, + constraints = activeTask!!.constraints + MessagingConstraints.CONTENT_TYPE(contentTypes), + onSuccess = activeTask!!.onSuccess + ) + runCurrentTask() + selectConstraintsDialog = false + }, + onDismiss = { + selectConstraintsDialog = false + activeTask = null + } + ) + } + } + + if (activeJob != null) { + Dialog(onDismissRequest = { + activeJob?.cancel() + activeJob = null + activeTask = null + }) { + Column(modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(15.dp) + .border(1.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(20.dp)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp)) + { + Text("Processed ${processMessageCount.intValue} messages") + if (activeTask?.hasFixedGoal() == true) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp), + progress = processMessageCount.intValue.toFloat() / selectedMessages.size.toFloat(), + color = MaterialTheme.colorScheme.primary + ) + } else { + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + + IconButton(onClick = { taskSelectionDropdown = !taskSelectionDropdown }) { + Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null) + } + + if (selectedMessages.isNotEmpty()) { + IconButton(onClick = { selectedMessages.clear() }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "Close") + } + } + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = MaterialTheme.colorScheme.inverseSurface, + onSurface = MaterialTheme.colorScheme.inverseOnSurface + ), + shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(50.dp)) + ) { + DropdownMenu( + expanded = taskSelectionDropdown, onDismissRequest = { taskSelectionDropdown = false } + ) { + val hasSelection = selectedMessages.isNotEmpty() + ActionButton(text = if (hasSelection) "Save selection" else "Save all", icon = Icons.Rounded.BookmarkAdded) { + launchMessagingTask(MessagingTaskType.SAVE) + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true + } + ActionButton(text = if (hasSelection) "Unsave selection" else "Unsave all", icon = Icons.Rounded.BookmarkBorder) { + launchMessagingTask(MessagingTaskType.UNSAVE) + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true + } + ActionButton(text = if (hasSelection) "Mark selected Snap as seen" else "Mark all Snaps as seen", icon = Icons.Rounded.RemoveRedEye) { + launchMessagingTask(MessagingTaskType.READ, listOf( + MessagingConstraints.NO_USER_ID(myUserId), + MessagingConstraints.CONTENT_TYPE(arrayOf(ContentType.SNAP)) + )) + runCurrentTask() + } + ActionButton(text = if (hasSelection) "Delete selected" else "Delete all", icon = Icons.Rounded.DeleteForever) { + launchMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(myUserId))) { message -> + coroutineScope.launch { + messages.remove(message.serverMessageId) + messageSize = messages.size + } + } + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true + } + } + } + } + + @Composable + private fun ConversationPreview( + messages: SortedMap<Long, Message>, + messageSize: Int, + fetchNewMessages: () -> Unit + ) { + DisposableEffect(Unit) { + onDispose { + selectedMessages.clear() + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize(), + state = previewScrollState, + ) { + item { + if (messages.isEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + horizontalArrangement = Arrangement.Center + ) { + Text("No messages") + } + } + Spacer(modifier = Modifier.height(20.dp)) + + LaunchedEffect(Unit) { + if (messages.size > 0) { + fetchNewMessages() + } + } + } + items(messageSize) {index -> + val elementKey = remember(index) { messages.entries.elementAt(index).value.clientMessageId } + val messageReader = ProtoReader(messages.entries.elementAt(index).value.content) + val contentType = ContentType.fromMessageContainer(messageReader) + + Card( + modifier = Modifier + .padding(5.dp) + .pointerInput(Unit) { + if (contentType == ContentType.STATUS) return@pointerInput + detectTapGestures( + onLongPress = { + toggleSelectedMessage(elementKey) + }, + onTap = { + if (selectedMessages.isNotEmpty()) { + toggleSelectedMessage(elementKey) + } + } + ) + }, + colors = CardDefaults.cardColors( + containerColor = if (selectedMessages.contains(elementKey)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + ), + ) { + Row( + modifier = Modifier + .padding(5.dp) + ) { + + Text("[${contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } }] ${messageReader.getString(2, 1) ?: ""}") + } + } + } + } + } + + + @Composable + private fun LoadingRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(40.dp), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier + .padding() + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry -> + val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) } + val id = remember { navBackStackEntry.arguments?.getString("id")!! } + + previewScrollState = rememberLazyListState() + coroutineScope = rememberCoroutineScope() + + var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) } + var isBridgeConnected by remember { mutableStateOf(false) } + var hasBridgeError by remember { mutableStateOf(false) } + + fun fetchNewMessages() { + coroutineScope.launch(Dispatchers.IO) cs@{ + runCatching { + val queriedMessages = messagingBridge.fetchConversationWithMessagesPaginated( + conversationId!!, + 20, + lastMessageId + ) + + if (queriedMessages == null) { + context.shortToast("Failed to fetch messages") + return@cs + } + + withContext(Dispatchers.Main) { + messages.putAll(queriedMessages.map { it.serverMessageId to it }) + messageSize = messages.size + if (queriedMessages.isNotEmpty()) { + lastMessageId = queriedMessages.first().clientMessageId + delay(20) + previewScrollState.scrollToItem(queriedMessages.size - 1) + } + } + }.onFailure { + context.log.error("Failed to fetch messages", it) + context.shortToast("Failed to fetch messages: ${it.message}") + } + context.log.verbose("fetched ${messages.size} messages") + } + } + + fun onMessagingBridgeReady(scope: SocialScope, scopeId: String) { + context.log.verbose("onMessagingBridgeReady: $scope $scopeId") + + runCatching { + messagingBridge = context.bridgeService!!.messagingBridge!! + conversationId = if (scope == SocialScope.FRIEND) messagingBridge.getOneToOneConversationId(scopeId) else scopeId + if (conversationId == null) { + context.longToast("Failed to fetch conversation id") + return + } + if (runCatching { !messagingBridge.isSessionStarted }.getOrDefault(true)) { + context.androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + )?.let { + val mainIntent = Intent.makeRestartActivityTask(it.component).apply { + putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true) + } + context.androidContext.startActivity(mainIntent) + } + messagingBridge.registerSessionStartListener(object: SessionStartListener.Stub() { + override fun onConnected() { + fetchNewMessages() + } + }) + return + } + fetchNewMessages() + }.onFailure { + context.longToast("Failed to initialize messaging bridge") + context.log.error("Failed to initialize messaging bridge", it) + } + } + + LaunchedEffect(Unit) { + messages.clear() + messageSize = 0 + conversationId = null + + isBridgeConnected = context.hasMessagingBridge() + if (isBridgeConnected) { + onMessagingBridgeReady(scope, id) + } else { + SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also { + context.androidContext.sendBroadcast(it) + } + coroutineScope.launch(Dispatchers.IO) { + withTimeout(10000) { + while (!context.hasMessagingBridge()) { + delay(100) + } + isBridgeConnected = true + onMessagingBridgeReady(scope, id) + } + }.invokeOnCompletion { + if (it != null) { + hasBridgeError = true + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + if (hasBridgeError) { + Text("Failed to connect to Snapchat through bridge service") + } + + if (!isBridgeConnected && !hasBridgeError) { + LoadingRow() + } + + if (isBridgeConnected && !hasBridgeError) { + ConversationPreview(messages, messageSize, ::fetchNewMessages) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt @@ -0,0 +1,269 @@ +package me.rhunk.snapenhance.ui.manager.pages.social + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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 androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.BitmojiImage +import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset + +class SocialRoot : Routes.Route() { + private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList()) + private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList()) + + fun updateScopeLists() { + context.coroutineScope.launch(Dispatchers.IO) { + friendList = context.modDatabase.getFriends(descOrder = true) + groupList = context.modDatabase.getGroups() + } + } + + private val addFriendDialog by lazy { + AddFriendDialog(context, this) + } + + @Composable + private fun ScopeList(scope: SocialScope) { + val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() } + + LazyColumn( + modifier = Modifier + .padding(2.dp) + .fillMaxWidth() + .fillMaxHeight(), + contentPadding = PaddingValues(bottom = 110.dp), + ) { + //check if scope list is empty + val listSize = when (scope) { + SocialScope.GROUP -> groupList.size + SocialScope.FRIEND -> friendList.size + } + + if (listSize == 0) { + item { + Text( + text = "(empty)", modifier = Modifier + .fillMaxWidth() + .padding(10.dp), textAlign = TextAlign.Center + ) + } + } + + items(listSize) { index -> + val id = when (scope) { + SocialScope.GROUP -> groupList[index].conversationId + SocialScope.FRIEND -> friendList[index].userId + } + + Card( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + .height(80.dp) + .clickable { + routes.manageScope.navigate { + put("id", id) + put("scope", scope.key) + } + }, + ) { + Row( + modifier = Modifier + .padding(10.dp) + .fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + when (scope) { + SocialScope.GROUP -> { + val group = groupList[index] + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = group.name, + maxLines = 1, + fontWeight = FontWeight.Bold + ) + } + } + + SocialScope.FRIEND -> { + val friend = friendList[index] + var streaks by remember { mutableStateOf(friend.streaks) } + + LaunchedEffect(friend.userId) { + withContext(Dispatchers.IO) { + streaks = context.modDatabase.getFriendStreaks(friend.userId) + } + } + + BitmojiImage( + context = context, + url = BitmojiSelfie.getBitmojiSelfie( + friend.selfieId, + friend.bitmojiId, + BitmojiSelfie.BitmojiSelfieType.THREE_D + ) + ) + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth() + .weight(1f) + ) { + Text( + text = friend.displayName ?: friend.mutableUsername, + maxLines = 1, + fontWeight = FontWeight.Bold + ) + Text( + text = friend.mutableUsername, + maxLines = 1, + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + streaks?.takeIf { it.notify }?.let { streaks -> + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon), + contentDescription = null, + modifier = Modifier.height(40.dp), + tint = if (streaks.isAboutToExpire(remainingHours)) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + ) + Text( + text = context.translation.format( + "manager.sections.social.streaks_expiration_short", + "hours" to (((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().takeIf { it > 0 } ?: 0) + .toString() + ), + maxLines = 1, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + FilledIconButton(onClick = { + routes.messagingPreview.navigate { + put("id", id) + put("scope", scope.key) + } + }) { + Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null) + } + } + } + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + val titles = listOf("Friends", "Groups") + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { titles.size } + var showAddFriendDialog by remember { mutableStateOf(false) } + + if (showAddFriendDialog) { + addFriendDialog.Content { + showAddFriendDialog = false + } + } + + LaunchedEffect(Unit) { + updateScopeLists() + } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { + showAddFriendDialog = true + }, + modifier = Modifier.padding(10.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null + ) + } + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.pagerTabIndicatorOffset( + pagerState = pagerState, + tabPositions = tabPositions + ) + ) + }) { + titles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + Text( + text = title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + HorizontalPager( + modifier = Modifier.padding(paddingValues), + state = pagerState + ) { page -> + when (page) { + 0 -> ScopeList(SocialScope.FRIEND) + 1 -> ScopeList(SocialScope.GROUP) + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/DebugSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/DebugSection.kt @@ -1,4 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections - -class DebugSection { -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt @@ -1,23 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import me.rhunk.snapenhance.ui.manager.Section - -class NotImplemented : Section() { - @Composable - override fun Content() { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = "Not implemented yet!") - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt @@ -1,456 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections - - import android.content.Intent -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.Lifecycle -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.bridge.DownloadCallback -import me.rhunk.snapenhance.common.data.download.DownloadMetadata -import me.rhunk.snapenhance.common.data.download.MediaDownloadSource -import me.rhunk.snapenhance.common.data.download.createNewFilePath -import me.rhunk.snapenhance.common.util.ktx.longHashCode -import me.rhunk.snapenhance.download.DownloadProcessor -import me.rhunk.snapenhance.download.FFMpegProcessor -import me.rhunk.snapenhance.task.PendingTask -import me.rhunk.snapenhance.task.PendingTaskListener -import me.rhunk.snapenhance.task.Task -import me.rhunk.snapenhance.task.TaskStatus -import me.rhunk.snapenhance.task.TaskType -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.util.OnLifecycleEvent -import java.io.File -import java.util.UUID -import kotlin.math.absoluteValue - -class TasksSection : Section() { - private var activeTasks by mutableStateOf(listOf<PendingTask>()) - private lateinit var recentTasks: MutableList<Task> - private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() - - private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { - scope.launch(Dispatchers.IO) { - activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() - } - } - - private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) { - val firstTask = selection.first().first - - val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) - val pendingTask = context.taskManager.createPendingTask( - Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash) - ) - pendingTask.status = TaskStatus.RUNNING - fetchActiveTasks() - - context.coroutineScope.launch { - val filesToMerge = mutableListOf<File>() - - selection.forEach { (task, documentFile) -> - val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also { - it.deleteOnExit() - } - - runCatching { - pendingTask.updateProgress("Copying ${documentFile.name}") - context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> - //copy with progress - val length = documentFile.length().toFloat() - tempFile.outputStream().use { outputStream -> - val buffer = ByteArray(16 * 1024) - var read: Int - while (inputStream.read(buffer).also { read = it } != -1) { - outputStream.write(buffer, 0, read) - pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt()) - } - outputStream.flush() - filesToMerge.add(tempFile) - } - } - }.onFailure { - pendingTask.fail("Failed to copy file $documentFile to $tempFile") - filesToMerge.forEach { it.delete() } - return@launch - } - } - - val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also { - it.deleteOnExit() - } - - runCatching { - context.shortToast("Merging ${filesToMerge.size} files") - FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute( - FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge, mergedFile) - ) - DownloadProcessor(context, object: DownloadCallback.Default() { - override fun onSuccess(outputPath: String) { - context.log.verbose("Merged files to $outputPath") - } - }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata( - mediaIdentifier = taskHash, - outputPath = createNewFilePath( - context.config.root, - taskHash, - downloadSource = MediaDownloadSource.MERGED, - mediaAuthor = firstTask.author, - creationTimestamp = System.currentTimeMillis() - ), - mediaAuthor = firstTask.author, - downloadSource = MediaDownloadSource.MERGED.translate(context.translation), - iconUrl = null - )) - }.onFailure { - context.log.error("Failed to merge files", it) - pendingTask.fail(it.message ?: "Failed to merge files") - }.onSuccess { - pendingTask.success() - } - filesToMerge.forEach { it.delete() } - mergedFile.delete() - }.also { - pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() })) - } - } - - - @Composable - override fun TopBarActions(rowScope: RowScope) { - var showConfirmDialog by remember { mutableStateOf(false) } - - if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { - taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.let { documentFile -> - IconButton(onClick = { - runCatching { - context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { - setDataAndType(documentFile.uri, documentFile.type) - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK - }) - taskSelection.clear() - }.onFailure { - context.log.error("Failed to open file ${taskSelection.first().second}", it) - } - }) { - Icon(Icons.Filled.OpenInNew, contentDescription = "Open") - } - } - } - - if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { - IconButton(onClick = { - mergeSelection(taskSelection.toList().also { - taskSelection.clear() - }.map { it.first to it.second!! }) - }) { - Icon(Icons.Filled.Merge, contentDescription = "Merge") - } - } - - IconButton(onClick = { - showConfirmDialog = true - }) { - Icon(Icons.Filled.Delete, contentDescription = "Clear tasks") - } - - if (showConfirmDialog) { - var alsoDeleteFiles by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = { showConfirmDialog = false }, - title = { - if (taskSelection.isNotEmpty()) { - Text("Remove ${taskSelection.size} tasks?") - } else { - Text("Remove all tasks?") - } - }, - text = { - Column { - if (taskSelection.isNotEmpty()) { - Text("Are you sure you want to remove selected tasks?") - Row ( - modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable { - alsoDeleteFiles = !alsoDeleteFiles - }, - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox(checked = alsoDeleteFiles, onCheckedChange = { - alsoDeleteFiles = it - }) - Text("Also delete files") - } - } else { - Text("Are you sure you want to remove all tasks?") - } - } - }, - confirmButton = { - Button( - onClick = { - showConfirmDialog = false - - if (taskSelection.isNotEmpty()) { - taskSelection.forEach { (task, documentFile) -> - context.taskManager.removeTask(task) - recentTasks.remove(task) - if (alsoDeleteFiles) { - documentFile?.delete() - } - } - activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } - taskSelection.clear() - } else { - context.taskManager.clearAllTasks() - recentTasks.clear() - activeTasks.forEach { - runCatching { - it.cancel() - }.onFailure { throwable -> - context.log.error("Failed to cancel task $it", throwable) - } - } - activeTasks = listOf() - context.taskManager.getActiveTasks().clear() - } - } - ) { - Text("Yes") - } - }, - dismissButton = { - Button( - onClick = { - showConfirmDialog = false - } - ) { - Text("No") - } - } - ) - } - } - - @Composable - private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { - var taskStatus by remember { mutableStateOf(task.status) } - var taskProgressLabel by remember { mutableStateOf<String?>(null) } - var taskProgress by remember { mutableIntStateOf(-1) } - val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } - var documentFile by remember { mutableStateOf<DocumentFile?>(null) } - var isDocumentFileReadable by remember { mutableStateOf(true) } - - LaunchedEffect(taskStatus.key) { - launch(Dispatchers.IO) { - documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch) - isDocumentFileReadable = documentFile?.canRead() ?: false - } - } - - val listener = remember { PendingTaskListener( - onStateChange = { - taskStatus = it - }, - onProgress = { label, progress -> - taskProgressLabel = label - taskProgress = progress - } - ) } - - LaunchedEffect(Unit) { - pendingTask?.addListener(listener) - } - - DisposableEffect(Unit) { - onDispose { - pendingTask?.removeListener(listener) - } - } - - OutlinedCard(modifier = modifier.clickable { - if (isSelected) { - taskSelection.removeIf { it.first == task } - return@clickable - } - taskSelection.add(task to documentFile) - }.let { - if (isSelected) { - it - .border(2.dp, MaterialTheme.colorScheme.primary) - .clip(MaterialTheme.shapes.medium) - } else it - }) { - Row( - modifier = Modifier.padding(15.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.padding(end = 15.dp) - ) { - documentFile?.let { file -> - val mimeType = file.type ?: "" - when { - !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") - mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") - mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") - mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") - else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") - } - } ?: run { - when (task.type) { - TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") - TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") - } - } - } - Column( - modifier = Modifier.weight(1f), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text(task.title, style = MaterialTheme.typography.bodyMedium) - task.author?.takeIf { it != "null" }?.let { - Spacer(modifier = Modifier.width(5.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - Text(task.hash, style = MaterialTheme.typography.labelSmall) - Column( - modifier = Modifier.padding(top = 5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - if (taskStatus.isFinalStage()) { - if (taskStatus != TaskStatus.SUCCESS) { - Text("$taskStatus", style = MaterialTheme.typography.bodySmall) - } - } else { - taskProgressLabel?.let { - Text(it, style = MaterialTheme.typography.bodySmall) - } - if (taskProgress != -1) { - LinearProgressIndicator( - progress = taskProgress.toFloat() / 100f, - strokeCap = StrokeCap.Round - ) - } else { - task.extra?.let { - Text(it, style = MaterialTheme.typography.bodySmall) - } - } - } - } - } - - Column { - if (pendingTask != null && !taskStatus.isFinalStage()) { - FilledIconButton(onClick = { - runCatching { - pendingTask.cancel() - }.onFailure { throwable -> - context.log.error("Failed to cancel task $pendingTask", throwable) - } - }) { - Icon(Icons.Filled.Close, contentDescription = "Cancel") - } - } else { - when (taskStatus) { - TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary) - TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error) - TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error) - else -> {} - } - } - } - } - } - } - - @Preview - @Composable - override fun Content() { - val scrollState = rememberLazyListState() - val scope = rememberCoroutineScope() - recentTasks = remember { mutableStateListOf() } - var lastFetchedTaskId: Long? by remember { mutableStateOf(null) } - - fun fetchNewRecentTasks() { - scope.launch(Dispatchers.IO) { - val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE) - if (tasks.isNotEmpty()) { - lastFetchedTaskId = tasks.keys.last() - val activeTaskIds = activeTasks.map { it.taskId } - recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) - } - } - } - - LaunchedEffect(Unit) { - fetchActiveTasks(this) - } - - DisposableEffect(Unit) { - onDispose { - taskSelection.clear() - } - } - - OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - fetchActiveTasks(scope) - } - } - - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxSize() - ) { - item { - if (activeTasks.isEmpty() && recentTasks.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - context.translation["manager.sections.tasks.no_tasks"].let { - Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary) - Text(it, style = MaterialTheme.typography.bodyLarge) - } - } - } - } - items(activeTasks, key = { it.taskId }) {pendingTask -> - TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask) - } - items(recentTasks, key = { it.hash }) { task -> - TaskCard(modifier = Modifier.padding(8.dp), task) - } - item { - Spacer(modifier = Modifier.height(20.dp)) - LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { - fetchNewRecentTasks() - } - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/CallbackAlias.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/CallbackAlias.kt @@ -1,4 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.features - -typealias ClickCallback = (Boolean) -> Unit -typealias RegisterClickCallback = (ClickCallback) -> ClickCallback- \ 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 @@ -1,589 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.features - -import android.content.Intent -import android.net.Uri -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.rounded.Save -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.common.config.* -import me.rhunk.snapenhance.ui.manager.MainActivity -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.util.* - -@OptIn(ExperimentalMaterial3Api::class) -class FeaturesSection : Section() { - private val alertDialogs by lazy { AlertDialogs(context.translation) } - - companion object { - const val MAIN_ROUTE = "feature_root" - const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}" - const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}" - } - - - private var activityLauncherHelper: ActivityLauncherHelper? = null - private val featuresRouteName by lazy { context.translation["manager.routes.features"] } - - private lateinit var rememberScaffoldState: BottomSheetScaffoldState - - private val allContainers by lazy { - val containers = mutableMapOf<String, PropertyPair<*>>() - fun queryContainerRecursive(container: ConfigContainer) { - container.properties.forEach { - if (it.key.dataType.type == DataProcessors.Type.CONTAINER) { - containers[it.key.name] = PropertyPair(it.key, it.value) - queryContainerRecursive(it.value.get() as ConfigContainer) - } - } - } - queryContainerRecursive(context.config.root) - containers - } - - private val allProperties by lazy { - val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() - allContainers.values.forEach { - val container = it.value.get() as ConfigContainer - container.properties.forEach { property -> - properties[property.key] = property.value - } - } - properties - } - - private fun navigateToMainRoot() { - navController.navigate(MAIN_ROUTE, NavOptions.Builder() - .setPopUpTo(navController.graph.findStartDestination().id, false) - .setLaunchSingleTop(true) - .build() - ) - } - - override fun canGoBack() = sectionTopBarName() != featuresRouteName - - override fun sectionTopBarName(): String { - navController.currentBackStackEntry?.arguments?.getString("name")?.let { routeName -> - val currentContainerPair = allContainers[routeName] - return context.translation["${currentContainerPair?.key?.propertyTranslationPath()}.name"] - } - return featuresRouteName - } - - override fun init() { - activityLauncherHelper = ActivityLauncherHelper(context.activity!!) - } - - private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { - activityLauncherHelper?.let(block) ?: run { - //open manager if activity launcher is null - val intent = Intent(context.androidContext, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra("route", enumSection.route) - context.androidContext.startActivity(intent) - } - } - - override fun build(navGraphBuilder: NavGraphBuilder) { - navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { - composable(MAIN_ROUTE) { - Container(context.config.root) - } - - composable(FEATURE_CONTAINER_ROUTE, enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100)) - }, exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) - }) { backStackEntry -> - backStackEntry.arguments?.getString("name")?.let { containerName -> - allContainers[containerName]?.let { - Container(it.value.get() as ConfigContainer) - } - } - } - - composable(SEARCH_FEATURE_ROUTE) { backStackEntry -> - backStackEntry.arguments?.getString("keyword")?.let { keyword -> - val properties = allProperties.filter { - it.key.name.contains(keyword, ignoreCase = true) || - context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || - context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) - }.map { PropertyPair(it.key, it.value) } - - PropertiesView(properties) - } - } - } - } - - @Composable - private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { - var showDialog by remember { mutableStateOf(false) } - var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } - - fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true } - - if (showDialog) { - Dialog( - properties = DialogProperties( - usePlatformDefaultWidth = false - ), - onDismissRequest = { showDialog = false }, - ) { - dialogComposable() - } - } - - val propertyValue = property.value - - if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { - IconButton(onClick = registerClickCallback { - activityLauncher { - chooseFolder { uri -> - propertyValue.setAny(uri) - } - } - }.let { { it.invoke(true) } }) { - Icon(Icons.Filled.FolderOpen, contentDescription = null) - } - return - } - - when (val dataType = remember { property.key.dataType.type }) { - DataProcessors.Type.BOOLEAN -> { - var state by remember { mutableStateOf(propertyValue.get() as Boolean) } - Switch( - checked = state, - onCheckedChange = registerClickCallback { - state = state.not() - propertyValue.setAny(state) - } - ) - } - - DataProcessors.Type.MAP_COORDINATES -> { - registerDialogOnClickCallback() - dialogComposable = { - alertDialogs.ChooseLocationDialog(property) { - showDialog = false - } - } - - Text( - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier.widthIn(0.dp, 120.dp), - text = (propertyValue.get() as Pair<*, *>).let { - "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}" - } - ) - } - - DataProcessors.Type.STRING_UNIQUE_SELECTION -> { - registerDialogOnClickCallback() - - dialogComposable = { - alertDialogs.UniqueSelectionDialog(property) - } - - Text( - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier.widthIn(0.dp, 120.dp), - text = (propertyValue.getNullable() as? String ?: "null").let { - property.key.propertyOption(context.translation, it) - } - ) - } - - DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { - dialogComposable = { - when (dataType) { - DataProcessors.Type.STRING_MULTIPLE_SELECTION -> { - alertDialogs.MultipleSelectionDialog(property) - } - DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { - alertDialogs.KeyboardInputDialog(property) { showDialog = false } - } - else -> {} - } - } - - registerDialogOnClickCallback().let { { it.invoke(true) } }.also { - if (dataType == DataProcessors.Type.INTEGER || - dataType == DataProcessors.Type.FLOAT) { - FilledIconButton(onClick = it) { - Text( - text = propertyValue.get().toString(), - modifier = Modifier.wrapContentWidth(), - overflow = TextOverflow.Ellipsis - ) - } - } else { - IconButton(onClick = it) { - Icon(Icons.Filled.OpenInNew, contentDescription = null) - } - } - } - } - DataProcessors.Type.CONTAINER -> { - val container = propertyValue.get() as ConfigContainer - - registerClickCallback { - navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) - } - - if (!container.hasGlobalState) return - - var state by remember { mutableStateOf(container.globalState ?: false) } - - Box( - modifier = Modifier - .padding(end = 15.dp), - ) { - - Box(modifier = Modifier - .height(50.dp) - .width(1.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - shape = RoundedCornerShape(5.dp) - )) - } - - Switch( - checked = state, - onCheckedChange = { - state = state.not() - container.globalState = state - } - ) - } - } - - } - - @Composable - private fun PropertyCard(property: PropertyPair<*>) { - var clickCallback by remember { mutableStateOf<ClickCallback?>(null) } - val noticeColorMap = mapOf( - FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87), - FeatureNotice.BAN_RISK.key to Color(0xFFFF8585), - FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87), - FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722), - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) - ) { - Row( - modifier = Modifier - .fillMaxSize() - .clickable { - clickCallback?.invoke(true) - } - .padding(all = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - property.key.params.icon?.let { iconName -> - //TODO: find a better way to load icons - val icon: ImageVector? = remember(iconName) { - runCatching { - val cl = Class.forName("androidx.compose.material.icons.filled.${iconName}Kt") - val method = cl.declaredMethods.first() - method.invoke(null, Icons.Filled) as ImageVector - }.getOrNull() - } - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = 10.dp) - ) - } - } - - Column( - modifier = Modifier - .align(Alignment.CenterVertically) - .weight(1f, fill = true) - .padding(all = 10.dp) - ) { - Text( - text = context.translation[property.key.propertyName()], - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - Text( - text = context.translation[property.key.propertyDescription()], - fontSize = 12.sp, - lineHeight = 15.sp - ) - property.key.params.notices.also { - if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp)) - }.forEach { - Text( - text = context.translation["features.notices.${it.key}"], - color = noticeColorMap[it.key] ?: Color(0xFFFFFB87), - fontSize = 12.sp, - lineHeight = 15.sp - ) - } - } - Row( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(all = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PropertyAction(property, registerClickCallback = { callback -> - clickCallback = callback - callback - }) - } - } - } - } - - @Composable - private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) { - var searchValue by remember { mutableStateOf("") } - val scope = rememberCoroutineScope() - var currentSearchJob by remember { mutableStateOf<Job?>(null) } - - rowScope.apply { - TextField( - value = searchValue, - onValueChange = { keyword -> - searchValue = keyword - if (keyword.isEmpty()) { - navigateToMainRoot() - return@TextField - } - currentSearchJob?.cancel() - scope.launch { - delay(300) - navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() - .setLaunchSingleTop(true) - .setPopUpTo(MAIN_ROUTE, false) - .build() - ) - }.also { currentSearchJob = it } - }, - - keyboardActions = KeyboardActions(onDone = { - focusRequester.freeFocus() - }), - modifier = Modifier - .focusRequester(focusRequester) - .weight(1f, fill = true) - .padding(end = 10.dp) - .height(70.dp), - singleLine = true, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedContainerColor = MaterialTheme.colorScheme.surface, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ) - ) - } - } - - @Composable - override fun TopBarActions(rowScope: RowScope) { - var showSearchBar by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - - if (showSearchBar) { - FeatureSearchBar(rowScope, focusRequester) - LaunchedEffect(true) { - focusRequester.requestFocus() - } - } - - IconButton(onClick = { - showSearchBar = showSearchBar.not() - if (!showSearchBar && currentRoute == SEARCH_FEATURE_ROUTE) { - navigateToMainRoot() - } - }) { - Icon( - imageVector = if (showSearchBar) Icons.Filled.Close - else Icons.Filled.Search, - contentDescription = null - ) - } - - if (showSearchBar) return - - var showExportDropdownMenu by remember { mutableStateOf(false) } - var showResetConfirmationDialog by remember { mutableStateOf(false) } - - if (showResetConfirmationDialog) { - Dialog(onDismissRequest = { showResetConfirmationDialog = false }) { - alertDialogs.ConfirmDialog( - title = "Reset config", - message = "Are you sure you want to reset the config?", - onConfirm = { - context.config.reset() - context.shortToast("Config successfully reset!") - }, - onDismiss = { showResetConfirmationDialog = false } - ) - } - } - - val actions = remember { - mapOf( - "Export" to { - activityLauncher { - saveFile("config.json", "application/json") { uri -> - context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { - context.config.writeConfig() - context.config.exportToString().byteInputStream().copyTo(it) - context.shortToast("Config exported successfully!") - } - } - } - }, - "Import" to { - activityLauncher { - openFile("application/json") { uri -> - context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { - runCatching { - context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) - }.onFailure { - context.longToast("Failed to import config ${it.message}") - return@use - } - context.shortToast("Config successfully loaded!") - } - } - } - }, - "Reset" to { showResetConfirmationDialog = true } - ) - } - - IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = null - ) - } - - if (showExportDropdownMenu) { - DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) { - actions.forEach { (name, action) -> - DropdownMenuItem( - text = { - Text(text = name) - }, - onClick = { - action() - showExportDropdownMenu = false - } - ) - } - } - } - } - - @Composable - private fun PropertiesView( - properties: List<PropertyPair<*>> - ) { - rememberScaffoldState = rememberBottomSheetScaffoldState() - Scaffold( - snackbarHost = { SnackbarHost(rememberScaffoldState.snackbarHostState) }, - modifier = Modifier.fillMaxSize(), - content = { innerPadding -> - LazyColumn( - modifier = Modifier - .fillMaxHeight() - .padding(innerPadding), - //save button space - contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp), - verticalArrangement = Arrangement.Top - ) { - items(properties) { - PropertyCard(it) - } - } - } - ) - } - - @Composable - override fun FloatingActionButton() { - val scope = rememberCoroutineScope() - FloatingActionButton( - onClick = { - context.config.writeConfig() - scope.launch { - rememberScaffoldState.snackbarHostState.showSnackbar("Saved") - } - }, - modifier = Modifier.padding(10.dp), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - shape = RoundedCornerShape(16.dp), - ) { - Icon( - imageVector = Icons.Rounded.Save, - contentDescription = null - ) - } - } - - - @Composable - private fun Container( - configContainer: ConfigContainer - ) { - val properties = remember { - configContainer.properties.map { PropertyPair(it.key, it.value) } - } - - PropertiesView(properties) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/PickLocation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/PickLocation.kt @@ -1,4 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.features - -class PickLocation { -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -1,318 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.home - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.common.BuildConfig -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.manager.data.InstallationSummary -import me.rhunk.snapenhance.ui.manager.data.Updater -import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper -import java.util.Locale - -class HomeSection : Section() { - companion object { - val cardMargin = 10.dp - const val HOME_ROOT = "home_root" - const val LOGS_SECTION_ROUTE = "home_logs" - const val SETTINGS_SECTION_ROUTE = "home_settings" - } - - private var installationSummary: InstallationSummary? = null - private var userLocale: String? = null - private val homeSubSection by lazy { HomeSubSection(context) } - private var latestUpdate: Updater.LatestRelease? = null - private lateinit var activityLauncherHelper: ActivityLauncherHelper - - override fun init() { - activityLauncherHelper = ActivityLauncherHelper(context.activity!!) - } - - @Composable - private fun SummaryCards(installationSummary: InstallationSummary) { - val summaryInfo = remember { - mapOf( - "Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"), - "Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"), - "Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"), - "Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"), - "Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"), - "Device" to installationSummary.platformInfo.device, - "Android Version" to installationSummary.platformInfo.androidVersion, - "System ABI" to installationSummary.platformInfo.systemAbi - ) - } - - Card( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(all = 10.dp), - ) { - summaryInfo.forEach { (title, value) -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(all = 5.dp), - ) { - Text( - text = title, - fontSize = 12.sp, - fontWeight = FontWeight.Light, - ) - Text( - fontSize = 14.sp, - text = value, - lineHeight = 20.sp - ) - } - } - } - } - } - - override fun onResumed() { - if (!context.mappings.isMappingsLoaded) { - context.mappings.init(context.androidContext) - } - context.coroutineScope.launch { - userLocale = context.translation.loadedLocale.getDisplayName(Locale.getDefault()) - runCatching { - installationSummary = context.installationSummary - }.onFailure { - context.longToast("SnapEnhance failed to load installation summary: ${it.message}") - } - runCatching { - if (!BuildConfig.DEBUG) { - latestUpdate = Updater.checkForLatestRelease() - } - }.onFailure { - context.longToast("SnapEnhance failed to check for updates: ${it.message}") - } - } - } - - override fun sectionTopBarName(): String { - if (currentRoute == HOME_ROOT) { - return "" - } - return context.translation["manager.routes.$currentRoute"] - } - - @Composable - override fun FloatingActionButton() { - if (currentRoute == LOGS_SECTION_ROUTE) { - homeSubSection.LogsActionButtons() - } - } - - @Composable - override fun TopBarActions(rowScope: RowScope) { - rowScope.apply { - when (currentRoute) { - HOME_ROOT -> { - IconButton(onClick = { - navController.navigate(LOGS_SECTION_ROUTE) - }) { - Icon(Icons.Filled.BugReport, contentDescription = null) - } - IconButton(onClick = { - navController.navigate(SETTINGS_SECTION_ROUTE) - }) { - Icon(Icons.Filled.Settings, contentDescription = null) - } - } - LOGS_SECTION_ROUTE -> { - homeSubSection.LogsTopBarButtons(activityLauncherHelper, navController, this) - } - } - } - } - - override fun build(navGraphBuilder: NavGraphBuilder) { - navGraphBuilder.navigation( - route = enumSection.route, - startDestination = HOME_ROOT - ) { - composable(HOME_ROOT) { - Content() - } - composable(LOGS_SECTION_ROUTE) { - homeSubSection.LogsSection() - } - composable(SETTINGS_SECTION_ROUTE) { - SettingsSection(activityLauncherHelper).also { it.context = context }.Content() - } - } - } - - - @Composable - @Preview - override fun Content() { - val avenirNextFontFamily = remember { - FontFamily( - Font(R.font.avenir_next_medium, FontWeight.Medium) - ) - } - - Column( - modifier = Modifier - .verticalScroll(ScrollState(0)) - ) { - - Image( - painter = painterResource(id = R.drawable.launcher_icon_monochrome), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - contentScale = ContentScale.FillHeight, - modifier = Modifier - .fillMaxWidth() - .scale(1.8f) - .height(90.dp) - ) - - Text( - text = remember { intArrayOf(101,99,110,97,104,110,69,112,97,110,83).map { it.toChar() }.joinToString("").reversed() }, - fontSize = 30.sp, - fontFamily = avenirNextFontFamily, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - - Text( - text = "v" + BuildConfig.VERSION_NAME + " \u00b7 by rhunk", - fontSize = 12.sp, - fontFamily = avenirNextFontFamily, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - - Text( - text = "An xposed module made to enhance your Snapchat experience", - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally), - modifier = Modifier - .fillMaxWidth() - .padding(all = 10.dp) - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp).clickable { - context.activity?.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse( - intArrayOf(101,99,110,97,104,110,69,112,97,110,83,47,107,110,117,104,114,47,109,111,99,46,98,117,104,116,105,103,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed() - ) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } - ) - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp).clickable { - context.activity?.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse( - intArrayOf(101,99,110,97,104,110,101,112,97,110,115,47,101,109,46,116,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed() - ) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } - ) - } - Spacer(modifier = Modifier.height(20.dp)) - - if (latestUpdate != null) { - OutlinedCard( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ){ - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 15.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = "SnapEnhance Update", - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - ) - Text( - fontSize = 12.sp, - text = "Version ${latestUpdate?.versionName} is available!", - lineHeight = 20.sp - ) - } - Button(onClick = { - context.activity?.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(latestUpdate?.releaseUrl) - } - ) - }, modifier = Modifier.height(40.dp)) { - Text(text = "Download") - } - } - } - } - - SummaryCards(installationSummary = installationSummary ?: return) - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt @@ -1,254 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.home - -import android.net.Uri -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown -import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Report -import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.LogReader -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.common.logger.LogChannel -import me.rhunk.snapenhance.common.logger.LogLevel -import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper -import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator -import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState -import me.rhunk.snapenhance.ui.util.saveFile - -class HomeSubSection( - private val context: RemoteSideContext -) { - private val logListState by lazy { LazyListState(0) } - - @Composable - fun LogsSection() { - val coroutineScope = rememberCoroutineScope() - val clipboardManager = LocalClipboardManager.current - var lineCount by remember { mutableIntStateOf(0) } - var logReader by remember { mutableStateOf<LogReader?>(null) } - var isRefreshing by remember { mutableStateOf(false) } - - fun refreshLogs() { - coroutineScope.launch(Dispatchers.IO) { - runCatching { - logReader = context.log.newReader { - lineCount++ - } - lineCount = logReader!!.lineCount - }.onFailure { - context.longToast("Failed to read logs!") - } - delay(300) - isRefreshing = false - withContext(Dispatchers.Main) { - logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@withContext) - } - } - } - - val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = { - refreshLogs() - }) - - LaunchedEffect(Unit) { - isRefreshing = true - refreshLogs() - } - - Box( - modifier = Modifier - .fillMaxSize() - ) { - LazyColumn( - modifier = Modifier - .background(MaterialTheme.colorScheme.surface) - .horizontalScroll(ScrollState(0)), - state = logListState - ) { - item { - if (lineCount == 0 && logReader != null) { - Text( - text = "No logs found!", - modifier = Modifier.padding(16.dp), - fontSize = 12.sp, - fontWeight = FontWeight.Light - ) - } - } - items(lineCount) { index -> - val logLine = remember(index) { logReader?.getLogLine(index) } ?: return@items - var expand by remember { mutableStateOf(false) } - - Box(modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - coroutineScope.launch { - clipboardManager.setText(AnnotatedString(logLine.message)) - } - }, - onTap = { - expand = !expand - } - ) - }) { - - Row( - modifier = Modifier - .padding(4.dp) - .fillMaxWidth() - .defaultMinSize(minHeight = 30.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (!expand) { - Icon( - imageVector = when (logLine.logLevel) { - LogLevel.DEBUG -> Icons.Outlined.BugReport - LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report - LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info - LogLevel.WARN -> Icons.Outlined.Warning - }, - contentDescription = null, - ) - - Text( - text = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag, - modifier = Modifier.padding(start = 4.dp), - fontWeight = FontWeight.Light, - fontSize = 10.sp, - ) - - Text( - text = logLine.dateTime, - modifier = Modifier.padding(start = 4.dp, end = 4.dp), - fontSize = 10.sp - ) - } - - Text( - text = logLine.message.trimIndent(), - fontSize = 10.sp, - maxLines = if (expand) Int.MAX_VALUE else 6, - overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis, - softWrap = !expand, - ) - } - } - } - } - - PullRefreshIndicator( - refreshing = isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) - } - } - - @Composable - fun LogsTopBarButtons(activityLauncherHelper: ActivityLauncherHelper, navController: NavController, rowScope: RowScope) { - var showDropDown by remember { mutableStateOf(false) } - - IconButton(onClick = { - showDropDown = true - }) { - Icon(Icons.Filled.MoreVert, contentDescription = null) - } - - rowScope.apply { - DropdownMenu( - expanded = showDropDown, - onDismissRequest = { showDropDown = false }, - modifier = Modifier.align(Alignment.CenterVertically) - ) { - DropdownMenuItem(onClick = { - context.log.clearLogs() - navController.navigate(HomeSection.LOGS_SECTION_ROUTE) - showDropDown = false - }, text = { - Text( - text = context.translation["manager.sections.home.logs.clear_logs_button"] - ) - }) - - DropdownMenuItem(onClick = { - activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri -> - context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { - runCatching { - context.log.exportLogsToZip(it) - context.longToast("Saved logs to $uri") - }.onFailure { - context.longToast("Failed to save logs to $uri!") - context.log.error("Failed to save logs to $uri!", it) - } - } - } - showDropDown = false - }, text = { - Text( - text = context.translation["manager.sections.home.logs.export_logs_button"] - ) - }) - } - } - } - - @Composable - fun LogsActionButtons() { - val coroutineScope = rememberCoroutineScope() - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } } - val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } } - FilledIconButton( - onClick = { - coroutineScope.launch { - logListState.scrollToItem(0) - } - }, - enabled = firstVisibleItem != 0 - ) { - Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null) - } - - FilledIconButton( - onClick = { - coroutineScope.launch { - logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch) - } - }, - enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1 - ) { - Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null) - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt @@ -1,240 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.home - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.core.net.toUri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.common.Constants -import me.rhunk.snapenhance.common.action.EnumAction -import me.rhunk.snapenhance.common.bridge.types.BridgeFileType -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.setup.Requirements -import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper -import me.rhunk.snapenhance.ui.util.AlertDialogs -import me.rhunk.snapenhance.ui.util.saveFile - -class SettingsSection( - private val activityLauncherHelper: ActivityLauncherHelper -) : Section() { - private val dialogs by lazy { AlertDialogs(context.translation) } - - @Composable - private fun RowTitle(title: String) { - Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) - } - - @Composable - private fun RowAction(title: String, requireConfirmation: Boolean = false, action: () -> Unit) { - var confirmationDialog by remember { - mutableStateOf(false) - } - - fun takeAction() { - if (requireConfirmation) { - confirmationDialog = true - } else { - action() - } - } - - if (requireConfirmation && confirmationDialog) { - Dialog(onDismissRequest = { confirmationDialog = false }) { - dialogs.ConfirmDialog(title = "Are you sure?", onConfirm = { - action() - confirmationDialog = false - }, onDismiss = { - confirmationDialog = false - }) - } - } - - ShiftedRow( - modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .clickable { - takeAction() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = title, modifier = Modifier.padding(start = 26.dp)) - IconButton(onClick = { takeAction() }) { - Icon( - imageVector = Icons.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - } - } - - private fun launchActionIntent(action: EnumAction) { - val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) - intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key) - context.androidContext.startActivity(intent) - } - - @Composable - private fun ShiftedRow( - modifier: Modifier = Modifier, - horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, - verticalAlignment: Alignment.Vertical = Alignment.Top, - content: @Composable RowScope.() -> Unit - ) { - Row( - modifier = modifier.padding(start = 26.dp), - horizontalArrangement = horizontalArrangement, - verticalAlignment = verticalAlignment - ) { content(this) } - } - - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - override fun Content() { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(ScrollState(0)) - ) { - RowTitle(title = "Actions") - EnumAction.entries.forEach { enumAction -> - RowAction(title = context.translation["actions.${enumAction.key}"]) { - launchActionIntent(enumAction) - } - } - RowAction(title = "Regenerate Mappings") { - context.checkForRequirements(Requirements.MAPPINGS) - } - RowAction(title = "Change Language") { - context.checkForRequirements(Requirements.LANGUAGE) - } - RowTitle(title = "Message Logger") - ShiftedRow { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - var storedMessagesCount by remember { mutableIntStateOf(0) } - var storedStoriesCount by remember { mutableIntStateOf(0) } - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - storedMessagesCount = context.messageLogger.getStoredMessageCount() - storedStoriesCount = context.messageLogger.getStoredStoriesCount() - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(5.dp) - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text(text = "$storedMessagesCount messages") - Text(text = "$storedStoriesCount stories") - } - Button(onClick = { - runCatching { - activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> - context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream -> - context.messageLogger.databaseFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - } - } - }.onFailure { - context.log.error("Failed to export database", it) - context.longToast("Failed to export database! ${it.localizedMessage}") - } - }) { - Text(text = "Export") - } - Button(onClick = { - runCatching { - context.messageLogger.purgeAll() - storedMessagesCount = 0 - storedStoriesCount = 0 - }.onFailure { - context.log.error("Failed to clear messages", it) - context.longToast("Failed to clear messages! ${it.localizedMessage}") - }.onSuccess { - context.shortToast("Done!") - } - }) { - Text(text = "Clear") - } - } - } - } - - RowTitle(title = "Clear App Files") - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - var selectedFileType by remember { mutableStateOf(BridgeFileType.entries.first()) } - Box( - modifier = Modifier - .weight(1f) - .padding(start = 26.dp) - ) { - var expanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - modifier = Modifier.fillMaxWidth(0.8f) - ) { - TextField( - value = selectedFileType.displayName, - onValueChange = {}, - readOnly = true, - modifier = Modifier.menuAnchor() - ) - - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - BridgeFileType.entries.forEach { fileType -> - DropdownMenuItem(onClick = { - expanded = false - selectedFileType = fileType - }, text = { - Text(text = fileType.displayName) - }) - } - } - } - } - Button(onClick = { - runCatching { - selectedFileType.resolve(context.androidContext).delete() - }.onFailure { - context.log.error("Failed to clear file", it) - context.longToast("Failed to clear file! ${it.localizedMessage}") - }.onSuccess { - context.shortToast("Done!") - } - }) { - Text(text = "Clear") - } - } - Spacer(modifier = Modifier.height(50.dp)) - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt @@ -1,305 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.scripting - -import android.content.Intent -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.LibraryBooks -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.common.scripting.type.ModuleInfo -import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface -import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager -import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper -import me.rhunk.snapenhance.ui.util.chooseFolder -import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator -import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh -import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState - -class ScriptsSection : Section() { - private lateinit var activityLauncherHelper: ActivityLauncherHelper - - override fun init() { - activityLauncherHelper = ActivityLauncherHelper(context.activity!!) - } - - @Composable - fun ModuleItem(script: ModuleInfo) { - var enabled by remember { - mutableStateOf(context.modDatabase.isScriptEnabled(script.name)) - } - var openSettings by remember { - mutableStateOf(false) - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - elevation = CardDefaults.cardElevation() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - openSettings = !openSettings - } - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 8.dp) - ) { - Text(text = script.displayName ?: script.name, fontSize = 20.sp,) - Text(text = script.description ?: "No description", fontSize = 14.sp,) - } - IconButton(onClick = { openSettings = !openSettings }) { - Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",) - } - Switch( - checked = enabled, - onCheckedChange = { isChecked -> - context.modDatabase.setScriptEnabled(script.name, isChecked) - enabled = isChecked - runCatching { - val modulePath = context.scriptManager.getModulePath(script.name)!! - context.scriptManager.unloadScript(modulePath) - if (isChecked) { - context.scriptManager.loadScript(modulePath) - context.scriptManager.runtime.getModuleByName(script.name) - ?.callFunction("module.onSnapEnhanceLoad") - context.shortToast("Loaded script ${script.name}") - } else { - context.shortToast("Unloaded script ${script.name}") - } - }.onFailure { throwable -> - enabled = !isChecked - ("Failed to ${if (isChecked) "enable" else "disable"} script").let { - context.log.error(it, throwable) - context.shortToast(it) - } - } - } - ) - } - - if (openSettings) { - ScriptSettings(script) - } - } - } - - @Composable - override fun FloatingActionButton() { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.End, - ) { - ExtendedFloatingActionButton( - onClick = { - - }, - icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, - text = { - Text(text = "Import from URL") - }, - ) - ExtendedFloatingActionButton( - onClick = { - context.scriptManager.getScriptsFolder()?.let { - context.androidContext.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = it.uri - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } - }, - icon= { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") }, - text = { - Text(text = "Open Scripts Folder") - }, - ) - } - } - - - @Composable - fun ScriptSettings(script: ModuleInfo) { - val settingsInterface = remember { - val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null - (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS) - } - - if (settingsInterface == null) { - Text( - text = "This module does not have any settings", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp) - ) - } else { - ScriptInterface(interfaceBuilder = settingsInterface) - } - } - - @Composable - override fun Content() { - var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) } - var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } - val coroutineScope = rememberCoroutineScope() - - var refreshing by remember { - mutableStateOf(false) - } - - fun syncScripts() { - runCatching { - scriptingFolder = context.scriptManager.getScriptsFolder() - context.scriptManager.sync() - scriptModules = context.modDatabase.getScripts() - }.onFailure { - context.log.error("Failed to sync scripts", it) - } - } - - LaunchedEffect(Unit) { - refreshing = true - withContext(Dispatchers.IO) { - syncScripts() - refreshing = false - } - } - - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { - refreshing = true - syncScripts() - coroutineScope.launch { - delay(300) - refreshing = false - } - }) - - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .pullRefresh(pullRefreshState), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - if (scriptingFolder == null) { - Text( - text = "No scripts folder selected", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { - activityLauncherHelper.chooseFolder { - context.config.root.scripting.moduleFolder.set(it) - context.config.writeConfig() - coroutineScope.launch { - syncScripts() - } - } - }) { - Text(text = "Select folder") - } - } else if (scriptModules.isEmpty()) { - Text( - text = "No scripts found", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp) - ) - } - } - items(scriptModules.size) { index -> - ModuleItem(scriptModules[index]) - } - item { - Spacer(modifier = Modifier.height(200.dp)) - } - } - - PullRefreshIndicator( - refreshing = refreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) - } - - var scriptingWarning by remember { - mutableStateOf(context.sharedPreferences.run { - getBoolean("scripting_warning", true).also { - edit().putBoolean("scripting_warning", false).apply() - } - }) - } - - if (scriptingWarning) { - var timeout by remember { - mutableIntStateOf(10) - } - - LaunchedEffect(Unit) { - while (timeout > 0) { - delay(1000) - timeout-- - } - } - - AlertDialog(onDismissRequest = { - if (timeout == 0) { - scriptingWarning = false - } - }, title = { - Text(text = context.translation["manager.dialogs.scripting_warning.title"]) - }, text = { - Text(text = context.translation["manager.dialogs.scripting_warning.content"]) - }, confirmButton = { - TextButton( - onClick = { - scriptingWarning = false - }, - enabled = timeout == 0 - ) { - Text(text = "OK " + if (timeout > 0) "($timeout)" else "") - } - }) - } - } - - @Composable - override fun TopBarActions(rowScope: RowScope) { - rowScope.apply { - IconButton(onClick = { - context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { - data = "https://github.com/SnapEnhance/docs".toUri() - flags = Intent.FLAG_ACTIVITY_NEW_TASK - }) - }) { - Icon(imageVector = Icons.Default.LibraryBooks, contentDescription = "Documentation") - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt @@ -1,263 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.social - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.common.ReceiversConfig -import me.rhunk.snapenhance.common.data.MessagingFriendInfo -import me.rhunk.snapenhance.common.data.MessagingGroupInfo -import me.rhunk.snapenhance.common.data.SocialScope -import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper - -class AddFriendDialog( - private val context: RemoteSideContext, - private val section: SocialSection, -) { - - private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} - - @Composable - private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { - var currentState by remember { mutableStateOf(getCurrentState()) } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - currentState = !currentState - onState(currentState) - } - .padding(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = name, - fontSize = 15.sp, - modifier = Modifier - .weight(1f) - .onGloballyPositioned { - currentState = getCurrentState() - } - ) - - Checkbox( - checked = currentState, - onCheckedChange = { - currentState = it - onState(currentState) - } - ) - } - } - - @Composable - private fun DialogHeader(searchKeyword: MutableState<String>) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - ) { - Text( - text = translation["title"], - fontSize = 23.sp, - fontWeight = FontWeight.ExtraBold, - modifier = Modifier - .align(alignment = Alignment.CenterHorizontally) - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = searchKeyword.value, - onValueChange = { searchKeyword.value = it }, - label = { - Text(text = translation["search_hint"]) - }, - modifier = Modifier - .weight(1f) - .padding(end = 10.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - leadingIcon = { - Icon(Icons.Filled.Search, contentDescription = "Search") - } - ) - } - } - - - @Composable - fun Content(dismiss: () -> Unit = { }) { - var cachedFriends by remember { mutableStateOf(null as List<MessagingFriendInfo>?) } - var cachedGroups by remember { mutableStateOf(null as List<MessagingGroupInfo>?) } - - val coroutineScope = rememberCoroutineScope() - - var timeoutJob: Job? = null - var hasFetchError by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - context.modDatabase.receiveMessagingDataCallback = { friends, groups -> - cachedFriends = friends - cachedGroups = groups - timeoutJob?.cancel() - hasFetchError = false - } - SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also { - runCatching { - context.androidContext.sendBroadcast(it) - }.onFailure { - context.log.error("Failed to send broadcast", it) - hasFetchError = true - } - } - timeoutJob = coroutineScope.launch { - withContext(Dispatchers.IO) { - delay(10000) - hasFetchError = true - } - } - } - - Dialog( - onDismissRequest = { - timeoutJob?.cancel() - dismiss() - }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - colors = CardDefaults.elevatedCardColors(), - modifier = Modifier - .fillMaxSize() - .fillMaxWidth() - .padding(all = 20.dp) - ) { - if (cachedGroups == null || cachedFriends == null) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(10.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (hasFetchError) { - Text( - text = translation["fetch_error"], - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) - ) - return@Card - } - CircularProgressIndicator( - modifier = Modifier - .padding() - .size(30.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.primary - ) - } - return@Card - } - - val searchKeyword = remember { mutableStateOf("") } - - val filteredGroups = cachedGroups!!.takeIf { searchKeyword.value.isNotBlank() }?.filter { - it.name.contains(searchKeyword.value, ignoreCase = true) - } ?: cachedGroups!! - - val filteredFriends = cachedFriends!!.takeIf { searchKeyword.value.isNotBlank() }?.filter { - it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) || - it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true - } ?: cachedFriends!! - - DialogHeader(searchKeyword) - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(10.dp) - ) { - item { - if (filteredGroups.isEmpty()) return@item - Text(text = translation["category_groups"], - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) - ) - } - - items(filteredGroups.size) { - val group = filteredGroups[it] - ListCardEntry( - name = group.name, - getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null } - ) { state -> - if (state) { - context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId) - } else { - context.modDatabase.deleteGroup(group.conversationId) - } - context.modDatabase.executeAsync { - section.onResumed() - } - } - } - - item { - if (filteredFriends.isEmpty()) return@item - Text(text = translation["category_friends"], - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) - ) - } - - items(filteredFriends.size) { - val friend = filteredFriends[it] - - ListCardEntry( - name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername, - getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null } - ) { state -> - if (state) { - context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId) - } else { - context.modDatabase.deleteFriend(friend.userId) - } - context.modDatabase.executeAsync { - section.onResumed() - } - } - } - } - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt @@ -1,276 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.social - -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider -import coil.annotation.ExperimentalCoilApi -import coil.disk.DiskCache -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.bridge.DownloadCallback -import me.rhunk.snapenhance.common.data.FileType -import me.rhunk.snapenhance.common.data.StoryData -import me.rhunk.snapenhance.common.data.download.* -import me.rhunk.snapenhance.common.util.ktx.longHashCode -import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.core.util.media.PreviewUtils -import me.rhunk.snapenhance.download.DownloadProcessor -import me.rhunk.snapenhance.ui.util.Dialog -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.File -import java.text.DateFormat -import java.util.Date -import java.util.UUID -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.math.absoluteValue - -@OptIn(ExperimentalCoilApi::class) -@Composable -fun LoggedStories( - context: RemoteSideContext, - userId: String -) { - val stories = remember { - mutableStateListOf<StoryData>() - } - val friendInfo = remember { - context.modDatabase.getFriendInfo(userId) - } - val httpClient = remember { OkHttpClient() } - var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } - - var selectedStory by remember { mutableStateOf<StoryData?>(null) } - var coilCacheFile by remember { mutableStateOf<File?>(null) } - - selectedStory?.let { story -> - Dialog(onDismissRequest = { - selectedStory = null - }) { - Card( - modifier = Modifier - .padding(4.dp) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = "Posted on ${story.postedAt.let { - DateFormat.getDateTimeInstance().format(Date(it)) - }}") - Text(text = "Created at ${story.createdAt.let { - DateFormat.getDateTimeInstance().format(Date(it)) - }}") - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - Button(onClick = { - context.androidContext.externalCacheDir?.let { cacheDir -> - val cacheFile = coilCacheFile ?: run { - context.shortToast("Failed to get file") - return@Button - } - val targetFile = File(cacheDir, cacheFile.name) - cacheFile.copyTo(targetFile, overwrite = true) - context.androidContext.startActivity(Intent().apply { - action = Intent.ACTION_VIEW - setDataAndType( - FileProvider.getUriForFile( - context.androidContext, - "me.rhunk.snapenhance.fileprovider", - targetFile - ), - FileType.fromFile(targetFile).mimeType - ) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - }) - } - }) { - Text(text = "Open") - } - - Button(onClick = { - val mediaAuthor = friendInfo?.mutableUsername ?: userId - val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16) - - DownloadProcessor( - remoteSideContext = context, - callback = object: DownloadCallback.Default() { - override fun onSuccess(outputPath: String?) { - context.shortToast("Downloaded to $outputPath") - } - - override fun onFailure(message: String?, throwable: String?) { - context.shortToast("Failed to download $message") - } - } - ).enqueue(DownloadRequest( - inputMedias = arrayOf( - InputMedia( - content = story.url, - type = DownloadMediaType.REMOTE_MEDIA, - encryption = story.key?.let { it to story.iv!! }?.toKeyPair() - ) - ) - ), DownloadMetadata( - mediaIdentifier = uniqueHash, - outputPath = createNewFilePath( - context.config.root, - uniqueHash, - MediaDownloadSource.STORY_LOGGER, - mediaAuthor, - story.createdAt - ), - iconUrl = null, - mediaAuthor = friendInfo?.mutableUsername ?: userId, - downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation), - )) - }) { - Text(text = "Download") - } - } - } - } - } - } - - if (stories.isEmpty()) { - Text(text = "No stories found", Modifier.fillMaxWidth(), textAlign = TextAlign.Center) - } - - LazyVerticalGrid( - columns = GridCells.Adaptive(100.dp), - contentPadding = PaddingValues(8.dp), - ) { - items(stories) { story -> - var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) } - val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) } - - fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean { - runCatching { - val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() - - snapshot.data.toFile().inputStream().use { inputStream -> - MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> - mediaList[type] = splitInputStream.readBytes() - } - } - - val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false - val overlay = mediaList[SplitMediaAssetType.OVERLAY] - - var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) - } - - imageBitmap = bitmap?.asImageBitmap() - return true - } - return false - } - - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - withTimeout(10000L) { - context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let { - openDiskCacheSnapshot(it) - it.close() - return@withTimeout - } - - runCatching { - val response = httpClient.newCall(Request( - url = story.url.toHttpUrl() - )).execute() - response.body.byteStream().use { - val decrypted = story.key?.let { _ -> - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv)) - CipherInputStream(it, cipher) - } ?: it - - context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply { - data.toFile().outputStream().use { fos -> - decrypted.copyTo(fos) - } - commitAndOpenSnapshot()?.use { snapshot -> - openDiskCacheSnapshot(snapshot) - snapshot.close() - } - } - } - }.onFailure { - context.log.error("Failed to load story", it) - } - } - } - } - - Column( - modifier = Modifier - .padding(8.dp) - .clickable { - selectedStory = story - coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use { - it?.data?.toFile() - } - } - .heightIn(min = 128.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - imageBitmap?.let { - Card { - Image( - bitmap = it, - modifier = Modifier.fillMaxSize(), - contentDescription = null, - ) - } - } ?: run { - CircularProgressIndicator() - } - } - } - item { - LaunchedEffect(Unit) { - context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result -> - stories.addAll(result.values) - result.keys.minOrNull()?.let { - lastStoryTimestamp = it - } - } - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt @@ -1,522 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.social - -import android.content.Intent -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.BookmarkAdded -import androidx.compose.material.icons.rounded.BookmarkBorder -import androidx.compose.material.icons.rounded.DeleteForever -import androidx.compose.material.icons.rounded.RemoveRedEye -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.* -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge -import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener -import me.rhunk.snapenhance.bridge.snapclient.types.Message -import me.rhunk.snapenhance.common.Constants -import me.rhunk.snapenhance.common.ReceiversConfig -import me.rhunk.snapenhance.common.data.ContentType -import me.rhunk.snapenhance.common.data.SocialScope -import me.rhunk.snapenhance.common.util.protobuf.ProtoReader -import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper -import me.rhunk.snapenhance.messaging.MessagingConstraints -import me.rhunk.snapenhance.messaging.MessagingTask -import me.rhunk.snapenhance.messaging.MessagingTaskConstraint -import me.rhunk.snapenhance.messaging.MessagingTaskType -import me.rhunk.snapenhance.ui.util.Dialog - -class MessagingPreview( - private val context: RemoteSideContext, - private val scope: SocialScope, - private val scopeId: String -) { - private lateinit var coroutineScope: CoroutineScope - private lateinit var messagingBridge: MessagingBridge - private lateinit var previewScrollState: LazyListState - private val myUserId by lazy { messagingBridge.myUserId } - private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") } - - private var conversationId: String? = null - private val messages = sortedMapOf<Long, Message>() // server message id => message - private var messageSize by mutableIntStateOf(0) - private var lastMessageId = Long.MAX_VALUE - private val selectedMessages = mutableStateListOf<Long>() // client message id - - private fun toggleSelectedMessage(messageId: Long) { - if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId) - else selectedMessages.add(messageId) - } - - @Composable - private fun ActionButton( - text: String, - icon: ImageVector, - onClick: () -> Unit, - ) { - DropdownMenuItem( - onClick = onClick, - text = { - Row( - modifier = Modifier.padding(5.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null - ) - Text(text = text) - } - } - ) - } - - @Composable - private fun ConstraintsSelectionDialog( - onChoose: (Array<ContentType>) -> Unit, - onDismiss: () -> Unit - ) { - val selectedTypes = remember { mutableStateListOf<ContentType>() } - var selectAllState by remember { mutableStateOf(false) } - val availableTypes = remember { arrayOf( - ContentType.CHAT, - ContentType.NOTE, - ContentType.SNAP, - ContentType.STICKER, - ContentType.EXTERNAL_MEDIA - ) } - - fun toggleContentType(contentType: ContentType) { - if (selectAllState) return - if (selectedTypes.contains(contentType)) { - selectedTypes.remove(contentType) - } else { - selectedTypes.add(contentType) - } - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - ) { - Column( - modifier = Modifier.padding(15.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(5.dp) - ) { - Text("Choose content types to process") - Spacer(modifier = Modifier.height(5.dp)) - availableTypes.forEach { contentType -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(2.dp) - .pointerInput(Unit) { - detectTapGestures(onTap = { toggleContentType(contentType) }) - }, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = selectedTypes.contains(contentType), - enabled = !selectAllState, - onCheckedChange = { toggleContentType(contentType) } - ) - Text(text = contentType.toString()) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Switch(checked = selectAllState, onCheckedChange = { - selectAllState = it - }) - Text(text = "Select all") - } - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - Button(onClick = { onDismiss() }) { - Text("Cancel") - } - Button(onClick = { - onChoose(if (selectAllState) ContentType.entries.toTypedArray() - else selectedTypes.toTypedArray()) - }) { - Text("Continue") - } - } - } - } - } - - @Composable - fun TopBarAction() { - var taskSelectionDropdown by remember { mutableStateOf(false) } - var selectConstraintsDialog by remember { mutableStateOf(false) } - var activeTask by remember { mutableStateOf(null as MessagingTask?) } - var activeJob by remember { mutableStateOf(null as Job?) } - val processMessageCount = remember { mutableIntStateOf(0) } - - fun runCurrentTask() { - activeJob = coroutineScope.launch(Dispatchers.IO) { - activeTask?.run() - withContext(Dispatchers.Main) { - activeTask = null - activeJob = null - } - }.also { job -> - job.invokeOnCompletion { - if (it != null) { - context.log.verbose("Failed to process messages: ${it.message}") - return@invokeOnCompletion - } - context.longToast("Processed ${processMessageCount.intValue} messages") - } - } - } - - fun launchMessagingTask(taskType: MessagingTaskType, constraints: List<MessagingTaskConstraint> = listOf(), onSuccess: (Message) -> Unit = {}) { - taskSelectionDropdown = false - processMessageCount.intValue = 0 - activeTask = MessagingTask( - messagingBridge, conversationId!!, taskType, constraints, - overrideClientMessageIds = selectedMessages.takeIf { it.isNotEmpty() }?.toList(), - processedMessageCount = processMessageCount, - onSuccess = onSuccess, - onFailure = { message, reason -> - context.log.verbose("Failed to process message ${message.clientMessageId}: $reason") - } - ) - selectedMessages.clear() - } - - if (selectConstraintsDialog && activeTask != null) { - Dialog(onDismissRequest = { - selectConstraintsDialog = false - activeTask = null - }) { - ConstraintsSelectionDialog( - onChoose = { contentTypes -> - launchMessagingTask( - taskType = activeTask!!.taskType, - constraints = activeTask!!.constraints + MessagingConstraints.CONTENT_TYPE(contentTypes), - onSuccess = activeTask!!.onSuccess - ) - runCurrentTask() - selectConstraintsDialog = false - }, - onDismiss = { - selectConstraintsDialog = false - activeTask = null - } - ) - } - } - - if (activeJob != null) { - Dialog(onDismissRequest = { - activeJob?.cancel() - activeJob = null - activeTask = null - }) { - Column(modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .padding(15.dp) - .border(1.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(20.dp)), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(5.dp)) - { - Text("Processed ${processMessageCount.intValue} messages") - if (activeTask?.hasFixedGoal() == true) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp), - progress = processMessageCount.intValue.toFloat() / selectedMessages.size.toFloat(), - color = MaterialTheme.colorScheme.primary - ) - } else { - CircularProgressIndicator( - modifier = Modifier - .padding() - .size(30.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - } - - IconButton(onClick = { taskSelectionDropdown = !taskSelectionDropdown }) { - Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null) - } - - if (selectedMessages.isNotEmpty()) { - IconButton(onClick = { selectedMessages.clear() }) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "Close") - } - } - - MaterialTheme( - colorScheme = MaterialTheme.colorScheme.copy( - surface = MaterialTheme.colorScheme.inverseSurface, - onSurface = MaterialTheme.colorScheme.inverseOnSurface - ), - shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(50.dp)) - ) { - DropdownMenu( - expanded = taskSelectionDropdown, onDismissRequest = { taskSelectionDropdown = false } - ) { - val hasSelection = selectedMessages.isNotEmpty() - ActionButton(text = if (hasSelection) "Save selection" else "Save all", icon = Icons.Rounded.BookmarkAdded) { - launchMessagingTask(MessagingTaskType.SAVE) - if (hasSelection) runCurrentTask() - else selectConstraintsDialog = true - } - ActionButton(text = if (hasSelection) "Unsave selection" else "Unsave all", icon = Icons.Rounded.BookmarkBorder) { - launchMessagingTask(MessagingTaskType.UNSAVE) - if (hasSelection) runCurrentTask() - else selectConstraintsDialog = true - } - ActionButton(text = if (hasSelection) "Mark selected Snap as seen" else "Mark all Snaps as seen", icon = Icons.Rounded.RemoveRedEye) { - launchMessagingTask(MessagingTaskType.READ, listOf( - MessagingConstraints.NO_USER_ID(myUserId), - MessagingConstraints.CONTENT_TYPE(arrayOf(ContentType.SNAP)) - )) - runCurrentTask() - } - ActionButton(text = if (hasSelection) "Delete selected" else "Delete all", icon = Icons.Rounded.DeleteForever) { - launchMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(myUserId))) { message -> - coroutineScope.launch { - messages.remove(message.serverMessageId) - messageSize = messages.size - } - } - if (hasSelection) runCurrentTask() - else selectConstraintsDialog = true - } - } - } - } - - @Composable - private fun ConversationPreview() { - DisposableEffect(Unit) { - onDispose { - selectedMessages.clear() - } - } - - LazyColumn( - modifier = Modifier - .fillMaxSize(), - state = previewScrollState, - ) { - item { - if (messages.isEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - horizontalArrangement = Arrangement.Center - ) { - Text("No messages") - } - } - Spacer(modifier = Modifier.height(20.dp)) - - LaunchedEffect(Unit) { - if (messages.size > 0) { - fetchNewMessages() - } - } - } - items(messageSize) {index -> - val elementKey = remember(index) { messages.entries.elementAt(index).value.clientMessageId } - val messageReader = ProtoReader(messages.entries.elementAt(index).value.content) - val contentType = ContentType.fromMessageContainer(messageReader) - - Card( - modifier = Modifier - .padding(5.dp) - .pointerInput(Unit) { - if (contentType == ContentType.STATUS) return@pointerInput - detectTapGestures( - onLongPress = { - toggleSelectedMessage(elementKey) - }, - onTap = { - if (selectedMessages.isNotEmpty()) { - toggleSelectedMessage(elementKey) - } - } - ) - }, - colors = CardDefaults.cardColors( - containerColor = if (selectedMessages.contains(elementKey)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant - ), - ) { - Row( - modifier = Modifier - .padding(5.dp) - ) { - - Text("[${contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } }] ${messageReader.getString(2, 1) ?: ""}") - } - } - } - } - } - - private fun fetchNewMessages() { - coroutineScope.launch(Dispatchers.IO) cs@{ - runCatching { - val queriedMessages = messagingBridge.fetchConversationWithMessagesPaginated( - conversationId!!, - 100, - lastMessageId - ) - - if (queriedMessages == null) { - context.shortToast("Failed to fetch messages") - return@cs - } - - coroutineScope.launch { - messages.putAll(queriedMessages.map { it.serverMessageId to it }) - messageSize = messages.size - if (queriedMessages.isNotEmpty()) { - lastMessageId = queriedMessages.first().clientMessageId - previewScrollState.scrollToItem(queriedMessages.size - 1) - } - } - }.onFailure { - context.shortToast("Failed to fetch messages: ${it.message}") - } - context.log.verbose("fetched ${messages.size} messages") - } - } - - private fun onMessagingBridgeReady() { - runCatching { - messagingBridge = context.bridgeService!!.messagingBridge!! - conversationId = if (scope == SocialScope.FRIEND) messagingBridge.getOneToOneConversationId(scopeId) else scopeId - if (conversationId == null) { - context.longToast("Failed to fetch conversation id") - return - } - if (!messagingBridge.isSessionStarted) { - context.androidContext.packageManager.getLaunchIntentForPackage( - Constants.SNAPCHAT_PACKAGE_NAME - )?.let { - val mainIntent = Intent.makeRestartActivityTask(it.component).apply { - putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true) - } - context.androidContext.startActivity(mainIntent) - } - messagingBridge.registerSessionStartListener(object: SessionStartListener.Stub() { - override fun onConnected() { - fetchNewMessages() - } - }) - return - } - fetchNewMessages() - }.onFailure { - context.longToast("Failed to initialize messaging bridge") - context.log.error("Failed to initialize messaging bridge", it) - } - } - - @Composable - private fun LoadingRow() { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator( - modifier = Modifier - .padding() - .size(30.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.primary - ) - } - } - - @Composable - fun Content() { - previewScrollState = rememberLazyListState() - coroutineScope = rememberCoroutineScope() - var isBridgeConnected by remember { mutableStateOf(false) } - var hasBridgeError by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - ) { - LaunchedEffect(Unit) { - isBridgeConnected = context.hasMessagingBridge() - if (isBridgeConnected) { - onMessagingBridgeReady() - } else { - SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also { - context.androidContext.sendBroadcast(it) - } - coroutineScope.launch(Dispatchers.IO) { - withTimeout(10000) { - while (!context.hasMessagingBridge()) { - delay(100) - } - isBridgeConnected = true - onMessagingBridgeReady() - } - }.invokeOnCompletion { - if (it != null) { - hasBridgeError = true - } - } - } - } - - if (hasBridgeError) { - Text("Failed to connect to Snapchat through bridge service") - } - - if (!isBridgeConnected && !hasBridgeError) { - LoadingRow() - } - - if (isBridgeConnected && !hasBridgeError) { - ConversationPreview() - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -1,359 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.social - -import android.content.Intent -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch -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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.common.data.MessagingRuleType -import me.rhunk.snapenhance.common.data.SocialScope -import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie -import me.rhunk.snapenhance.ui.util.AlertDialogs -import me.rhunk.snapenhance.ui.util.BitmojiImage -import me.rhunk.snapenhance.ui.util.Dialog -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -class ScopeContent( - private val context: RemoteSideContext, - private val section: SocialSection, - private val navController: NavController, - val scope: SocialScope, - private val id: String -) { - private val dialogs by lazy { AlertDialogs(context.translation) } - private val translation by lazy { context.translation.getCategory("manager.sections.social") } - - fun deleteScope(coroutineScope: CoroutineScope) { - when (scope) { - SocialScope.FRIEND -> context.modDatabase.deleteFriend(id) - SocialScope.GROUP -> context.modDatabase.deleteGroup(id) - } - context.modDatabase.executeAsync { - coroutineScope.launch { - section.onResumed() - navController.popBackStack() - } - } - } - - @Composable - fun Content() { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - ) { - when (scope) { - SocialScope.FRIEND -> Friend() - SocialScope.GROUP -> Group() - } - - Spacer(modifier = Modifier.height(16.dp)) - - val rules = context.modDatabase.getRules(id) - - SectionTitle(translation["rules_title"]) - - ContentCard { - //manager anti features etc - MessagingRuleType.entries.forEach { ruleType -> - var ruleEnabled by remember { - mutableStateOf(rules.any { it.key == ruleType.key }) - } - - val ruleState = context.config.root.rules.getRuleState(ruleType) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(all = 4.dp) - ) { - Text( - text = if (ruleType.listMode && ruleState != null) { - context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"] - } else context.translation["rules.properties.${ruleType.key}.name"], - modifier = Modifier.weight(1f).padding(start = 5.dp, end = 5.dp) - ) - Switch(checked = ruleEnabled, - enabled = if (ruleType.listMode) ruleState != null else true, - onCheckedChange = { - context.modDatabase.setRule(id, ruleType.key, it) - ruleEnabled = it - }) - } - } - } - } - } - - @Composable - private fun ContentCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { - Card( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth() - ) { - Column( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth() - .then(modifier) - ) { - content() - } - } - } - - @Composable - private fun SectionTitle(title: String) { - Text( - text = title, - maxLines = 1, - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier - .offset(x = 20.dp) - .padding(bottom = 10.dp) - ) - } - - //need to display all units? - private fun computeStreakETA(timestamp: Long): String { - val now = System.currentTimeMillis() - val stringBuilder = StringBuilder() - val diff = timestamp - now - val seconds = diff / 1000 - val minutes = seconds / 60 - val hours = minutes / 60 - val days = hours / 24 - if (days > 0) { - stringBuilder.append("$days day ") - return stringBuilder.toString() - } - if (hours > 0) { - stringBuilder.append("$hours hours ") - return stringBuilder.toString() - } - if (minutes > 0) { - stringBuilder.append("$minutes minutes ") - return stringBuilder.toString() - } - if (seconds > 0) { - stringBuilder.append("$seconds seconds ") - return stringBuilder.toString() - } - return "Expired" - } - - @OptIn(ExperimentalEncodingApi::class) - @Composable - private fun Friend() { - //fetch the friend from the database - val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { - Text(text = translation["not_found"]) - return - } - - val streaks = remember { - context.modDatabase.getFriendStreaks(id) - } - - Column( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie( - friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D - ) - BitmojiImage(context = context, url = bitmojiUrl, size = 100) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = friend.displayName ?: friend.mutableUsername, - maxLines = 1, - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = friend.mutableUsername, - maxLines = 1, - fontSize = 12.sp, - fontWeight = FontWeight.Light - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - if (context.config.root.experimental.storyLogger.get()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - ) { - Button(onClick = { - navController.navigate(SocialSection.LOGGED_STORIES_ROUTE.replace("{userId}", id)) - }) { - Text("Show Logged Stories") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } - - Column { - //streaks - streaks?.let { - var shouldNotify by remember { mutableStateOf(it.notify) } - SectionTitle(translation["streaks_title"]) - ContentCard { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = translation.format( - "streaks_length_text", "length" to streaks.length.toString() - ), maxLines = 1 - ) - Text( - text = translation.format( - "streaks_expiration_text", - "eta" to computeStreakETA(streaks.expirationTimestamp) - ), maxLines = 1 - ) - } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = translation["reminder_button"], - maxLines = 1, - modifier = Modifier.padding(end = 10.dp) - ) - Switch(checked = shouldNotify, onCheckedChange = { - context.modDatabase.setFriendStreaksNotify(id, it) - shouldNotify = it - }) - } - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - // e2ee section - - if (context.config.root.experimental.e2eEncryption.globalState == true) { - SectionTitle(translation["e2ee_title"]) - var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))} - var importDialog by remember { mutableStateOf(false) } - - if (importDialog) { - Dialog( - onDismissRequest = { importDialog = false } - ) { - dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey -> - importDialog = false - runCatching { - val key = Base64.decode(newKey) - if (key.size != 32) { - context.longToast("Invalid key size (must be 32 bytes)") - return@runCatching - } - - context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) - context.longToast("Successfully imported key") - hasSecretKey = true - }.onFailure { - context.longToast("Failed to import key: ${it.message}") - context.log.error("Failed to import key", it) - } - }) - } - } - - ContentCard { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (hasSecretKey) { - OutlinedButton(onClick = { - val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton) - //TODO: fingerprint auth - context.activity!!.startActivity(Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, secretKey) - type = "text/plain" - }, "").apply { - putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( - Intent().apply { - putExtra(Intent.EXTRA_TEXT, secretKey) - putExtra(Intent.EXTRA_SUBJECT, secretKey) - }) - ) - }) - }) { - Text( - text = "Export Base64", - maxLines = 1 - ) - } - } - - OutlinedButton(onClick = { importDialog = true }) { - Text( - text = "Import Base64", - maxLines = 1 - ) - } - } - } - } - } - } - - @Composable - private fun Group() { - //fetch the group from the database - val group = remember { context.modDatabase.getGroupInfo(id) } ?: run { - Text(text = translation["not_found"]) - return - } - - - Column( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = translation.format( - "participants_text", "count" to group.participantsCount.toString() - ), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light - ) - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -1,363 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections.social - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RemoveRedEye -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.DeleteForever -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource -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 androidx.compose.ui.window.Dialog -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navigation -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.common.data.MessagingFriendInfo -import me.rhunk.snapenhance.common.data.MessagingGroupInfo -import me.rhunk.snapenhance.common.data.SocialScope -import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.util.AlertDialogs -import me.rhunk.snapenhance.ui.util.BitmojiImage -import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset - -class SocialSection : Section() { - private lateinit var friendList: List<MessagingFriendInfo> - private lateinit var groupList: List<MessagingGroupInfo> - - companion object { - const val MAIN_ROUTE = "social_route" - const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}" - const val LOGGED_STORIES_ROUTE = "logged_stories/?userId={userId}" - } - - private var currentScopeContent: ScopeContent? = null - private var currentMessagingPreview by mutableStateOf(null as MessagingPreview?) - - private val addFriendDialog by lazy { - AddFriendDialog(context, this) - } - - //FIXME: don't reload the entire list when a friend is added/deleted - override fun onResumed() { - friendList = context.modDatabase.getFriends(descOrder = true) - groupList = context.modDatabase.getGroups() - } - - override fun canGoBack() = currentRoute != MAIN_ROUTE - - override fun build(navGraphBuilder: NavGraphBuilder) { - navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { - composable(MAIN_ROUTE) { - Content() - } - - SocialScope.entries.forEach { scope -> - composable(scope.tabRoute) { - val id = it.arguments?.getString("id") ?: return@composable - remember { - ScopeContent( - context, - this@SocialSection, - navController, - scope, - id - ).also { tab -> - currentScopeContent = tab - } - }.Content() - } - } - - composable(LOGGED_STORIES_ROUTE) { - val userId = it.arguments?.getString("userId") ?: return@composable - LoggedStories(context, userId) - } - - composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry -> - val id = navBackStackEntry.arguments?.getString("id") ?: return@composable - val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable - val messagePreview = remember { - MessagingPreview(context, SocialScope.getByName(scope), id) - } - LaunchedEffect(key1 = id) { - currentMessagingPreview = messagePreview - } - messagePreview.Content() - DisposableEffect(Unit) { - onDispose { - currentMessagingPreview = null - } - } - } - } - } - - @Composable - override fun TopBarActions(rowScope: RowScope) { - var deleteConfirmDialog by remember { mutableStateOf(false) } - val coroutineScope = rememberCoroutineScope() - - if (deleteConfirmDialog) { - currentScopeContent?.let { scopeContent -> - Dialog(onDismissRequest = { deleteConfirmDialog = false }) { - remember { AlertDialogs(context.translation) }.ConfirmDialog( - title = "Are you sure you want to delete this ${scopeContent.scope.key.lowercase()}?", - onDismiss = { deleteConfirmDialog = false }, - onConfirm = { - scopeContent.deleteScope(coroutineScope); deleteConfirmDialog = false - } - ) - } - } - } - - if (currentRoute == MESSAGING_PREVIEW_ROUTE) { - currentMessagingPreview?.TopBarAction() - } - - if (currentRoute == SocialScope.FRIEND.tabRoute || currentRoute == SocialScope.GROUP.tabRoute) { - IconButton( - onClick = { deleteConfirmDialog = true }, - ) { - Icon( - imageVector = Icons.Rounded.DeleteForever, - contentDescription = null - ) - } - } - } - - - @Composable - private fun ScopeList(scope: SocialScope) { - val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() } - - LazyColumn( - modifier = Modifier - .padding(2.dp) - .fillMaxWidth() - .fillMaxHeight(), - contentPadding = PaddingValues(bottom = 110.dp), - ) { - //check if scope list is empty - val listSize = when (scope) { - SocialScope.GROUP -> groupList.size - SocialScope.FRIEND -> friendList.size - } - - if (listSize == 0) { - item { - Text( - text = "(empty)", modifier = Modifier - .fillMaxWidth() - .padding(10.dp), textAlign = TextAlign.Center - ) - } - } - - items(listSize) { index -> - val id = when (scope) { - SocialScope.GROUP -> groupList[index].conversationId - SocialScope.FRIEND -> friendList[index].userId - } - - Card( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth() - .height(80.dp) - .clickable { - navController.navigate( - scope.tabRoute.replace("{id}", id) - ) - }, - ) { - Row( - modifier = Modifier - .padding(10.dp) - .fillMaxSize(), - verticalAlignment = Alignment.CenterVertically - ) { - when (scope) { - SocialScope.GROUP -> { - val group = groupList[index] - Column( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth() - .weight(1f) - ) { - Text( - text = group.name, - maxLines = 1, - fontWeight = FontWeight.Bold - ) - } - } - - SocialScope.FRIEND -> { - val friend = friendList[index] - var streaks by remember { mutableStateOf(friend.streaks) } - - LaunchedEffect(friend.userId) { - withContext(Dispatchers.IO) { - streaks = context.modDatabase.getFriendStreaks(friend.userId) - } - } - - BitmojiImage( - context = context, - url = BitmojiSelfie.getBitmojiSelfie( - friend.selfieId, - friend.bitmojiId, - BitmojiSelfie.BitmojiSelfieType.THREE_D - ) - ) - Column( - modifier = Modifier - .padding(10.dp) - .fillMaxWidth() - .weight(1f) - ) { - Text( - text = friend.displayName ?: friend.mutableUsername, - maxLines = 1, - fontWeight = FontWeight.Bold - ) - Text( - text = friend.mutableUsername, - maxLines = 1, - fontSize = 12.sp, - fontWeight = FontWeight.Light - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - streaks?.takeIf { it.notify }?.let { streaks -> - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon), - contentDescription = null, - modifier = Modifier.height(40.dp), - tint = if (streaks.isAboutToExpire(remainingHours)) - MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary - ) - Text( - text = context.translation.format( - "manager.sections.social.streaks_expiration_short", - "hours" to (((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().takeIf { it > 0 } ?: 0) - .toString() - ), - maxLines = 1, - fontWeight = FontWeight.Bold - ) - } - } - } - } - - FilledIconButton(onClick = { - navController.navigate( - MESSAGING_PREVIEW_ROUTE.replace("{id}", id).replace("{scope}", scope.key) - ) - }) { - Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null) - } - } - } - } - } - } - - - @OptIn(ExperimentalFoundationApi::class) - @Composable - override fun Content() { - val titles = listOf("Friends", "Groups") - val coroutineScope = rememberCoroutineScope() - val pagerState = rememberPagerState { titles.size } - var showAddFriendDialog by remember { mutableStateOf(false) } - - if (showAddFriendDialog) { - addFriendDialog.Content { - showAddFriendDialog = false - } - } - - Scaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { - showAddFriendDialog = true - }, - modifier = Modifier.padding(10.dp), - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - shape = RoundedCornerShape(16.dp), - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = null - ) - } - } - ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues)) { - TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset( - pagerState = pagerState, - tabPositions = tabPositions - ) - ) - }) { - titles.forEachIndexed { index, title -> - Tab( - selected = pagerState.currentPage == index, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { - Text( - text = title, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - - HorizontalPager( - modifier = Modifier.padding(paddingValues), - state = pagerState - ) { page -> - when (page) { - 0 -> ScopeList(SocialScope.FRIEND) - 1 -> ScopeList(SocialScope.GROUP) - } - } - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/SettingsOverlay.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/SettingsOverlay.kt @@ -25,9 +25,7 @@ import com.arthenica.ffmpegkit.Packages.getPackageName import me.rhunk.snapenhance.R import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.common.ui.createComposeView -import me.rhunk.snapenhance.ui.manager.EnumSection import me.rhunk.snapenhance.ui.manager.Navigation -import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection class SettingsOverlay( @@ -55,26 +53,15 @@ class SettingsOverlay( dismissCallback = { navHostController.popBackStack() } } - val navigation = remember { - Navigation( - context, - mapOf( - EnumSection.FEATURES to FeaturesSection().apply { - enumSection = EnumSection.FEATURES - context = this@SettingsOverlay.context - } - ), - navHostController - ) - } + val navigation = remember { Navigation(context, navHostController) } Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { navigation.TopBar() } ) { innerPadding -> - navigation.NavigationHost( - startDestination = EnumSection.FEATURES, - innerPadding = innerPadding + navigation.Content( + innerPadding, + startDestination = navigation.routes.features.routeInfo.id ) } } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -29,6 +29,8 @@ "home_settings": "Settings", "home_logs": "Logs", "social": "Social", + "manage_scope": "Manage Scope", + "messaging_preview": "Preview", "scripts": "Scripts" }, "sections": { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -110,7 +110,6 @@ class BridgeClient( return runCatching { block() }.getOrElse { - context.log.error("failed to call service", it) if (it is DeadObjectException) { context.softRestartApp() }