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

View File

@ -10,64 +10,40 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navigation
import me.rhunk.snapenhance.RemoteSideContext
@OptIn(ExperimentalMaterial3Api::class)
class Navigation(
private val context: RemoteSideContext,
private val sections: Map<EnumSection, Section>,
private val navHostController: NavHostController
private val navController: NavHostController,
val routes: Routes = Routes(context).also {
it.navController = navController
}
){
@Composable
fun NavigationHost(
startDestination: EnumSection,
innerPadding: PaddingValues
) {
NavHost(
navHostController,
startDestination = startDestination.route,
Modifier.padding(innerPadding),
enterTransition = { fadeIn(tween(100)) },
exitTransition = { fadeOut(tween(100)) }
) {
sections.forEach { (_, instance) ->
instance.navController = navHostController
instance.build(this)
}
}
}
private fun getCurrentSection(navDestination: NavDestination) = sections.firstNotNullOf { (section, instance) ->
if (navDestination.hierarchy.any { it.route == section.route }) {
instance
} else {
null
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar() {
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination ?: return
val currentSection = getCurrentSection(currentDestination)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val canGoBack = remember (navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry)?.let {
!it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination)
} == true }
TopAppBar(title = {
currentSection.Title()
routes.getCurrentRoute(navBackStackEntry)?.title?.invoke() ?: Text(text = routes.getCurrentRoute(navBackStackEntry)?.routeInfo?.translatedKey ?: "Unknown Page")
}, navigationIcon = {
val backButtonAnimation by animateFloatAsState(if (currentSection.canGoBack()) 1f else 0f,
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f,
label = "backButtonAnimation"
)
@ -78,65 +54,88 @@ class Navigation(
.height(48.dp)
) {
IconButton(
onClick = { navHostController.popBackStack() }
onClick = {
if (canGoBack) {
navController.popBackStack()
}
}
) {
Icon(Icons.Filled.ArrowBack, contentDescription = null)
}
}
}, actions = {
currentSection.TopBarActions(this)
routes.getCurrentRoute(navBackStackEntry)?.topBarActions?.invoke(this)
})
}
@Composable
fun Fab() {
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination ?: return
val currentSection = getCurrentSection(currentDestination)
fun BottomBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val primaryRoutes = remember { routes.getRoutes().filter { it.routeInfo.primary } }
currentSection.FloatingActionButton()
}
@Composable
fun NavBar() {
NavigationBar {
val navBackStackEntry by navHostController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
sections.keys.forEach { section ->
fun selected() = currentDestination?.hierarchy?.any { it.route == section.route } == true
val currentRoute = routes.getCurrentRoute(navBackStackEntry)
primaryRoutes.forEach { route ->
NavigationBarItem(
alwaysShowLabel = false,
modifier = Modifier
.fillMaxHeight(),
modifier = Modifier.fillMaxHeight(),
icon = {
Icon(
imageVector = section.icon,
contentDescription = null
)
Icon(imageVector = route.routeInfo.icon, contentDescription = null)
},
label = {
Text(
textAlign = TextAlign.Center,
softWrap = false,
fontSize = 12.sp,
modifier = Modifier.wrapContentWidth(unbounded = true),
text = if (selected()) context.translation["manager.routes.${section.route}"] else "",
text = if (currentRoute == route) context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] else "",
)
},
selected = selected(),
selected = currentRoute == route,
onClick = {
navHostController.navigate(section.route) {
popUpTo(navHostController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
route.navigateReset()
}
)
}
}
}
@Composable
fun FloatingActionButton() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
routes.getCurrentRoute(navBackStackEntry)?.floatingActionButton?.invoke()
}
@Composable
fun Content(paddingValues: PaddingValues, startDestination: String) {
NavHost(
navController = navController,
startDestination = startDestination,
Modifier.padding(paddingValues),
enterTransition = { fadeIn(tween(100)) },
exitTransition = { fadeOut(tween(100)) }
) {
routes.getRoutes().filter { it.parentRoute == null }.forEach { route ->
val children = routes.getRoutes().filter { it.parentRoute == route }
if (children.isEmpty()) {
composable(route.routeInfo.id) {
route.content.invoke(it)
}
route.customComposables.invoke(this)
} else {
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
composable("main_" + route.routeInfo.id) {
route.content.invoke(it)
}
children.forEach { child ->
composable(child.routeInfo.id) {
child.content.invoke(it)
}
}
route.customComposables.invoke(this)
}
}
}
}
}
}

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

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

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

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

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

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

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

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

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 androidx.compose.foundation.background
@ -23,8 +23,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.*
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener
import me.rhunk.snapenhance.bridge.snapclient.types.Message
@ -38,23 +38,21 @@ import me.rhunk.snapenhance.messaging.MessagingConstraints
import me.rhunk.snapenhance.messaging.MessagingTask
import me.rhunk.snapenhance.messaging.MessagingTaskConstraint
import me.rhunk.snapenhance.messaging.MessagingTaskType
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.Dialog
import java.util.SortedMap
class MessagingPreview(
private val context: RemoteSideContext,
private val scope: SocialScope,
private val scopeId: String
) {
class MessagingPreview: Routes.Route() {
private lateinit var coroutineScope: CoroutineScope
private lateinit var messagingBridge: MessagingBridge
private lateinit var previewScrollState: LazyListState
private val myUserId by lazy { messagingBridge.myUserId }
private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") }
private var conversationId: String? = null
private val messages = sortedMapOf<Long, Message>() // server message id => message
private var messages = sortedMapOf<Long, Message>()
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 fun toggleSelectedMessage(messageId: Long) {
@ -172,8 +170,7 @@ class MessagingPreview(
}
}
@Composable
fun TopBarAction() {
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var taskSelectionDropdown by remember { mutableStateOf(false) }
var selectConstraintsDialog by remember { mutableStateOf(false) }
var activeTask by remember { mutableStateOf(null as MessagingTask?) }
@ -325,7 +322,11 @@ class MessagingPreview(
}
@Composable
private fun ConversationPreview() {
private fun ConversationPreview(
messages: SortedMap<Long, Message>,
messageSize: Int,
fetchNewMessages: () -> Unit
) {
DisposableEffect(Unit) {
onDispose {
selectedMessages.clear()
@ -393,65 +394,6 @@ class MessagingPreview(
}
}
private fun fetchNewMessages() {
coroutineScope.launch(Dispatchers.IO) cs@{
runCatching {
val queriedMessages = messagingBridge.fetchConversationWithMessagesPaginated(
conversationId!!,
100,
lastMessageId
)
if (queriedMessages == null) {
context.shortToast("Failed to fetch messages")
return@cs
}
coroutineScope.launch {
messages.putAll(queriedMessages.map { it.serverMessageId to it })
messageSize = messages.size
if (queriedMessages.isNotEmpty()) {
lastMessageId = queriedMessages.first().clientMessageId
previewScrollState.scrollToItem(queriedMessages.size - 1)
}
}
}.onFailure {
context.shortToast("Failed to fetch messages: ${it.message}")
}
context.log.verbose("fetched ${messages.size} messages")
}
}
private fun onMessagingBridgeReady() {
runCatching {
messagingBridge = context.bridgeService!!.messagingBridge!!
conversationId = if (scope == SocialScope.FRIEND) messagingBridge.getOneToOneConversationId(scopeId) else scopeId
if (conversationId == null) {
context.longToast("Failed to fetch conversation id")
return
}
if (!messagingBridge.isSessionStarted) {
context.androidContext.packageManager.getLaunchIntentForPackage(
Constants.SNAPCHAT_PACKAGE_NAME
)?.let {
val mainIntent = Intent.makeRestartActivityTask(it.component).apply {
putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true)
}
context.androidContext.startActivity(mainIntent)
}
messagingBridge.registerSessionStartListener(object: SessionStartListener.Stub() {
override fun onConnected() {
fetchNewMessages()
}
})
return
}
fetchNewMessages()
}.onFailure {
context.longToast("Failed to initialize messaging bridge")
context.log.error("Failed to initialize messaging bridge", it)
}
}
@Composable
private fun LoadingRow() {
@ -471,41 +413,113 @@ class MessagingPreview(
}
}
@Composable
fun Content() {
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
val scope = remember { SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) }
val id = remember { navBackStackEntry.arguments?.getString("id")!! }
previewScrollState = rememberLazyListState()
coroutineScope = rememberCoroutineScope()
var lastMessageId by remember { mutableLongStateOf(Long.MAX_VALUE) }
var isBridgeConnected by remember { mutableStateOf(false) }
var hasBridgeError by remember { mutableStateOf(false) }
fun fetchNewMessages() {
coroutineScope.launch(Dispatchers.IO) cs@{
runCatching {
val queriedMessages = messagingBridge.fetchConversationWithMessagesPaginated(
conversationId!!,
20,
lastMessageId
)
if (queriedMessages == null) {
context.shortToast("Failed to fetch messages")
return@cs
}
withContext(Dispatchers.Main) {
messages.putAll(queriedMessages.map { it.serverMessageId to it })
messageSize = messages.size
if (queriedMessages.isNotEmpty()) {
lastMessageId = queriedMessages.first().clientMessageId
delay(20)
previewScrollState.scrollToItem(queriedMessages.size - 1)
}
}
}.onFailure {
context.log.error("Failed to fetch messages", it)
context.shortToast("Failed to fetch messages: ${it.message}")
}
context.log.verbose("fetched ${messages.size} messages")
}
}
fun onMessagingBridgeReady(scope: SocialScope, scopeId: String) {
context.log.verbose("onMessagingBridgeReady: $scope $scopeId")
runCatching {
messagingBridge = context.bridgeService!!.messagingBridge!!
conversationId = if (scope == SocialScope.FRIEND) messagingBridge.getOneToOneConversationId(scopeId) else scopeId
if (conversationId == null) {
context.longToast("Failed to fetch conversation id")
return
}
if (runCatching { !messagingBridge.isSessionStarted }.getOrDefault(true)) {
context.androidContext.packageManager.getLaunchIntentForPackage(
Constants.SNAPCHAT_PACKAGE_NAME
)?.let {
val mainIntent = Intent.makeRestartActivityTask(it.component).apply {
putExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, true)
}
context.androidContext.startActivity(mainIntent)
}
messagingBridge.registerSessionStartListener(object: SessionStartListener.Stub() {
override fun onConnected() {
fetchNewMessages()
}
})
return
}
fetchNewMessages()
}.onFailure {
context.longToast("Failed to initialize messaging bridge")
context.log.error("Failed to initialize messaging bridge", it)
}
}
LaunchedEffect(Unit) {
messages.clear()
messageSize = 0
conversationId = null
isBridgeConnected = context.hasMessagingBridge()
if (isBridgeConnected) {
onMessagingBridgeReady(scope, id)
} else {
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
context.androidContext.sendBroadcast(it)
}
coroutineScope.launch(Dispatchers.IO) {
withTimeout(10000) {
while (!context.hasMessagingBridge()) {
delay(100)
}
isBridgeConnected = true
onMessagingBridgeReady(scope, id)
}
}.invokeOnCompletion {
if (it != null) {
hasBridgeError = true
}
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
) {
LaunchedEffect(Unit) {
isBridgeConnected = context.hasMessagingBridge()
if (isBridgeConnected) {
onMessagingBridgeReady()
} else {
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
context.androidContext.sendBroadcast(it)
}
coroutineScope.launch(Dispatchers.IO) {
withTimeout(10000) {
while (!context.hasMessagingBridge()) {
delay(100)
}
isBridgeConnected = true
onMessagingBridgeReady()
}
}.invokeOnCompletion {
if (it != null) {
hasBridgeError = true
}
}
}
}
if (hasBridgeError) {
Text("Failed to connect to Snapchat through bridge service")
}
@ -515,7 +529,7 @@ class MessagingPreview(
}
if (isBridgeConnected && !hasBridgeError) {
ConversationPreview()
ConversationPreview(messages, messageSize, ::fetchNewMessages)
}
}
}

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.clickable
@ -10,7 +10,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -22,10 +21,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -34,119 +30,25 @@ import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
class SocialSection : Section() {
private lateinit var friendList: List<MessagingFriendInfo>
private lateinit var groupList: List<MessagingGroupInfo>
class SocialRoot : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
companion object {
const val MAIN_ROUTE = "social_route"
const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}"
const val LOGGED_STORIES_ROUTE = "logged_stories/?userId={userId}"
fun updateScopeLists() {
context.coroutineScope.launch(Dispatchers.IO) {
friendList = context.modDatabase.getFriends(descOrder = true)
groupList = context.modDatabase.getGroups()
}
}
private var currentScopeContent: ScopeContent? = null
private var currentMessagingPreview by mutableStateOf(null as MessagingPreview?)
private val addFriendDialog by lazy {
AddFriendDialog(context, this)
}
//FIXME: don't reload the entire list when a friend is added/deleted
override fun onResumed() {
friendList = context.modDatabase.getFriends(descOrder = true)
groupList = context.modDatabase.getGroups()
}
override fun canGoBack() = currentRoute != MAIN_ROUTE
override fun build(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) {
composable(MAIN_ROUTE) {
Content()
}
SocialScope.entries.forEach { scope ->
composable(scope.tabRoute) {
val id = it.arguments?.getString("id") ?: return@composable
remember {
ScopeContent(
context,
this@SocialSection,
navController,
scope,
id
).also { tab ->
currentScopeContent = tab
}
}.Content()
}
}
composable(LOGGED_STORIES_ROUTE) {
val userId = it.arguments?.getString("userId") ?: return@composable
LoggedStories(context, userId)
}
composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getString("id") ?: return@composable
val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable
val messagePreview = remember {
MessagingPreview(context, SocialScope.getByName(scope), id)
}
LaunchedEffect(key1 = id) {
currentMessagingPreview = messagePreview
}
messagePreview.Content()
DisposableEffect(Unit) {
onDispose {
currentMessagingPreview = null
}
}
}
}
}
@Composable
override fun TopBarActions(rowScope: RowScope) {
var deleteConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (deleteConfirmDialog) {
currentScopeContent?.let { scopeContent ->
Dialog(onDismissRequest = { deleteConfirmDialog = false }) {
remember { AlertDialogs(context.translation) }.ConfirmDialog(
title = "Are you sure you want to delete this ${scopeContent.scope.key.lowercase()}?",
onDismiss = { deleteConfirmDialog = false },
onConfirm = {
scopeContent.deleteScope(coroutineScope); deleteConfirmDialog = false
}
)
}
}
}
if (currentRoute == MESSAGING_PREVIEW_ROUTE) {
currentMessagingPreview?.TopBarAction()
}
if (currentRoute == SocialScope.FRIEND.tabRoute || currentRoute == SocialScope.GROUP.tabRoute) {
IconButton(
onClick = { deleteConfirmDialog = true },
) {
Icon(
imageVector = Icons.Rounded.DeleteForever,
contentDescription = null
)
}
}
}
@Composable
private fun ScopeList(scope: SocialScope) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
@ -186,9 +88,10 @@ class SocialSection : Section() {
.fillMaxWidth()
.height(80.dp)
.clickable {
navController.navigate(
scope.tabRoute.replace("{id}", id)
)
routes.manageScope.navigate {
put("id", id)
put("scope", scope.key)
}
},
) {
Row(
@ -275,9 +178,10 @@ class SocialSection : Section() {
}
FilledIconButton(onClick = {
navController.navigate(
MESSAGING_PREVIEW_ROUTE.replace("{id}", id).replace("{scope}", scope.key)
)
routes.messagingPreview.navigate {
put("id", id)
put("scope", scope.key)
}
}) {
Icon(imageVector = Icons.Filled.RemoveRedEye, contentDescription = null)
}
@ -287,10 +191,8 @@ class SocialSection : Section() {
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
override fun Content() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val titles = listOf("Friends", "Groups")
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
@ -302,6 +204,10 @@ class SocialSection : Section() {
}
}
LaunchedEffect(Unit) {
updateScopeLists()
}
Scaffold(
floatingActionButton = {
FloatingActionButton(

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

View File

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

View File

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