refactor(app): navigation

This commit is contained in:
rhunk
2024-02-10 19:00:58 +01:00
parent d6128a8849
commit 3e3424fea3
23 changed files with 887 additions and 1023 deletions

View File

@ -15,15 +15,9 @@ import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.common.ui.AppMaterialTheme import me.rhunk.snapenhance.common.ui.AppMaterialTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var sections: Map<EnumSection, Section>
private lateinit var navController: NavHostController private lateinit var navController: NavHostController
private lateinit var managerContext: RemoteSideContext private lateinit var managerContext: RemoteSideContext
override fun onPostResume() {
super.onPostResume()
sections.values.forEach { it.onResumed() }
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (::navController.isInitialized.not()) return if (::navController.isInitialized.not()) return
@ -40,37 +34,30 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val startDestination = intent.getStringExtra("route")?.let { EnumSection.fromRoute(it) } ?: EnumSection.HOME
managerContext = SharedContextHolder.remote(this).apply { managerContext = SharedContextHolder.remote(this).apply {
activity = this@MainActivity activity = this@MainActivity
checkForRequirements() checkForRequirements()
} }
sections = EnumSection.entries.associateWith { val routes = Routes(managerContext)
it.section.java.constructors.first().newInstance() as Section routes.getRoutes().forEach { it.init() }
}.onEach { (section, instance) ->
with(instance) {
enumSection = section
context = managerContext
init()
}
}
setContent { setContent {
navController = rememberNavController() 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 { AppMaterialTheme {
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { navigation.TopBar() }, topBar = { navigation.TopBar() },
bottomBar = { navigation.NavBar() }, bottomBar = { navigation.BottomBar() },
floatingActionButton = { navigation.Fab() } floatingActionButton = { navigation.FloatingActionButton() }
) { innerPadding -> ) { innerPadding -> navigation.Content(innerPadding, startDestination) }
navigation.NavigationHost(
innerPadding = innerPadding,
startDestination = startDestination
)
}
} }
} }
} }

View File

@ -10,64 +10,40 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp 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.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navigation
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
@OptIn(ExperimentalMaterial3Api::class)
class Navigation( class Navigation(
private val context: RemoteSideContext, private val context: RemoteSideContext,
private val sections: Map<EnumSection, Section>, private val navController: NavHostController,
private val navHostController: 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 @Composable
fun TopBar() { fun TopBar() {
val navBackStackEntry by navHostController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination ?: return
val currentSection = getCurrentSection(currentDestination) val canGoBack = remember (navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry)?.let {
!it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination)
} == true }
TopAppBar(title = { TopAppBar(title = {
currentSection.Title() routes.getCurrentRoute(navBackStackEntry)?.title?.invoke() ?: Text(text = routes.getCurrentRoute(navBackStackEntry)?.routeInfo?.translatedKey ?: "Unknown Page")
}, navigationIcon = { }, navigationIcon = {
val backButtonAnimation by animateFloatAsState(if (currentSection.canGoBack()) 1f else 0f, val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f,
label = "backButtonAnimation" label = "backButtonAnimation"
) )
@ -78,65 +54,88 @@ class Navigation(
.height(48.dp) .height(48.dp)
) { ) {
IconButton( IconButton(
onClick = { navHostController.popBackStack() } onClick = {
if (canGoBack) {
navController.popBackStack()
}
}
) { ) {
Icon(Icons.Filled.ArrowBack, contentDescription = null) Icon(Icons.Filled.ArrowBack, contentDescription = null)
} }
} }
}, actions = { }, actions = {
currentSection.TopBarActions(this) routes.getCurrentRoute(navBackStackEntry)?.topBarActions?.invoke(this)
}) })
} }
@Composable @Composable
fun Fab() { fun BottomBar() {
val navBackStackEntry by navHostController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination ?: return val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.primary } }
val currentSection = getCurrentSection(currentDestination)
currentSection.FloatingActionButton()
}
@Composable
fun NavBar() {
NavigationBar { NavigationBar {
val navBackStackEntry by navHostController.currentBackStackEntryAsState() val currentRoute = routes.getCurrentRoute(navBackStackEntry)
val currentDestination = navBackStackEntry?.destination primaryRoutes.forEach { route ->
sections.keys.forEach { section ->
fun selected() = currentDestination?.hierarchy?.any { it.route == section.route } == true
NavigationBarItem( NavigationBarItem(
alwaysShowLabel = false, alwaysShowLabel = false,
modifier = Modifier modifier = Modifier.fillMaxHeight(),
.fillMaxHeight(),
icon = { icon = {
Icon( Icon(imageVector = route.routeInfo.icon, contentDescription = null)
imageVector = section.icon,
contentDescription = null
)
}, },
label = { label = {
Text( Text(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
softWrap = false, softWrap = false,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.wrapContentWidth(unbounded = true), 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 = { onClick = {
navHostController.navigate(section.route) { route.navigateReset()
popUpTo(navHostController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
} }
) )
} }
} }
} }
@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)
}
}
}
}
}
} }

View File

