From 3e3424fea3afd009b316ab24ef09c0eb2485a8a9 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 10 Feb 2024 19:00:58 +0100 Subject: [PATCH] refactor(app): navigation --- .../snapenhance/ui/manager/MainActivity.kt | 37 +-- .../snapenhance/ui/manager/Navigation.kt | 141 +++++---- .../me/rhunk/snapenhance/ui/manager/Routes.kt | 134 +++++++++ .../rhunk/snapenhance/ui/manager/Section.kt | 91 ------ .../TasksSection.kt => pages/TasksRoot.kt} | 22 +- .../features/CallbackAlias.kt | 2 +- .../features/FeaturesRoot.kt} | 110 +++---- .../home/HomeLogs.kt} | 114 ++++--- .../HomeSection.kt => pages/home/HomeRoot.kt} | 147 ++++------ .../home/HomeSettings.kt} | 20 +- .../scripting/ScriptingRoot.kt} | 34 +-- .../social/AddFriendDialog.kt | 12 +- .../ui/manager/pages/social/LoggedStories.kt | 277 ++++++++++++++++++ .../social/ManageScope.kt} | 84 ++++-- .../social/MessagingPreview.kt | 212 +++++++------- .../social/SocialRoot.kt} | 142 ++------- .../ui/manager/sections/DebugSection.kt | 4 - .../ui/manager/sections/NotImplemented.kt | 23 -- .../manager/sections/features/PickLocation.kt | 4 - .../manager/sections/social/LoggedStories.kt | 276 ----------------- .../snapenhance/ui/overlay/SettingsOverlay.kt | 21 +- common/src/main/assets/lang/en_US.json | 2 + .../snapenhance/core/bridge/BridgeClient.kt | 1 - 23 files changed, 887 insertions(+), 1023 deletions(-) create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/TasksSection.kt => pages/TasksRoot.kt} (97%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections => pages}/features/CallbackAlias.kt (65%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/features/FeaturesSection.kt => pages/features/FeaturesRoot.kt} (85%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/home/HomeSubSection.kt => pages/home/HomeLogs.kt} (80%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/home/HomeSection.kt => pages/home/HomeRoot.kt} (73%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/home/SettingsSection.kt => pages/home/HomeSettings.kt} (95%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/scripting/ScriptsSection.kt => pages/scripting/ScriptingRoot.kt} (93%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections => pages}/social/AddFriendDialog.kt (96%) create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/social/ScopeContent.kt => pages/social/ManageScope.kt} (84%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections => pages}/social/MessagingPreview.kt (79%) rename app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/{sections/social/SocialSection.kt => pages/social/SocialRoot.kt} (68%) delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/DebugSection.kt delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/PickLocation.kt delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt 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 index fdfd46d4..58d91c65 100644 --- 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 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 index 4aaecdaa..36306146 100644 --- 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, - private val navHostController: NavHostController + private val navController: NavHostController, + val routes: Routes = Routes(context).also { + it.navController = navController + } ){ - @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 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,65 +54,88 @@ 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 - } - launchSingleTop = true - restoreState = 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) + } + } + 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 new file mode 100644 index 00000000..e9935a07 --- /dev/null +++ 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() +} + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class Routes( + private val context: RemoteSideContext, +) { + lateinit var navController: NavController + private val routes = mutableListOf() + + 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) = args.takeIf { it.isNotEmpty() }?.let { + args.entries.fold(id) { acc, (key, value) -> + acc.replace("{$key}", value) + } + } ?: id + + fun navigate(args: MutableMap.() -> Unit = {}) { + routes.navController.navigate(replaceArguments(routeInfo.id, HashMap().apply { args() })) + } + + fun navigateReset(args: MutableMap.() -> Unit = {}) { + routes.navController.navigate(replaceArguments(routeInfo.id, HashMap().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 = 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 deleted file mode 100644 index ed1f8242..00000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt +++ /dev/null @@ -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 = 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/sections/TasksSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt similarity index 97% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt index d03a8aa0..1c4a872d 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections +package me.rhunk.snapenhance.ui.manager.pages import android.content.Intent import androidx.compose.foundation.border @@ -15,12 +15,12 @@ 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.compose.ui.unit.dp import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle -import kotlinx.coroutines.CoroutineScope + import androidx.navigation.NavBackStackEntry + import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.rhunk.snapenhance.bridge.DownloadCallback @@ -35,13 +35,13 @@ 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 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 TasksSection : Section() { +class TasksRoot : Routes.Route() { private var activeTasks by mutableStateOf(listOf()) private lateinit var recentTasks: MutableList private val taskSelection = mutableStateListOf>() @@ -132,9 +132,7 @@ class TasksSection : Section() { } } - - @Composable - override fun TopBarActions(rowScope: RowScope) { + override val topBarActions: @Composable() (RowScope.() -> Unit) = { var showConfirmDialog by remember { mutableStateOf(false) } if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { @@ -386,9 +384,7 @@ class TasksSection : Section() { } } - @Preview - @Composable - override fun Content() { + override val content: @Composable (NavBackStackEntry) -> Unit = { val scrollState = rememberLazyListState() val scope = rememberCoroutineScope() recentTasks = remember { mutableStateListOf() } 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/pages/features/CallbackAlias.kt similarity index 65% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/CallbackAlias.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt index d56e3979..469d5d1c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/CallbackAlias.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/CallbackAlias.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.features +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/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt similarity index 85% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt index ff47411e..4f66c27b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.features +package me.rhunk.snapenhance.ui.manager.pages.features import android.content.Intent import android.net.Uri @@ -30,33 +30,29 @@ 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 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.manager.Routes import me.rhunk.snapenhance.ui.util.* @OptIn(ExperimentalMaterial3Api::class) -class FeaturesSection : Section() { +class FeaturesRoot : Routes.Route() { 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 { @@ -85,24 +81,14 @@ class FeaturesSection : Section() { } private fun navigateToMainRoot() { - navController.navigate(MAIN_ROUTE, NavOptions.Builder() - .setPopUpTo(navController.graph.findStartDestination().id, false) + routes.navController.navigate(routeInfo.id, NavOptions.Builder() + .setPopUpTo(routes.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() { + override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } @@ -111,39 +97,39 @@ class FeaturesSection : Section() { //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) + intent.putExtra("route", routeInfo.id) context.androidContext.startActivity(intent) } } - override fun build(navGraphBuilder: NavGraphBuilder) { - navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { - composable(MAIN_ROUTE) { - Container(context.config.root) - } + override val content: @Composable (NavBackStackEntry) -> Unit = { + 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) - } + 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) } + 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) - } + PropertiesView(properties) } } } @@ -262,7 +248,7 @@ class FeaturesSection : Section() { val container = propertyValue.get() as ConfigContainer registerClickCallback { - navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) + routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) } if (!container.hasGlobalState) return @@ -398,10 +384,10 @@ class FeaturesSection : Section() { } currentSearchJob?.cancel() scope.launch { - delay(300) - navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() + delay(150) + routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() .setLaunchSingleTop(true) - .setPopUpTo(MAIN_ROUTE, false) + .setPopUpTo(routeInfo.id, false) .build() ) }.also { currentSearchJob = it } @@ -428,13 +414,12 @@ class FeaturesSection : Section() { } } - @Composable - override fun TopBarActions(rowScope: RowScope) { + override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ var showSearchBar by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } if (showSearchBar) { - FeatureSearchBar(rowScope, focusRequester) + FeatureSearchBar(this, focusRequester) LaunchedEffect(true) { focusRequester.requestFocus() } @@ -442,18 +427,18 @@ class FeaturesSection : Section() { IconButton(onClick = { showSearchBar = showSearchBar.not() - if (!showSearchBar && currentRoute == SEARCH_FEATURE_ROUTE) { + if (!showSearchBar && routes.currentDestination == SEARCH_FEATURE_ROUTE) { navigateToMainRoot() } }) { Icon( imageVector = if (showSearchBar) Icons.Filled.Close - else Icons.Filled.Search, + else Icons.Filled.Search, contentDescription = null ) } - if (showSearchBar) return + if (showSearchBar) return@topBarActions var showExportDropdownMenu by remember { mutableStateOf(false) } var showResetConfirmationDialog by remember { mutableStateOf(false) } @@ -504,11 +489,13 @@ class FeaturesSection : Section() { ) } - IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = null - ) + if (context.activity != null) { + IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null + ) + } } if (showExportDropdownMenu) { @@ -553,8 +540,7 @@ class FeaturesSection : Section() { ) } - @Composable - override fun FloatingActionButton() { + override val floatingActionButton: @Composable () -> Unit = { val scope = rememberCoroutineScope() FloatingActionButton( onClick = { 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/pages/home/HomeLogs.kt similarity index 80% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt index dc537bd1..191cf74e 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.home +package me.rhunk.snapenhance.ui.manager.pages.home import android.net.Uri import androidx.compose.foundation.ScrollState @@ -27,27 +27,74 @@ 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 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.RemoteSideContext 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 HomeSubSection( - private val context: RemoteSideContext -) { +class HomeLogs : Routes.Route() { private val logListState by lazy { LazyListState(0) } + private lateinit var activityLauncherHelper: ActivityLauncherHelper - @Composable - fun LogsSection() { + 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) } @@ -172,56 +219,7 @@ class HomeSubSection( } } - @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() { + override val floatingActionButton: @Composable () -> Unit = { val coroutineScope = rememberCoroutineScope() Column( verticalArrangement = Arrangement.spacedBy(5.dp), 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/pages/home/HomeRoot.kt similarity index 73% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt index 6c94895f..9102fba9 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.home +package me.rhunk.snapenhance.ui.manager.pages.home import android.content.Intent import android.net.Uri @@ -11,8 +11,7 @@ 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.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -25,36 +24,29 @@ 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 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.Section +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 java.util.Locale +import me.rhunk.snapenhance.ui.util.OnLifecycleEvent -class HomeSection : Section() { +class HomeRoot : Routes.Route() { 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() { + override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } @@ -109,91 +101,28 @@ class HomeSection : Section() { } } - override fun onResumed() { - if (!context.mappings.isMappingsLoaded) { - context.mappings.init(context.androidContext) + override val topBarActions: @Composable (RowScope.() -> Unit) = { + IconButton(onClick = { + routes.homeLogs.navigate() + }) { + Icon(Icons.Filled.BugReport, contentDescription = null) } - 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}") - } + IconButton(onClick = { + routes.settings.navigate() + }) { + Icon(Icons.Filled.Settings, contentDescription = null) } } - 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() { + override val content: @Composable (NavBackStackEntry) -> Unit = { val avenirNextFontFamily = remember { FontFamily( Font(R.font.avenir_next_medium, FontWeight.Medium) ) } + var latestUpdate by remember { mutableStateOf(null) } + Column( modifier = Modifier .verticalScroll(ScrollState(0)) @@ -312,7 +241,37 @@ class HomeSection : Section() { } } - SummaryCards(installationSummary = installationSummary ?: return) + 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/sections/home/SettingsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt similarity index 95% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt index 100ad436..bde387e0 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.home +package me.rhunk.snapenhance.ui.manager.pages.home import androidx.compose.foundation.ScrollState import androidx.compose.foundation.clickable @@ -15,22 +15,26 @@ 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.Section +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 SettingsSection( - private val activityLauncherHelper: ActivityLauncherHelper -) : Section() { +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) @@ -102,10 +106,8 @@ class SettingsSection( ) { content(this) } } - @OptIn(ExperimentalMaterial3Api::class) - @Composable - override fun Content() { + override val content: @Composable (NavBackStackEntry) -> Unit = { Column( modifier = Modifier .fillMaxSize() @@ -208,7 +210,7 @@ class SettingsSection( readOnly = true, modifier = Modifier.menuAnchor() ) - + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { BridgeFileType.entries.forEach { fileType -> DropdownMenuItem(onClick = { 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/pages/scripting/ScriptingRoot.kt similarity index 93% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt index ff6ea089..0dc68cf2 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.scripting +package me.rhunk.snapenhance.ui.manager.pages.scripting import android.content.Intent import androidx.compose.foundation.clickable @@ -17,6 +17,7 @@ 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 @@ -25,17 +26,17 @@ 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.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 ScriptsSection : Section() { +class ScriptingRoot : Routes.Route() { private lateinit var activityLauncherHelper: ActivityLauncherHelper - override fun init() { + override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } @@ -107,8 +108,7 @@ class ScriptsSection : Section() { } } - @Composable - override fun FloatingActionButton() { + override val floatingActionButton: @Composable () -> Unit = { Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End, @@ -160,8 +160,7 @@ class ScriptsSection : Section() { } } - @Composable - override fun Content() { + override val content: @Composable (NavBackStackEntry) -> Unit = { var scriptModules by remember { mutableStateOf(listOf()) } var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } val coroutineScope = rememberCoroutineScope() @@ -289,17 +288,14 @@ class ScriptsSection : Section() { } } - @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") - } + 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/sections/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt similarity index 96% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt index 9daba1d4..102faf1d 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.social +package me.rhunk.snapenhance.ui.manager.pages.social import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -32,7 +32,7 @@ import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper class AddFriendDialog( private val context: RemoteSideContext, - private val section: SocialSection, + private val socialRoot: SocialRoot, ) { private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} @@ -224,9 +224,7 @@ class AddFriendDialog( } else { context.modDatabase.deleteGroup(group.conversationId) } - context.modDatabase.executeAsync { - section.onResumed() - } + socialRoot.updateScopeLists() } } @@ -251,9 +249,7 @@ class AddFriendDialog( } else { context.modDatabase.deleteFriend(friend.userId) } - context.modDatabase.executeAsync { - section.onResumed() - } + socialRoot.updateScopeLists() } } } 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 new file mode 100644 index 00000000..7aebb093 --- /dev/null +++ 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() + } + val friendInfo = remember { + context.modDatabase.getFriendInfo(userId) + } + val httpClient = remember { OkHttpClient() } + var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } + + var selectedStory by remember { mutableStateOf(null) } + var coilCacheFile by remember { mutableStateOf(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(null) } + val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) } + + fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean { + runCatching { + val mediaList = mutableMapOf() + + 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/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt similarity index 84% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt index a171a8a5..873ea2b2 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt @@ -1,68 +1,90 @@ -package me.rhunk.snapenhance.ui.manager.sections.social +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.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.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.NavController +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.currentBackStackEntryAsState 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.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 ScopeContent( - private val context: RemoteSideContext, - private val section: SocialSection, - private val navController: NavController, - val scope: SocialScope, - private val id: String -) { +class ManageScope: Routes.Route() { private val dialogs by lazy { AlertDialogs(context.translation) } private val translation by lazy { context.translation.getCategory("manager.sections.social") } - fun deleteScope(coroutineScope: CoroutineScope) { + 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 { - section.onResumed() - navController.popBackStack() + routes.navController.popBackStack() } } } - @Composable - fun Content() { + 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() - SocialScope.GROUP -> Group() + SocialScope.FRIEND -> Friend(id) + SocialScope.GROUP -> Group(id) } Spacer(modifier = Modifier.height(16.dp)) @@ -163,7 +185,7 @@ class ScopeContent( @OptIn(ExperimentalEncodingApi::class) @Composable - private fun Friend() { + private fun Friend(id: String) { //fetch the friend from the database val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { Text(text = translation["not_found"]) @@ -208,7 +230,9 @@ class ScopeContent( horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), ) { Button(onClick = { - navController.navigate(SocialSection.LOGGED_STORIES_ROUTE.replace("{userId}", id)) + routes.loggedStories.navigate { + put("id", id) + } }) { Text("Show Logged Stories") } @@ -331,7 +355,7 @@ class ScopeContent( } @Composable - private fun Group() { + private fun Group(id: String) { //fetch the group from the database val group = remember { context.modDatabase.getGroupInfo(id) } ?: run { Text(text = translation["not_found"]) 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/pages/social/MessagingPreview.kt similarity index 79% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt index 870f20a2..25d830b8 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.social +package me.rhunk.snapenhance.ui.manager.pages.social import android.content.Intent import androidx.compose.foundation.background @@ -23,8 +23,8 @@ 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.RemoteSideContext import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener import me.rhunk.snapenhance.bridge.snapclient.types.Message @@ -38,23 +38,21 @@ 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( - private val context: RemoteSideContext, - private val scope: SocialScope, - private val scopeId: String -) { +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 conversationId: String? = null - private val messages = sortedMapOf() // server message id => message + private var messages = sortedMapOf() private var messageSize by mutableIntStateOf(0) - private var lastMessageId = Long.MAX_VALUE + private var conversationId by mutableStateOf(null) private val selectedMessages = mutableStateListOf() // client message id private fun toggleSelectedMessage(messageId: Long) { @@ -172,8 +170,7 @@ class MessagingPreview( } } - @Composable - fun TopBarAction() { + 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?) } @@ -325,7 +322,11 @@ class MessagingPreview( } @Composable - private fun ConversationPreview() { + private fun ConversationPreview( + messages: SortedMap, + messageSize: Int, + fetchNewMessages: () -> Unit + ) { DisposableEffect(Unit) { onDispose { selectedMessages.clear() @@ -393,65 +394,6 @@ class MessagingPreview( } } - 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() { @@ -471,41 +413,113 @@ class MessagingPreview( } } - @Composable - fun Content() { + 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() ) { - 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") } @@ -515,7 +529,7 @@ class MessagingPreview( } if (isBridgeConnected && !hasBridgeError) { - ConversationPreview() + ConversationPreview(messages, messageSize, ::fetchNewMessages) } } } 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/pages/social/SocialRoot.kt similarity index 68% rename from app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt index 6d266a21..62a2b2de 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.ui.manager.sections.social +package me.rhunk.snapenhance.ui.manager.pages.social import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -10,7 +10,6 @@ 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 @@ -22,10 +21,7 @@ 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 androidx.navigation.NavBackStackEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -34,119 +30,25 @@ 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.manager.Routes import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset -class SocialSection : Section() { - private lateinit var friendList: List - private lateinit var groupList: List +class SocialRoot : Routes.Route() { + private var friendList: List by mutableStateOf(emptyList()) + private var groupList: List by mutableStateOf(emptyList()) - 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}" + fun updateScopeLists() { + context.coroutineScope.launch(Dispatchers.IO) { + friendList = context.modDatabase.getFriends(descOrder = true) + groupList = context.modDatabase.getGroups() + } } - 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() } @@ -186,9 +88,10 @@ class SocialSection : Section() { .fillMaxWidth() .height(80.dp) .clickable { - navController.navigate( - scope.tabRoute.replace("{id}", id) - ) + routes.manageScope.navigate { + put("id", id) + put("scope", scope.key) + } }, ) { Row( @@ -275,9 +178,10 @@ class SocialSection : Section() { } FilledIconButton(onClick = { - navController.navigate( - MESSAGING_PREVIEW_ROUTE.replace("{id}", id).replace("{scope}", scope.key) - ) + routes.messagingPreview.navigate { + put("id", id) + put("scope", scope.key) + } }) { Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null) } @@ -287,10 +191,8 @@ class SocialSection : Section() { } } - @OptIn(ExperimentalFoundationApi::class) - @Composable - override fun Content() { + override val content: @Composable (NavBackStackEntry) -> Unit = { val titles = listOf("Friends", "Groups") val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState { titles.size } @@ -302,6 +204,10 @@ class SocialSection : Section() { } } + LaunchedEffect(Unit) { + updateScopeLists() + } + Scaffold( floatingActionButton = { FloatingActionButton( 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 deleted file mode 100644 index e37e169d..00000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/DebugSection.kt +++ /dev/null @@ -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 deleted file mode 100644 index 0cd63d59..00000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt +++ /dev/null @@ -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/features/PickLocation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/PickLocation.kt deleted file mode 100644 index 56a63ada..00000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/PickLocation.kt +++ /dev/null @@ -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/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt deleted file mode 100644 index f5a67bb1..00000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt +++ /dev/null @@ -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() - } - val friendInfo = remember { - context.modDatabase.getFriendInfo(userId) - } - val httpClient = remember { OkHttpClient() } - var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } - - var selectedStory by remember { mutableStateOf(null) } - var coilCacheFile by remember { mutableStateOf(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(null) } - val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) } - - fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean { - runCatching { - val mediaList = mutableMapOf() - - 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/overlay/SettingsOverlay.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/overlay/SettingsOverlay.kt index 1eac8238..1be75615 100644 --- 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 index 13967194..19a6dec4 100644 --- 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 index 91abfd0f..267ce8f3 100644 --- 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() }