@ -0,0 +1,134 @@
package me.rhunk.snapenhance.ui.manager
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Stars
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
import me.rhunk.snapenhance.ui.manager.pages.home.HomeRoot
import me.rhunk.snapenhance.ui.manager.pages.home.HomeSettings
import me.rhunk.snapenhance.ui.manager.pages.scripting.ScriptingRoot
import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRoot
data class RouteInfo(
val id: String,
val key: String = id,
val icon: ImageVector = Icons.Default.Home,
val primary: Boolean = false,
) {
var translatedKey: String? = null
val childIds = mutableListOf<String>()
}
@Suppress("unused", "MemberVisibilityCanBePrivate")
class Routes(
private val context: RemoteSideContext,
) {
lateinit var navController: NavController
private val routes = mutableListOf<Route>()
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true), TasksRoot())
val features = route(RouteInfo("features", icon = Icons.Default.Stars, primary = true), FeaturesRoot())
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot())
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
val messagingPreview = route(RouteInfo("messaging_preview/?scope={scope}&id={id}"), MessagingPreview()).parent(social)
val loggedStories = route(RouteInfo("logged_stories/?id={id}"), LoggedStories()).parent(social)
val scripting = route(RouteInfo("scripts", icon = Icons.Filled.DataObject, primary = true), ScriptingRoot())
open class Route {
open val init: () -> Unit = { }
open val title: @Composable (() -> Unit)? = null
open val topBarActions: @Composable RowScope.() -> Unit = {}
open val floatingActionButton: @Composable () -> Unit = {}
open val content: @Composable (NavBackStackEntry) -> Unit = {}
open val customComposables: NavGraphBuilder.() -> Unit = {}
var parentRoute: Route? = null
private set
lateinit var context: RemoteSideContext
lateinit var routeInfo: RouteInfo
lateinit var routes: Routes
private fun replaceArguments(id: String, args: Map<String, String>) = args.takeIf { it.isNotEmpty() }?.let {
args.entries.fold(id) { acc, (key, value) ->
acc.replace("{$key}", value)
}
} ?: id
fun navigate(args: MutableMap<String, String>.() -> Unit = {}) {
routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() }))
}
fun navigateReset(args: MutableMap<String, String>.() -> Unit = {}) {
routes.navController.navigate(replaceArguments(routeInfo.id, HashMap<String, String>().apply { args() })) {
popUpTo(routes.navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
fun parent(route: Route): Route {
assert(route.routeInfo.primary) { "Parent route must be a primary route" }
parentRoute = route
return this
}
}
val currentRoute: Route?
get() = routes.firstOrNull { route ->
navController.currentBackStackEntry?.destination?.hierarchy?.any { it.route == route.routeInfo.id } ?: false
}
val currentDestination: String?
get() = navController.currentBackStackEntry?.destination?.route
fun getCurrentRoute(navBackStackEntry: NavBackStackEntry?): Route? {
if (navBackStackEntry == null) return null
return navBackStackEntry.destination.hierarchy.firstNotNullOfOrNull { destination ->
routes.firstOrNull { route ->
route.routeInfo.id == destination.route || route.routeInfo.childIds.contains(destination.route)
}
}
}
fun getRoutes(): List<Route> = routes
private fun route(routeInfo: RouteInfo, route: Route): Route {
route.apply {
this.routeInfo = routeInfo
routes = this@Routes
context = this@Routes.context
this.routeInfo.translatedKey = context.translation.getOrNull("manager.routes.${route.routeInfo.key.substringBefore("/")}")
}
routes.add(route)
return route
}
}

View File

@ -1,91 +0,0 @@
package me.rhunk.snapenhance.ui.manager
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.sections.NotImplemented
import me.rhunk.snapenhance.ui.manager.sections.TasksSection
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
import me.rhunk.snapenhance.ui.manager.sections.home.HomeSection
import me.rhunk.snapenhance.ui.manager.sections.scripting.ScriptsSection
import me.rhunk.snapenhance.ui.manager.sections.social.SocialSection
import kotlin.reflect.KClass
enum class EnumSection(
val route: String,
val icon: ImageVector,
val section: KClass<out Section> = NotImplemented::class
) {
TASKS(
route = "tasks",
icon = Icons.Filled.TaskAlt,
section = TasksSection::class
),
FEATURES(
route = "features",
icon = Icons.Filled.Stars,
section = FeaturesSection::class
),
HOME(
route = "home",
icon = Icons.Filled.Home,
section = HomeSection::class
),
SOCIAL(
route = "social",
icon = Icons.Filled.Group,
section = SocialSection::class
),
SCRIPTS(
route = "scripts",
icon = Icons.Filled.DataObject,
section = ScriptsSection::class
);
companion object {
fun fromRoute(route: String): EnumSection {
return entries.first { it.route == route }
}
}
}
open class Section {
lateinit var enumSection: EnumSection
lateinit var context: RemoteSideContext
lateinit var navController: NavController
val currentRoute get() = navController.currentBackStackEntry?.destination?.route
open fun init() {}
open fun onResumed() {}
open fun sectionTopBarName(): String = context.translation["manager.routes.${enumSection.route}"]
open fun canGoBack(): Boolean = false
@Composable
open fun Title() { Text(text = sectionTopBarName()) }
@Composable
open fun Content() { NotImplemented() }
@Composable
open fun TopBarActions(rowScope: RowScope) {}
@Composable
open fun FloatingActionButton() {}
open fun build(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable(enumSection.route) {
Content()
}
}
}

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.ui.manager.sections package me.rhunk.snapenhance.ui.manager.pages
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.border import androidx.compose.foundation.border
@ -15,12 +15,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap 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.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.CoroutineScope import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.bridge.DownloadCallback 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.Task
import me.rhunk.snapenhance.task.TaskStatus import me.rhunk.snapenhance.task.TaskStatus
import me.rhunk.snapenhance.task.TaskType import me.rhunk.snapenhance.task.TaskType
import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
class TasksSection : Section() { class TasksRoot : Routes.Route() {
private var activeTasks by mutableStateOf(listOf<PendingTask>()) private var activeTasks by mutableStateOf(listOf<PendingTask>())
private lateinit var recentTasks: MutableList<Task> private lateinit var recentTasks: MutableList<Task>
private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
@ -132,9 +132,7 @@ class TasksSection : Section() {
} }
} }
override val topBarActions: @Composable() (RowScope.() -> Unit) = {
@Composable
override fun TopBarActions(rowScope: RowScope) {
var showConfirmDialog by remember { mutableStateOf(false) } var showConfirmDialog by remember { mutableStateOf(false) }
if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) {
@ -386,9 +384,7 @@ class TasksSection : Section() {
} }
} }
@Preview override val content: @Composable (NavBackStackEntry) -> Unit = {
@Composable
override fun Content() {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
recentTasks = remember { mutableStateListOf() } recentTasks = remember { mutableStateListOf() }

View File

@ -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 ClickCallback = (Boolean) -> Unit
typealias RegisterClickCallback = (ClickCallback) -> ClickCallback typealias RegisterClickCallback = (ClickCallback) -> ClickCallback

View File

@ -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.content.Intent
import android.net.Uri 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.config.* import me.rhunk.snapenhance.common.config.*
import me.rhunk.snapenhance.ui.manager.MainActivity 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.* import me.rhunk.snapenhance.ui.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
class FeaturesSection : Section() { class FeaturesRoot : Routes.Route() {
private val alertDialogs by lazy { AlertDialogs(context.translation) } private val alertDialogs by lazy { AlertDialogs(context.translation) }
companion object { companion object {
const val MAIN_ROUTE = "feature_root"
const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}" const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}"
const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}" const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}"
} }
private var activityLauncherHelper: ActivityLauncherHelper? = null private var activityLauncherHelper: ActivityLauncherHelper? = null
private val featuresRouteName by lazy { context.translation["manager.routes.features"] }
private lateinit var rememberScaffoldState: BottomSheetScaffoldState private lateinit var rememberScaffoldState: BottomSheetScaffoldState
private val allContainers by lazy { private val allContainers by lazy {
@ -85,24 +81,14 @@ class FeaturesSection : Section() {
} }
private fun navigateToMainRoot() { private fun navigateToMainRoot() {
navController.navigate(MAIN_ROUTE, NavOptions.Builder() routes.navController.navigate(routeInfo.id, NavOptions.Builder()
.setPopUpTo(navController.graph.findStartDestination().id, false) .setPopUpTo(routes.navController.graph.findStartDestination().id, false)
.setLaunchSingleTop(true) .setLaunchSingleTop(true)
.build() .build()
) )
} }
override fun canGoBack() = sectionTopBarName() != featuresRouteName override val init: () -> Unit = {
override fun sectionTopBarName(): String {
navController.currentBackStackEntry?.arguments?.getString("name")?.let { routeName ->
val currentContainerPair = allContainers[routeName]
return context.translation["${currentContainerPair?.key?.propertyTranslationPath()}.name"]
}
return featuresRouteName
}
override fun init() {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
@ -111,39 +97,39 @@ class FeaturesSection : Section() {
//open manager if activity launcher is null //open manager if activity launcher is null
val intent = Intent(context.androidContext, MainActivity::class.java) val intent = Intent(context.androidContext, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra("route", enumSection.route) intent.putExtra("route", routeInfo.id)
context.androidContext.startActivity(intent) context.androidContext.startActivity(intent)
} }
} }
override fun build(navGraphBuilder: NavGraphBuilder) { override val content: @Composable (NavBackStackEntry) -> Unit = {
navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { Container(context.config.root)
composable(MAIN_ROUTE) { }
Container(context.config.root)
}
composable(FEATURE_CONTAINER_ROUTE, enterTransition = { override val customComposables: NavGraphBuilder.() -> Unit = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100)) routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE))
}, exitTransition = {
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) composable(FEATURE_CONTAINER_ROUTE, enterTransition = {
}) { backStackEntry -> slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100))
backStackEntry.arguments?.getString("name")?.let { containerName -> }, exitTransition = {
allContainers[containerName]?.let { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300))
Container(it.value.get() as ConfigContainer) }) { backStackEntry ->
} backStackEntry.arguments?.getString("name")?.let { containerName ->
allContainers[containerName]?.let {
Container(it.value.get() as ConfigContainer)
} }
} }
}
composable(SEARCH_FEATURE_ROUTE) { backStackEntry -> composable(SEARCH_FEATURE_ROUTE) { backStackEntry ->
backStackEntry.arguments?.getString("keyword")?.let { keyword -> backStackEntry.arguments?.getString("keyword")?.let { keyword ->
val properties = allProperties.filter { val properties = allProperties.filter {
it.key.name.contains(keyword, ignoreCase = true) || it.key.name.contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
}.map { PropertyPair(it.key, it.value) } }.map { PropertyPair(it.key, it.value) }
PropertiesView(properties) PropertiesView(properties)
}
} }
} }
} }
@ -262,7 +248,7 @@ class FeaturesSection : Section() {
val container = propertyValue.get() as ConfigContainer val container = propertyValue.get() as ConfigContainer
registerClickCallback { registerClickCallback {
navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
} }
if (!container.hasGlobalState) return if (!container.hasGlobalState) return
@ -398,10 +384,10 @@ class FeaturesSection : Section() {
} }
currentSearchJob?.cancel() currentSearchJob?.cancel()
scope.launch { scope.launch {
delay(300) delay(150)
navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
.setLaunchSingleTop(true) .setLaunchSingleTop(true)
.setPopUpTo(MAIN_ROUTE, false) .setPopUpTo(routeInfo.id, false)
.build() .build()
) )
}.also { currentSearchJob = it } }.also { currentSearchJob = it }
@ -428,13 +414,12 @@ class FeaturesSection : Section() {
} }
} }
@Composable override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
override fun TopBarActions(rowScope: RowScope) {
var showSearchBar by remember { mutableStateOf(false) } var showSearchBar by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
if (showSearchBar) { if (showSearchBar) {
FeatureSearchBar(rowScope, focusRequester) FeatureSearchBar(this, focusRequester)
LaunchedEffect(true) { LaunchedEffect(true) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
@ -442,18 +427,18 @@ class FeaturesSection : Section() {
IconButton(onClick = { IconButton(onClick = {
showSearchBar = showSearchBar.not() showSearchBar = showSearchBar.not()
if (!showSearchBar && currentRoute == SEARCH_FEATURE_ROUTE) { if (!showSearchBar && routes.currentDestination == SEARCH_FEATURE_ROUTE) {
navigateToMainRoot() navigateToMainRoot()
} }
}) { }) {
Icon( Icon(
imageVector = if (showSearchBar) Icons.Filled.Close imageVector = if (showSearchBar) Icons.Filled.Close
else Icons.Filled.Search, else Icons.Filled.Search,
contentDescription = null contentDescription = null
) )
} }
if (showSearchBar) return if (showSearchBar) return@topBarActions
var showExportDropdownMenu by remember { mutableStateOf(false) } var showExportDropdownMenu by remember { mutableStateOf(false) }
var showResetConfirmationDialog by remember { mutableStateOf(false) } var showResetConfirmationDialog by remember { mutableStateOf(false) }
@ -504,11 +489,13 @@ class FeaturesSection : Section() {
) )
} }
IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { if (context.activity != null) {
Icon( IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) {
imageVector = Icons.Filled.MoreVert, Icon(
contentDescription = null imageVector = Icons.Filled.MoreVert,
) contentDescription = null
)
}
} }
if (showExportDropdownMenu) { if (showExportDropdownMenu) {
@ -553,8 +540,7 @@ class FeaturesSection : Section() {
) )
} }
@Composable override val floatingActionButton: @Composable () -> Unit = {
override fun FloatingActionButton() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {

View File

@ -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 android.net.Uri
import androidx.compose.foundation.ScrollState 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.LogReader
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.logger.LogChannel import me.rhunk.snapenhance.common.logger.LogChannel
import me.rhunk.snapenhance.common.logger.LogLevel 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.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
import me.rhunk.snapenhance.ui.util.saveFile import me.rhunk.snapenhance.ui.util.saveFile
class HomeSubSection( class HomeLogs : Routes.Route() {
private val context: RemoteSideContext
) {
private val logListState by lazy { LazyListState(0) } private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
@Composable override val init: () -> Unit = {
fun LogsSection() { 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 coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
var lineCount by remember { mutableIntStateOf(0) } var lineCount by remember { mutableIntStateOf(0) }
@ -172,56 +219,7 @@ class HomeSubSection(
} }
} }
@Composable override val floatingActionButton: @Composable () -> Unit = {
fun LogsTopBarButtons(activityLauncherHelper: ActivityLauncherHelper, navController: NavController, rowScope: RowScope) {
var showDropDown by remember { mutableStateOf(false) }
IconButton(onClick = {
showDropDown = true
}) {
Icon(Icons.Filled.MoreVert, contentDescription = null)
}
rowScope.apply {
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
modifier = Modifier.align(Alignment.CenterVertically)
) {
DropdownMenuItem(onClick = {
context.log.clearLogs()
navController.navigate(HomeSection.LOGS_SECTION_ROUTE)
showDropDown = false
}, text = {
Text(
text = context.translation["manager.sections.home.logs.clear_logs_button"]
)
})
DropdownMenuItem(onClick = {
activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use {
runCatching {
context.log.exportLogsToZip(it)
context.longToast("Saved logs to $uri")
}.onFailure {
context.longToast("Failed to save logs to $uri!")
context.log.error("Failed to save logs to $uri!", it)
}
}
}
showDropDown = false
}, text = {
Text(
text = context.translation["manager.sections.home.logs.export_logs_button"]
)
})
}
}
}
@Composable
fun LogsActionButtons() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Column( Column(
verticalArrangement = Arrangement.spacedBy(5.dp), verticalArrangement = Arrangement.spacedBy(5.dp),

View File

@ -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.content.Intent
import android.net.Uri 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.BugReport
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavGraphBuilder import androidx.lifecycle.Lifecycle
import androidx.navigation.compose.composable import androidx.navigation.NavBackStackEntry
import androidx.navigation.navigation import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.common.BuildConfig 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.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.Updater import me.rhunk.snapenhance.ui.manager.data.Updater
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper 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 { companion object {
val cardMargin = 10.dp 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 private lateinit var activityLauncherHelper: ActivityLauncherHelper
override fun init() { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
@ -109,91 +101,28 @@ class HomeSection : Section() {
} }
} }
override fun onResumed() { override val topBarActions: @Composable (RowScope.() -> Unit) = {
if (!context.mappings.isMappingsLoaded) { IconButton(onClick = {
context.mappings.init(context.androidContext) routes.homeLogs.navigate()
}) {
Icon(Icons.Filled.BugReport, contentDescription = null)
} }
context.coroutineScope.launch { IconButton(onClick = {
userLocale = context.translation.loadedLocale.getDisplayName(Locale.getDefault()) routes.settings.navigate()
runCatching { }) {
installationSummary = context.installationSummary Icon(Icons.Filled.Settings, contentDescription = null)
}.onFailure {
context.longToast("SnapEnhance failed to load installation summary: ${it.message}")
}
runCatching {
if (!BuildConfig.DEBUG) {
latestUpdate = Updater.checkForLatestRelease()
}
}.onFailure {
context.longToast("SnapEnhance failed to check for updates: ${it.message}")
}
} }
} }
override fun sectionTopBarName(): String { override val content: @Composable (NavBackStackEntry) -> Unit = {
if (currentRoute == HOME_ROOT) {
return ""
}
return context.translation["manager.routes.$currentRoute"]
}
@Composable
override fun FloatingActionButton() {
if (currentRoute == LOGS_SECTION_ROUTE) {
homeSubSection.LogsActionButtons()
}
}
@Composable
override fun TopBarActions(rowScope: RowScope) {
rowScope.apply {
when (currentRoute) {
HOME_ROOT -> {
IconButton(onClick = {
navController.navigate(LOGS_SECTION_ROUTE)
}) {
Icon(Icons.Filled.BugReport, contentDescription = null)
}
IconButton(onClick = {
navController.navigate(SETTINGS_SECTION_ROUTE)
}) {
Icon(Icons.Filled.Settings, contentDescription = null)
}
}
LOGS_SECTION_ROUTE -> {
homeSubSection.LogsTopBarButtons(activityLauncherHelper, navController, this)
}
}
}
}
override fun build(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.navigation(
route = enumSection.route,
startDestination = HOME_ROOT
) {
composable(HOME_ROOT) {
Content()
}
composable(LOGS_SECTION_ROUTE) {
homeSubSection.LogsSection()
}
composable(SETTINGS_SECTION_ROUTE) {
SettingsSection(activityLauncherHelper).also { it.context = context }.Content()
}
}
}
@Composable
@Preview
override fun Content() {
val avenirNextFontFamily = remember { val avenirNextFontFamily = remember {
FontFamily( FontFamily(
Font(R.font.avenir_next_medium, FontWeight.Medium) Font(R.font.avenir_next_medium, FontWeight.Medium)
) )
} }
var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) }
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(ScrollState(0)) .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) }
} }
} }
} }

View File

@ -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.ScrollState
import androidx.compose.foundation.clickable 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.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType 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.setup.Requirements
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.AlertDialogs import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.saveFile import me.rhunk.snapenhance.ui.util.saveFile
class SettingsSection( class HomeSettings : Routes.Route() {
private val activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
) : Section() {
private val dialogs by lazy { AlertDialogs(context.translation) } private val dialogs by lazy { AlertDialogs(context.translation) }
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@Composable @Composable
private fun RowTitle(title: String) { private fun RowTitle(title: String) {
Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold)
@ -102,10 +106,8 @@ class SettingsSection(
) { content(this) } ) { content(this) }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable override val content: @Composable (NavBackStackEntry) -> Unit = {
override fun Content() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -208,7 +210,7 @@ class SettingsSection(
readOnly = true, readOnly = true,
modifier = Modifier.menuAnchor() modifier = Modifier.menuAnchor()
) )
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
BridgeFileType.entries.forEach { fileType -> BridgeFileType.entries.forEach { fileType ->
DropdownMenuItem(onClick = { DropdownMenuItem(onClick = {

View File

@ -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 android.content.Intent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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.EnumScriptInterface
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface 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.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.chooseFolder import me.rhunk.snapenhance.ui.util.chooseFolder
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
class ScriptsSection : Section() { class ScriptingRoot : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
override fun init() { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
@ -107,8 +108,7 @@ class ScriptsSection : Section() {
} }
} }
@Composable override val floatingActionButton: @Composable () -> Unit = {
override fun FloatingActionButton() {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
@ -160,8 +160,7 @@ class ScriptsSection : Section() {
} }
} }
@Composable override val content: @Composable (NavBackStackEntry) -> Unit = {
override fun Content() {
var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) } var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) }
var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -289,17 +288,14 @@ class ScriptsSection : Section() {
} }
} }
@Composable override val topBarActions: @Composable() (RowScope.() -> Unit) = {
override fun TopBarActions(rowScope: RowScope) { IconButton(onClick = {
rowScope.apply { context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
IconButton(onClick = { data = "https://github.com/SnapEnhance/docs".toUri()
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK
data = "https://github.com/SnapEnhance/docs".toUri() })
flags = Intent.FLAG_ACTIVITY_NEW_TASK }) {
}) Icon(imageVector = Icons.Default.LibraryBooks, contentDescription = "Documentation")
}) {
Icon(imageVector = Icons.Default.LibraryBooks, contentDescription = "Documentation")
}
} }
} }
} }

View File

@ -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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -32,7 +32,7 @@ import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
class AddFriendDialog( class AddFriendDialog(
private val context: RemoteSideContext, private val context: RemoteSideContext,
private val section: SocialSection, private val socialRoot: SocialRoot,
) { ) {
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
@ -224,9 +224,7 @@ class AddFriendDialog(
} else { } else {
context.modDatabase.deleteGroup(group.conversationId) context.modDatabase.deleteGroup(group.conversationId)
} }
context.modDatabase.executeAsync { socialRoot.updateScopeLists()
section.onResumed()
}
} }
} }
@ -251,9 +249,7 @@ class AddFriendDialog(
} else { } else {
context.modDatabase.deleteFriend(friend.userId) context.modDatabase.deleteFriend(friend.userId)
} }
context.modDatabase.executeAsync { socialRoot.updateScopeLists()
section.onResumed()
}
} }
} }
} }

View File

@ -0,0 +1,277 @@
package me.rhunk.snapenhance.ui.manager.pages.social
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.navigation.NavBackStackEntry
import coil.annotation.ExperimentalCoilApi
import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.core.util.media.PreviewUtils
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.Dialog
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.text.DateFormat
import java.util.Date
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.math.absoluteValue
class LoggedStories : Routes.Route() {
@OptIn(ExperimentalCoilApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry ->
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember {
mutableStateListOf<StoryData>()
}
val friendInfo = remember {
context.modDatabase.getFriendInfo(userId)
}
val httpClient = remember { OkHttpClient() }
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
var coilCacheFile by remember { mutableStateOf<File?>(null) }
selectedStory?.let { story ->
Dialog(onDismissRequest = {
selectedStory = null
}) {
Card(
modifier = Modifier
.padding(4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Posted on ${story.postedAt.let {
DateFormat.getDateTimeInstance().format(Date(it))
}}")
Text(text = "Created at ${story.createdAt.let {
DateFormat.getDateTimeInstance().format(Date(it))
}}")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = {
context.androidContext.externalCacheDir?.let { cacheDir ->
val cacheFile = coilCacheFile ?: run {
context.shortToast("Failed to get file")
return@Button
}
val targetFile = File(cacheDir, cacheFile.name)
cacheFile.copyTo(targetFile, overwrite = true)
context.androidContext.startActivity(Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(
FileProvider.getUriForFile(
context.androidContext,
"me.rhunk.snapenhance.fileprovider",
targetFile
),
FileType.fromFile(targetFile).mimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}) {
Text(text = "Open")
}
Button(onClick = {
val mediaAuthor = friendInfo?.mutableUsername ?: userId
val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16)
DownloadProcessor(
remoteSideContext = context,
callback = object: DownloadCallback.Default() {
override fun onSuccess(outputPath: String?) {
context.shortToast("Downloaded to $outputPath")
}
override fun onFailure(message: String?, throwable: String?) {
context.shortToast("Failed to download $message")
}
}
).enqueue(DownloadRequest(
inputMedias = arrayOf(
InputMedia(
content = story.url,
type = DownloadMediaType.REMOTE_MEDIA,
encryption = story.key?.let { it to story.iv!! }?.toKeyPair()
)
)
), DownloadMetadata(
mediaIdentifier = uniqueHash,
outputPath = createNewFilePath(
context.config.root,
uniqueHash,
MediaDownloadSource.STORY_LOGGER,
mediaAuthor,
story.createdAt
),
iconUrl = null,
mediaAuthor = friendInfo?.mutableUsername ?: userId,
downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation),
))
}) {
Text(text = "Download")
}
}
}
}
}
}
if (stories.isEmpty()) {
Text(text = "No stories found", Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
contentPadding = PaddingValues(8.dp),
) {
items(stories) { story ->
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
runCatching {
val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
snapshot.data.toFile().inputStream().use { inputStream ->
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
mediaList[type] = splitInputStream.readBytes()
}
}
val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false
val overlay = mediaList[SplitMediaAssetType.OVERLAY]
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
overlay?.also {
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
}
imageBitmap = bitmap?.asImageBitmap()
return true
}
return false
}
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
withTimeout(10000L) {
context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let {
openDiskCacheSnapshot(it)
it.close()
return@withTimeout
}
runCatching {
val response = httpClient.newCall(Request(
url = story.url.toHttpUrl()
)).execute()
response.body.byteStream().use {
val decrypted = story.key?.let { _ ->
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv))
CipherInputStream(it, cipher)
} ?: it
context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply {
data.toFile().outputStream().use { fos ->
decrypted.copyTo(fos)
}
commitAndOpenSnapshot()?.use { snapshot ->
openDiskCacheSnapshot(snapshot)
snapshot.close()
}
}
}
}.onFailure {
context.log.error("Failed to load story", it)
}
}
}
}
Column(
modifier = Modifier
.padding(8.dp)
.clickable {
selectedStory = story
coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use {
it?.data?.toFile()
}
}
.heightIn(min = 128.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
imageBitmap?.let {
Card {
Image(
bitmap = it,
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
} ?: run {
CircularProgressIndicator()
}
}
}
item {
LaunchedEffect(Unit) {
context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result ->
stories.addAll(result.values)
result.keys.minOrNull()?.let {
lastStoryTimestamp = it
}
}
}
}
}
}
}

View File

@ -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 android.content.Intent
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material.icons.Icons
import androidx.compose.material3.Card import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.*
import androidx.compose.material3.Switch import androidx.compose.runtime.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.data.MessagingRuleType import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie 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.AlertDialogs
import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.Dialog import me.rhunk.snapenhance.ui.util.Dialog
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
class ScopeContent( class ManageScope: Routes.Route() {
private val context: RemoteSideContext,
private val section: SocialSection,
private val navController: NavController,
val scope: SocialScope,
private val id: String
) {
private val dialogs by lazy { AlertDialogs(context.translation) } private val dialogs by lazy { AlertDialogs(context.translation) }
private val translation by lazy { context.translation.getCategory("manager.sections.social") } 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) { when (scope) {
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id) SocialScope.FRIEND -> context.modDatabase.deleteFriend(id)
SocialScope.GROUP -> context.modDatabase.deleteGroup(id) SocialScope.GROUP -> context.modDatabase.deleteGroup(id)
} }
context.modDatabase.executeAsync { context.modDatabase.executeAsync {
coroutineScope.launch { coroutineScope.launch {
section.onResumed() routes.navController.popBackStack()
navController.popBackStack()
} }
} }
} }
@Composable override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{
fun Content() { 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( Column(
modifier = Modifier.verticalScroll(rememberScrollState()) modifier = Modifier.verticalScroll(rememberScrollState())
) { ) {
when (scope) { when (scope) {
SocialScope.FRIEND -> Friend() SocialScope.FRIEND -> Friend(id)
SocialScope.GROUP -> Group() SocialScope.GROUP -> Group(id)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -163,7 +185,7 @@ class ScopeContent(
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
@Composable @Composable
private fun Friend() { private fun Friend(id: String) {
//fetch the friend from the database //fetch the friend from the database
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
Text(text = translation["not_found"]) Text(text = translation["not_found"])
@ -208,7 +230,9 @@ class ScopeContent(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
) { ) {
Button(onClick = { Button(onClick = {
navController.navigate(SocialSection.LOGGED_STORIES_ROUTE.replace("{userId}", id)) routes.loggedStories.navigate {
put("id", id)
}
}) { }) {
Text("Show Logged Stories") Text("Show Logged Stories")
} }
@ -331,7 +355,7 @@ class ScopeContent(
} }
@Composable @Composable
private fun Group() { private fun Group(id: String) {
//fetch the group from the database //fetch the group from the database
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run { val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
Text(text = translation["not_found"]) Text(text = translation["not_found"])

View File

@ -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 android.content.Intent
import androidx.compose.foundation.background 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.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.* import kotlinx.coroutines.*
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener
import me.rhunk.snapenhance.bridge.snapclient.types.Message 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.MessagingTask
import me.rhunk.snapenhance.messaging.MessagingTaskConstraint import me.rhunk.snapenhance.messaging.MessagingTaskConstraint
import me.rhunk.snapenhance.messaging.MessagingTaskType import me.rhunk.snapenhance.messaging.MessagingTaskType
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.Dialog import me.rhunk.snapenhance.ui.util.Dialog
import java.util.SortedMap
class MessagingPreview( class MessagingPreview: Routes.Route() {
private val context: RemoteSideContext,
private val scope: SocialScope,
private val scopeId: String
) {
private lateinit var coroutineScope: CoroutineScope private lateinit var coroutineScope: CoroutineScope
private lateinit var messagingBridge: MessagingBridge private lateinit var messagingBridge: MessagingBridge
private lateinit var previewScrollState: LazyListState private lateinit var previewScrollState: LazyListState
private val myUserId by lazy { messagingBridge.myUserId } private val myUserId by lazy { messagingBridge.myUserId }
private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") } private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") }
private var conversationId: String? = null private var messages = sortedMapOf<Long, Message>()
private val messages = sortedMapOf<Long, Message>() // server message id => message
private var messageSize by mutableIntStateOf(0) private var messageSize by mutableIntStateOf(0)
private var lastMessageId = Long.MAX_VALUE private var conversationId by mutableStateOf<String?>(null)
private val selectedMessages = mutableStateListOf<Long>() // client message id private val selectedMessages = mutableStateListOf<Long>() // client message id
private fun toggleSelectedMessage(messageId: Long) { private fun toggleSelectedMessage(messageId: Long) {
@ -172,8 +170,7 @@ class MessagingPreview(
} }
} }
@Composable override val topBarActions: @Composable (RowScope.() -> Unit) = {
fun TopBarAction() {
var taskSelectionDropdown by remember { mutableStateOf(false) } var taskSelectionDropdown by remember { mutableStateOf(false) }
var selectConstraintsDialog by remember { mutableStateOf(false) } var selectConstraintsDialog by remember { mutableStateOf(false) }
var activeTask by remember { mutableStateOf(null as MessagingTask?) } var activeTask by remember { mutableStateOf(null as MessagingTask?) }
@ -325,7 +322,11 @@ class MessagingPreview(
} }
@Composable @Composable
private fun ConversationPreview() { private fun ConversationPreview(
messages: SortedMap<Long, Message>,
messageSize: Int,
fetchNewMessages: () -> Unit
) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
selectedMessages.clear() 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 @Composable
private fun LoadingRow() { private fun LoadingRow() {
@ -471,41 +413,113 @@ class MessagingPreview(
} }
} }
@Composable override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
fun Content() { val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) }
val id = remember { navBackStackEntry.arguments?.getString("id")!! }
previewScrollState = rememberLazyListState() previewScrollState = rememberLazyListState()
coroutineScope = rememberCoroutineScope() coroutineScope = rememberCoroutineScope()
var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) }
var isBridgeConnected by remember { mutableStateOf(false) } var isBridgeConnected by remember { mutableStateOf(false) }
var hasBridgeError 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .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) { if (hasBridgeError) {
Text("Failed to connect to Snapchat through bridge service") Text("Failed to connect to Snapchat through bridge service")
} }
@ -515,7 +529,7 @@ class MessagingPreview(
} }
if (isBridgeConnected && !hasBridgeError) { if (isBridgeConnected && !hasBridgeError) {
ConversationPreview() ConversationPreview(messages, messageSize, ::fetchNewMessages)
} }
} }
} }

View File

@ -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.ExperimentalFoundationApi
import androidx.compose.foundation.clickable 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.Icons
import androidx.compose.material.icons.filled.RemoveRedEye import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.manager.Section 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.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
class SocialSection : Section() { class SocialRoot : Routes.Route() {
private lateinit var friendList: List<MessagingFriendInfo> private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private lateinit var groupList: List<MessagingGroupInfo> private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
companion object { fun updateScopeLists() {
const val MAIN_ROUTE = "social_route" context.coroutineScope.launch(Dispatchers.IO) {
const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}" friendList = context.modDatabase.getFriends(descOrder = true)
const val LOGGED_STORIES_ROUTE = "logged_stories/?userId={userId}" groupList = context.modDatabase.getGroups()
}
} }
private var currentScopeContent: ScopeContent? = null
private var currentMessagingPreview by mutableStateOf(null as MessagingPreview?)
private val addFriendDialog by lazy { private val addFriendDialog by lazy {
AddFriendDialog(context, this) 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 @Composable
private fun ScopeList(scope: SocialScope) { private fun ScopeList(scope: SocialScope) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() } val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
@ -186,9 +88,10 @@ class SocialSection : Section() {
.fillMaxWidth() .fillMaxWidth()
.height(80.dp) .height(80.dp)
.clickable { .clickable {
navController.navigate( routes.manageScope.navigate {
scope.tabRoute.replace("{id}", id) put("id", id)
) put("scope", scope.key)
}
}, },
) { ) {
Row( Row(
@ -275,9 +178,10 @@ class SocialSection : Section() {
} }
FilledIconButton(onClick = { FilledIconButton(onClick = {
navController.navigate( routes.messagingPreview.navigate {
MESSAGING_PREVIEW_ROUTE.replace("{id}", id).replace("{scope}", scope.key) put("id", id)
) put("scope", scope.key)
}
}) { }) {
Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null) Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null)
} }
@ -287,10 +191,8 @@ class SocialSection : Section() {
} }
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable override val content: @Composable (NavBackStackEntry) -> Unit = {
override fun Content() {
val titles = listOf("Friends", "Groups") val titles = listOf("Friends", "Groups")
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size } val pagerState = rememberPagerState { titles.size }
@ -302,6 +204,10 @@ class SocialSection : Section() {
} }
} }
LaunchedEffect(Unit) {
updateScopeLists()
}
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(

View File

@ -1,4 +0,0 @@
package me.rhunk.snapenhance.ui.manager.sections
class DebugSection {
}

View File

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

View File

@ -1,4 +0,0 @@
package me.rhunk.snapenhance.ui.manager.sections.features
class PickLocation {
}

View File

@ -1,276 +0,0 @@
package me.rhunk.snapenhance.ui.manager.sections.social
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import coil.annotation.ExperimentalCoilApi
import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.core.util.media.PreviewUtils
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.ui.util.Dialog
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.text.DateFormat
import java.util.Date
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.math.absoluteValue
@OptIn(ExperimentalCoilApi::class)
@Composable
fun LoggedStories(
context: RemoteSideContext,
userId: String
) {
val stories = remember {
mutableStateListOf<StoryData>()
}
val friendInfo = remember {
context.modDatabase.getFriendInfo(userId)
}
val httpClient = remember { OkHttpClient() }
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
var coilCacheFile by remember { mutableStateOf<File?>(null) }
selectedStory?.let { story ->
Dialog(onDismissRequest = {
selectedStory = null
}) {
Card(
modifier = Modifier
.padding(4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Posted on ${story.postedAt.let {
DateFormat.getDateTimeInstance().format(Date(it))
}}")
Text(text = "Created at ${story.createdAt.let {
DateFormat.getDateTimeInstance().format(Date(it))
}}")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = {
context.androidContext.externalCacheDir?.let { cacheDir ->
val cacheFile = coilCacheFile ?: run {
context.shortToast("Failed to get file")
return@Button
}
val targetFile = File(cacheDir, cacheFile.name)
cacheFile.copyTo(targetFile, overwrite = true)
context.androidContext.startActivity(Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(
FileProvider.getUriForFile(
context.androidContext,
"me.rhunk.snapenhance.fileprovider",
targetFile
),
FileType.fromFile(targetFile).mimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}) {
Text(text = "Open")
}
Button(onClick = {
val mediaAuthor = friendInfo?.mutableUsername ?: userId
val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16)
DownloadProcessor(
remoteSideContext = context,
callback = object: DownloadCallback.Default() {
override fun onSuccess(outputPath: String?) {
context.shortToast("Downloaded to $outputPath")
}
override fun onFailure(message: String?, throwable: String?) {
context.shortToast("Failed to download $message")
}
}
).enqueue(DownloadRequest(
inputMedias = arrayOf(
InputMedia(
content = story.url,
type = DownloadMediaType.REMOTE_MEDIA,
encryption = story.key?.let { it to story.iv!! }?.toKeyPair()
)
)
), DownloadMetadata(
mediaIdentifier = uniqueHash,
outputPath = createNewFilePath(
context.config.root,
uniqueHash,
MediaDownloadSource.STORY_LOGGER,
mediaAuthor,
story.createdAt
),
iconUrl = null,
mediaAuthor = friendInfo?.mutableUsername ?: userId,
downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation),
))
}) {
Text(text = "Download")
}
}
}
}
}
}
if (stories.isEmpty()) {
Text(text = "No stories found", Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
contentPadding = PaddingValues(8.dp),
) {
items(stories) { story ->
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
runCatching {
val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
snapshot.data.toFile().inputStream().use { inputStream ->
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
mediaList[type] = splitInputStream.readBytes()
}
}
val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false
val overlay = mediaList[SplitMediaAssetType.OVERLAY]
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
overlay?.also {
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
}
imageBitmap = bitmap?.asImageBitmap()
return true
}
return false
}
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
withTimeout(10000L) {
context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let {
openDiskCacheSnapshot(it)
it.close()
return@withTimeout
}
runCatching {
val response = httpClient.newCall(Request(
url = story.url.toHttpUrl()
)).execute()
response.body.byteStream().use {
val decrypted = story.key?.let { _ ->
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv))
CipherInputStream(it, cipher)
} ?: it
context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply {
data.toFile().outputStream().use { fos ->
decrypted.copyTo(fos)
}
commitAndOpenSnapshot()?.use { snapshot ->
openDiskCacheSnapshot(snapshot)
snapshot.close()
}
}
}
}.onFailure {
context.log.error("Failed to load story", it)
}
}
}
}
Column(
modifier = Modifier
.padding(8.dp)
.clickable {
selectedStory = story
coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use {
it?.data?.toFile()
}
}
.heightIn(min = 128.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
imageBitmap?.let {
Card {
Image(
bitmap = it,
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
} ?: run {
CircularProgressIndicator()
}
}
}
item {
LaunchedEffect(Unit) {
context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result ->
stories.addAll(result.values)
result.keys.minOrNull()?.let {
lastStoryTimestamp = it
}
}
}
}
}
}

View File

@ -25,9 +25,7 @@ import com.arthenica.ffmpegkit.Packages.getPackageName
import me.rhunk.snapenhance.R import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.ui.createComposeView 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.Navigation
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
class SettingsOverlay( class SettingsOverlay(
@ -55,26 +53,15 @@ class SettingsOverlay(
dismissCallback = { navHostController.popBackStack() } dismissCallback = { navHostController.popBackStack() }
} }
val navigation = remember { val navigation = remember { Navigation(context, navHostController) }
Navigation(
context,
mapOf(
EnumSection.FEATURES to FeaturesSection().apply {
enumSection = EnumSection.FEATURES
context = this@SettingsOverlay.context
}
),
navHostController
)
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { navigation.TopBar() } topBar = { navigation.TopBar() }
) { innerPadding -> ) { innerPadding ->
navigation.NavigationHost( navigation.Content(
startDestination = EnumSection.FEATURES, innerPadding,
innerPadding = innerPadding startDestination = navigation.routes.features.routeInfo.id
) )
} }
} }

View File

@ -29,6 +29,8 @@
"home_settings": "Settings", "home_settings": "Settings",
"home_logs": "Logs", "home_logs": "Logs",
"social": "Social", "social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",
"scripts": "Scripts" "scripts": "Scripts"
}, },
"sections": { "sections": {

View File

@ -110,7 +110,6 @@ class BridgeClient(
return runCatching { return runCatching {
block() block()
}.getOrElse { }.getOrElse {
context.log.error("failed to call service", it)
if (it is DeadObjectException) { if (it is DeadObjectException) {
context.softRestartApp() context.softRestartApp()
} }