feat(ui): setup activity

- remote side context
- fix float dialogs
- fix choose folder
This commit is contained in:
rhunk 2023-08-03 21:48:23 +02:00
parent 853ceec290
commit 641d66b208
39 changed files with 648 additions and 248 deletions

View File

@ -85,6 +85,8 @@ dependencies {
implementation(libs.androidx.material)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.documentfile)
implementation(libs.gson)
debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
debugImplementation("androidx.compose.ui:ui-tooling-preview:1.4.3")

View File

@ -50,8 +50,7 @@
android:name=".ui.setup.SetupActivity"
android:exported="true"
android:theme="@style/AppTheme"
android:excludeFromRecents="true">
</activity>
android:excludeFromRecents="true" />
<activity
android:name=".ui.map.MapActivity"
android:exported="true"

View File

@ -0,0 +1,94 @@
package me.rhunk.snapenhance
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
import me.rhunk.snapenhance.ui.setup.Requirements
import me.rhunk.snapenhance.ui.setup.SetupActivity
import java.lang.ref.WeakReference
import kotlin.system.exitProcess
class RemoteSideContext(
val androidContext: Context
) {
private var _activity: WeakReference<Activity>? = null
var activity: Activity?
get() = _activity?.get()
set(value) { _activity = WeakReference(value) }
val config = ModConfig()
val translation = LocaleWrapper()
val mappings = MappingsWrapper(androidContext)
init {
config.loadFromContext(androidContext)
translation.userLocale = config.locale
translation.loadFromContext(androidContext)
mappings.apply {
loadFromContext(androidContext)
init()
}
}
fun getInstallationSummary() = InstallationSummary(
snapchatInfo = mappings.getSnapchatPackageInfo()?.let {
SnapchatAppInfo(
version = it.versionName,
versionCode = it.longVersionCode
)
},
mappingsInfo = if (mappings.isMappingsLoaded()) {
ModMappingsInfo(
generatedSnapchatVersion = mappings.getGeneratedBuildNumber(),
isOutdated = mappings.isMappingsOutdated()
)
} else null
)
fun checkForRequirements(overrideRequirements: Int? = null) {
var requirements = overrideRequirements ?: 0
if (!config.wasPresent) {
requirements = requirements or Requirements.FIRST_RUN
}
config.root.downloader.saveFolder.get().let {
if (it.isEmpty() || run {
val documentFile = runCatching { DocumentFile.fromTreeUri(androidContext, Uri.parse(it)) }.getOrNull()
documentFile == null || !documentFile.exists() || !documentFile.canWrite()
}) {
requirements = requirements or Requirements.SAVE_FOLDER
}
}
if (mappings.isMappingsOutdated() || !mappings.isMappingsLoaded()) {
requirements = requirements or Requirements.MAPPINGS
}
if (requirements == 0) return
val currentContext = activity ?: androidContext
Intent(currentContext, SetupActivity::class.java).apply {
putExtra("requirements", requirements)
if (currentContext !is Activity) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
currentContext.startActivity(this)
return@apply
}
currentContext.startActivityForResult(this, 22)
}
if (currentContext !is Activity) {
exitProcess(0)
}
}
}

View File

@ -0,0 +1,15 @@
package me.rhunk.snapenhance
import android.content.Context
import java.lang.ref.WeakReference
object SharedContextHolder {
private lateinit var _remoteSideContext: WeakReference<RemoteSideContext>
fun remote(context: Context): RemoteSideContext {
if (!::_remoteSideContext.isInitialized || _remoteSideContext.get() == null) {
_remoteSideContext = WeakReference(RemoteSideContext(context.applicationContext))
}
return _remoteSideContext.get()!!
}
}

View File

@ -3,7 +3,9 @@ package me.rhunk.snapenhance.bridge
import android.app.Service
import android.content.Intent
import android.os.IBinder
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
@ -11,7 +13,12 @@ import me.rhunk.snapenhance.download.DownloadProcessor
class BridgeService : Service() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
private lateinit var remoteSideContext: RemoteSideContext
override fun onBind(intent: Intent): IBinder {
remoteSideContext = SharedContextHolder.remote(this).apply {
checkForRequirements()
}
messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
return BridgeBinder()
}
@ -85,7 +92,7 @@ class BridgeService : Service() {
override fun clearMessageLogger() = messageLoggerWrapper.clearMessages()
override fun fetchTranslations() = LocaleWrapper.fetchLocales(context = this@BridgeService).associate {
override fun fetchLocales(userLocale: String) = LocaleWrapper.fetchLocales(context = this@BridgeService, userLocale).associate {
it.locale to it.content
}
@ -98,6 +105,8 @@ class BridgeService : Service() {
}
override fun enqueueDownload(intent: Intent, callback: DownloadCallback) {
SharedContextHolder.remote(this@BridgeService)
//TODO: refactor shared context
SharedContext.ensureInitialized(this@BridgeService)
DownloadProcessor(this@BridgeService, callback).onReceive(intent)
}

View File

@ -59,10 +59,6 @@ class DownloadProcessor (
private val context: Context,
private val callback: DownloadCallback
) {
companion object {
const val DOWNLOAD_REQUEST_EXTRA = "request"
const val DOWNLOAD_METADATA_EXTRA = "metadata"
}
private val translation by lazy {
SharedContext.translation.getCategory("download_processor")
@ -184,7 +180,9 @@ class DownloadProcessor (
fun handleInputStream(inputStream: InputStream) {
createMediaTempFile().apply {
if (inputMedia.encryption != null) {
decryptInputStream(inputStream, inputMedia.encryption).use { decryptedInputStream ->
decryptInputStream(inputStream,
inputMedia.encryption!!
).use { decryptedInputStream ->
decryptedInputStream.copyTo(outputStream())
}
} else {
@ -286,8 +284,8 @@ class DownloadProcessor (
fun onReceive(intent: Intent) {
CoroutineScope(Dispatchers.IO).launch {
val downloadMetadata = gson.fromJson(intent.getStringExtra(DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
val downloadRequest = gson.fromJson(intent.getStringExtra(DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
val downloadMetadata = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
val downloadRequest = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
SharedContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage ->
translation[if (downloadStage.isFinalStage) {

View File

@ -1,43 +1,49 @@
package me.rhunk.snapenhance.ui.manager
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.remember
import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.ui.AppMaterialTheme
import me.rhunk.snapenhance.ui.manager.util.SaveFolderChecker
import me.rhunk.snapenhance.util.ActivityResultCallback
class MainActivity : ComponentActivity() {
private val activityResultCallbacks = mutableMapOf<Int, ActivityResultCallback>()
@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val startDestination = intent.getStringExtra("route")?.let { EnumSection.fromRoute(it) } ?: EnumSection.HOME
val managerContext = ManagerContext(this)
val managerContext = SharedContextHolder.remote(this).apply {
activity = this@MainActivity
checkForRequirements()
}
//FIXME: temporary save folder
SaveFolderChecker.askForFolder(
this,
managerContext.config.root.downloader.saveFolder)
{
managerContext.config.writeConfig()
val sections = EnumSection.values().toList().associateWith {
it.section.constructors.first().call()
}.onEach { (section, instance) ->
with(instance) {
enumSection = section
context = managerContext
init()
}
}
setContent {
val navController = rememberNavController()
val navigation = Navigation(managerContext)
val navigation = remember { Navigation() }
AppMaterialTheme {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = { navigation.NavBar(navController = navController) }
) { innerPadding ->
navigation.NavigationHost(navController = navController, innerPadding = innerPadding, startDestination = startDestination)
navigation.NavigationHost(
sections = sections,
navController = navController,
innerPadding = innerPadding,
startDestination = startDestination
)
}
}
}

View File

@ -1,38 +0,0 @@
package me.rhunk.snapenhance.ui.manager
import android.content.Context
import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
class ManagerContext(
private val context: Context
) {
val config = ModConfig()
val translation = LocaleWrapper()
val mappings = MappingsWrapper(context)
init {
config.loadFromContext(context)
translation.loadFromContext(context)
mappings.apply { loadFromContext(context) }.init()
}
fun getInstallationSummary() = InstallationSummary(
snapchatInfo = mappings.getSnapchatPackageInfo()?.let {
SnapchatAppInfo(
version = it.versionName,
versionCode = it.longVersionCode
)
},
mappingsInfo = if (mappings.isMappingsLoaded()) {
ModMappingsInfo(
generatedSnapchatVersion = mappings.getGeneratedBuildNumber(),
isOutdated = mappings.isMappingsOutdated()
)
} else null
)
}

View File

@ -12,7 +12,6 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@ -23,25 +22,17 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
class Navigation(
private val context: ManagerContext
) {
class Navigation{
@Composable
fun NavigationHost(
sections: Map<EnumSection, Section>,
startDestination: EnumSection,
navController: NavHostController,
innerPadding: PaddingValues
) {
val sections = remember { EnumSection.values().toList().map {
it to it.section.constructors.first().call()
}.onEach { (section, instance) ->
instance.enumSection = section
instance.manager = context
instance.navController = navController
} }
NavHost(navController, startDestination = startDestination.route, Modifier.padding(innerPadding)) {
sections.forEach { (_, instance) ->
instance.navController = navController
instance.build(this)
}
}

View File

@ -11,6 +11,7 @@ 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.HomeSection
import me.rhunk.snapenhance.ui.manager.sections.NotImplemented
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
@ -61,9 +62,11 @@ enum class EnumSection(
open class Section {
lateinit var enumSection: EnumSection
lateinit var manager: ManagerContext
lateinit var context: RemoteSideContext
lateinit var navController: NavController
open fun init() {}
@Composable
open fun Content() { NotImplemented() }

View File

@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.setup.Requirements
class HomeSection : Section() {
companion object {
@ -76,7 +77,9 @@ class HomeSection : Section() {
)
//inline button
Button(onClick = {}, modifier = Modifier.height(40.dp)) {
Button(onClick = {
context.checkForRequirements(Requirements.MAPPINGS)
}, modifier = Modifier.height(40.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
}
}
@ -102,7 +105,7 @@ class HomeSection : Section() {
modifier = Modifier.padding(16.dp)
)
SummaryCards(manager.getInstallationSummary())
SummaryCards(context.getInstallationSummary())
}
}
}

View File

@ -140,14 +140,22 @@ class Dialogs {
Text(text = "Cancel")
}
Button(onClick = {
if (property.key.dataType.type == DataProcessors.Type.INTEGER) {
runCatching {
property.value.setAny(fieldValue.value.text.toInt())
}.onFailure {
property.value.setAny(0)
when (property.key.dataType.type) {
DataProcessors.Type.INTEGER -> {
runCatching {
property.value.setAny(fieldValue.value.text.toInt())
}.onFailure {
property.value.setAny(0)
}
}
} else {
property.value.setAny(fieldValue.value.text)
DataProcessors.Type.FLOAT -> {
runCatching {
property.value.setAny(fieldValue.value.text.toFloat())
}.onFailure {
property.value.setAny(0f)
}
}
else -> property.value.setAny(fieldValue.value.text)
}
dismiss()
}) {

View File

@ -1,5 +1,6 @@
package me.rhunk.snapenhance.ui.manager.sections.features
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -20,6 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.Card
@ -55,6 +57,7 @@ import me.rhunk.snapenhance.core.config.ConfigContainer
import me.rhunk.snapenhance.core.config.DataProcessors
import me.rhunk.snapenhance.core.config.PropertyPair
import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.ChooseFolderHelper
class FeaturesSection : Section() {
private val dialogs by lazy { Dialogs() }
@ -63,6 +66,15 @@ class FeaturesSection : Section() {
private const val MAIN_ROUTE = "root"
}
private lateinit var openFolderCallback: (uri: String) -> Unit
private lateinit var openFolderLauncher: () -> Unit
override fun init() {
openFolderLauncher = ChooseFolderHelper.createChooseFolder(context.activity!! as ComponentActivity) {
openFolderCallback(it)
}
}
@Composable
private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
val showDialog = remember { mutableStateOf(false) }
@ -83,6 +95,18 @@ class FeaturesSection : Section() {
val propertyValue = property.value
if (property.key.params.isFolder) {
IconButton(onClick = registerClickCallback {
openFolderCallback = { uri ->
propertyValue.setAny(uri)
}
openFolderLauncher()
}.let { { it.invoke(true) } }) {
Icon(Icons.Filled.FolderOpen, contentDescription = null)
}
return
}
when (val dataType = remember { property.key.dataType.type }) {
DataProcessors.Type.BOOLEAN -> {
val state = remember { mutableStateOf(propertyValue.get() as Boolean) }
@ -116,7 +140,7 @@ class FeaturesSection : Section() {
DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
dialogs.MultipleSelectionDialog(property)
}
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER -> {
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
dialogs.KeyboardInputDialog(property) { showDialog.value = false }
}
else -> {}
@ -124,7 +148,8 @@ class FeaturesSection : Section() {
}
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
if (dataType == DataProcessors.Type.INTEGER || dataType == DataProcessors.Type.FLOAT) {
if (dataType == DataProcessors.Type.INTEGER ||
dataType == DataProcessors.Type.FLOAT) {
FilledIconButton(onClick = it) {
Text(
text = propertyValue.get().toString(),
@ -256,7 +281,7 @@ class FeaturesSection : Section() {
floatingActionButton = {
FloatingActionButton(
onClick = {
manager.config.writeConfig()
context.config.writeConfig()
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Saved")
}
@ -299,13 +324,13 @@ class FeaturesSection : Section() {
}
}
}
queryContainerRecursive(manager.config.root)
queryContainerRecursive(context.config.root)
containers
}
navGraphBuilder.navigation(route = "features", startDestination = MAIN_ROUTE) {
composable(MAIN_ROUTE) {
Container(MAIN_ROUTE, manager.config.root)
Container(MAIN_ROUTE, context.config.root)
}
composable("container/{name}") { backStackEntry ->

View File

@ -1,42 +0,0 @@
package me.rhunk.snapenhance.ui.manager.util
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import me.rhunk.snapenhance.core.config.PropertyValue
import kotlin.system.exitProcess
object SaveFolderChecker {
fun askForFolder(activity: ComponentActivity, property: PropertyValue<String>, saveConfig: () -> Unit) {
if (property.get().isEmpty() || !property.get().startsWith("content://")) {
val startActivity = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) result@{
if (it.resultCode != Activity.RESULT_OK) return@result
val uri = it.data?.data ?: return@result
val value = uri.toString()
activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
property.set(value)
saveConfig()
Toast.makeText(activity, "save folder set!", Toast.LENGTH_SHORT).show()
activity.finish()
}
AlertDialog.Builder(activity)
.setTitle("Save folder")
.setMessage("Please select a folder where you want to save downloaded files.")
.setPositiveButton("Select") { _, _ ->
startActivity.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
)
}
.setNegativeButton("Cancel") { _, _ ->
exitProcess(0)
}
.show()
}
}
}

View File

@ -1,33 +1,21 @@
package me.rhunk.snapenhance.ui.setup
import android.os.Bundle
object Requirements {
const val FIRST_RUN = 0b00001
const val LANGUAGE = 0b00010
const val MAPPINGS = 0b00100
const val SAVE_FOLDER = 0b01000
const val FFMPEG = 0b10000
data class Requirements(
val firstRun: Boolean = false,
val language: Boolean = false,
val mappings: Boolean = false,
val saveFolder: Boolean = false,
val ffmpeg: Boolean = false
) {
companion object {
fun fromBundle(bundle: Bundle): Requirements {
return Requirements(
firstRun = bundle.getBoolean("firstRun"),
language = bundle.getBoolean("language"),
mappings = bundle.getBoolean("mappings"),
saveFolder = bundle.getBoolean("saveFolder"),
ffmpeg = bundle.getBoolean("ffmpeg")
)
}
fun toBundle(requirements: Requirements): Bundle {
return Bundle().apply {
putBoolean("firstRun", requirements.firstRun)
putBoolean("language", requirements.language)
putBoolean("mappings", requirements.mappings)
putBoolean("saveFolder", requirements.saveFolder)
putBoolean("ffmpeg", requirements.ffmpeg)
}
fun getName(requirement: Int): String {
return when (requirement) {
FIRST_RUN -> "FIRST_RUN"
LANGUAGE -> "LANGUAGE"
MAPPINGS -> "MAPPINGS"
SAVE_FOLDER -> "SAVE_FOLDER"
FFMPEG -> "FFMPEG"
else -> "UNKNOWN"
}
}
}

View File

@ -1,7 +1,9 @@
package me.rhunk.snapenhance.ui.setup
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -28,48 +31,64 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.ui.AppMaterialTheme
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.FfmpegScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.LanguageScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.WelcomeScreen
class SetupActivity : ComponentActivity() {
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val requirements = intent.getBundleExtra("requirements")?.let {
Requirements.fromBundle(it)
} ?: Requirements(firstRun = true)
val setupContext = SharedContextHolder.remote(this).apply {
activity = this@SetupActivity
}
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val requiredScreens = mutableListOf<SetupScreen>()
with(requiredScreens) {
with(requirements) {
if (firstRun || language) add(LanguageScreen().apply { route = "language" })
if (firstRun) add(WelcomeScreen().apply { route = "welcome" })
if (firstRun || saveFolder) add(SaveFolderScreen().apply { route = "saveFolder" })
if (firstRun || mappings) add(MappingsScreen().apply { route = "mappings" })
if (firstRun || ffmpeg) add(FfmpegScreen().apply { route = "ffmpeg" })
val isFirstRun = hasRequirement(Requirements.FIRST_RUN)
if (isFirstRun || hasRequirement(Requirements.LANGUAGE)) {
add(PickLanguageScreen().apply { route = "language" })
}
if (isFirstRun || hasRequirement(Requirements.SAVE_FOLDER)) {
add(SaveFolderScreen().apply { route = "saveFolder" })
}
if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) {
add(MappingsScreen().apply { route = "mappings" })
}
if (isFirstRun || hasRequirement(Requirements.FFMPEG)) {
add(FfmpegScreen().apply { route = "ffmpeg" })
}
}
// If there are no required screens, we can just finish the activity
if (requiredScreens.isEmpty()) {
finish()
return
}
requiredScreens.forEach { screen ->
screen.context = setupContext
screen.init()
}
setContent {
val navController = rememberNavController()
val canGoNext = remember { mutableStateOf(false) }
fun nextScreen() {
if (!canGoNext.value) return
canGoNext.value = false
if (requiredScreens.size > 1) {
canGoNext.value = false
requiredScreens.removeFirst()
navController.navigate(requiredScreens.first().route)
} else {
@ -98,18 +117,21 @@ class SetupActivity : ComponentActivity() {
.alpha(alpha)
) {
Icon(
imageVector = Icons.Default.ArrowForwardIos,
imageVector = if (requiredScreens.size <= 1 && canGoNext.value) {
Icons.Default.Check
} else {
Icons.Default.ArrowForwardIos
},
contentDescription = null
)
}
}
},
) { paddingValues ->
) {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.padding(paddingValues)
) {
NavHost(
navController = navController,
@ -118,6 +140,7 @@ class SetupActivity : ComponentActivity() {
requiredScreens.forEach { screen ->
screen.allowNext = { canGoNext.value = it }
composable(screen.route) {
BackHandler(true) {}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,

View File

@ -1,14 +0,0 @@
package me.rhunk.snapenhance.ui.setup
import android.content.Context
import me.rhunk.snapenhance.core.config.ModConfig
class SetupContext(
private val context: Context
) {
val config = ModConfig()
init {
config.loadFromContext(context)
}
}

View File

@ -1,11 +1,31 @@
package me.rhunk.snapenhance.ui.setup.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 me.rhunk.snapenhance.RemoteSideContext
abstract class SetupScreen {
lateinit var context: RemoteSideContext
lateinit var allowNext: (Boolean) -> Unit
lateinit var route: String
@Composable
fun DialogText(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier.padding(16.dp).then(modifier)
)
}
open fun init() {}
@Composable
abstract fun Content()
}

View File

@ -1,11 +0,0 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
import androidx.compose.runtime.Composable
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
class LanguageScreen : SetupScreen(){
@Composable
override fun Content() {
}
}

View File

@ -1,16 +1,98 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
class MappingsScreen : SetupScreen() {
@Composable
override fun Content() {
Text(text = "Mappings")
Button(onClick = { allowNext(true) }) {
Text(text = "Next")
val coroutineScope = rememberCoroutineScope()
val infoText = remember { mutableStateOf(null as String?) }
val isGenerating = remember { mutableStateOf(false) }
if (infoText.value != null) {
Dialog(onDismissRequest = {
infoText.value = null
}) {
Surface(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(text = infoText.value!!)
Button(onClick = {
infoText.value = null
},
modifier = Modifier.padding(top = 5.dp).align(alignment = androidx.compose.ui.Alignment.End)) {
Text(text = "OK")
}
}
}
}
}
fun tryToGenerateMappings() {
//check for snapchat installation
val installationSummary = context.getInstallationSummary()
if (installationSummary.snapchatInfo == null) {
throw Exception(context.translation["setup.mappings.generate_failure_no_snapchat"])
}
with(context.mappings) {
refresh()
}
}
val hasMappings = remember { mutableStateOf(false) }
DialogText(text = context.translation["setup.mappings.dialog"])
if (hasMappings.value) return
Button(onClick = {
if (isGenerating.value) return@Button
isGenerating.value = true
coroutineScope.launch(Dispatchers.IO) {
runCatching {
tryToGenerateMappings()
allowNext(true)
infoText.value = context.translation["setup.mappings.generate_success"]
hasMappings.value = true
}.onFailure {
isGenerating.value = false
infoText.value = context.translation["setup.mappings.generate_failure"] + "\n\n" + it.message
Logger.error("Failed to generate mappings", it)
}
}
}) {
if (isGenerating.value) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 5.dp).size(25.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colors.onPrimary
)
} else {
Text(text = context.translation["setup.mappings.generate_button"])
}
}
}
}

View File

@ -0,0 +1,115 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.ui.util.ObservableMutableState
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import java.util.Locale
class PickLanguageScreen : SetupScreen(){
@Composable
override fun Content() {
val androidContext = LocalContext.current
val availableLocales = remember { LocaleWrapper.fetchAvailableLocales(androidContext) }
allowNext(true)
fun getLocaleDisplayName(locale: String): String {
locale.split("_").let {
return java.util.Locale(it[0], it[1]).getDisplayName(java.util.Locale.getDefault())
}
}
val selectedLocale = remember {
val deviceLocale = Locale.getDefault().toString()
fun reloadTranslation(selectedLocale: String) {
context.translation.reloadFromContext(androidContext, selectedLocale)
}
ObservableMutableState(
defaultValue = availableLocales.firstOrNull {
locale -> locale == deviceLocale
} ?: LocaleWrapper.DEFAULT_LOCALE
) { _, newValue ->
context.config.locale = newValue
context.config.writeConfig()
reloadTranslation(newValue)
}.also { reloadTranslation(it.value) }
}
DialogText(text = context.translation["setup.dialogs.select_language"])
val isDialog = remember { mutableStateOf(false) }
if (isDialog.value) {
Dialog(onDismissRequest = { isDialog.value = false }) {
Surface(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
elevation = 8.dp,
shape = MaterialTheme.shapes.medium
) {
LazyColumn(
modifier = Modifier.scrollable(rememberScrollState(), orientation = Orientation.Vertical)
) {
items(availableLocales) { locale ->
Box(
modifier = Modifier
.height(70.dp)
.fillMaxWidth()
.clickable {
selectedLocale.value = locale
isDialog.value = false
},
contentAlignment = Alignment.Center
) {
Text(
text = getLocaleDisplayName(locale),
fontSize = 16.sp,
fontWeight = FontWeight.Light,
)
}
}
}
}
}
}
Box(
modifier = Modifier
.padding(top = 40.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
OutlinedButton(onClick = {
isDialog.value = true
}) {
Text(text = getLocaleDisplayName(selectedLocale.value), fontSize = 16.sp, fontWeight = FontWeight.Light)
}
}
}
}

View File

@ -1,17 +1,47 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ui.util.ObservableMutableState
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.util.ChooseFolderHelper
class SaveFolderScreen : SetupScreen() {
private lateinit var saveFolder: ObservableMutableState<String>
private lateinit var openFolderLauncher: () -> Unit
override fun init() {
saveFolder = ObservableMutableState(
defaultValue = "",
onChange = { _, newValue ->
Logger.debug(newValue)
if (newValue.isNotBlank()) {
context.config.root.downloader.saveFolder.set(newValue)
context.config.writeConfig()
allowNext(true)
}
}
)
openFolderLauncher = ChooseFolderHelper.createChooseFolder(context.activity as ComponentActivity) { uri ->
saveFolder.value = uri
}
}
@Composable
override fun Content() {
Text(text = "SaveFolder")
Button(onClick = {allowNext(true)}) {
Text(text = "Next")
DialogText(text = context.translation["setup.dialogs.save_folder"])
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
openFolderLauncher()
}) {
Text(text = context.translation["setup.dialogs.select_save_folder_button"])
}
}
}

View File

@ -0,0 +1,26 @@
package me.rhunk.snapenhance.ui.util
import android.app.Activity
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
object ChooseFolderHelper {
fun createChooseFolder(activity: ComponentActivity, callback: (uri: String) -> Unit): () -> Unit {
val activityResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) result@{
if (it.resultCode != Activity.RESULT_OK) return@result
val uri = it.data?.data ?: return@result
val value = uri.toString()
activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
callback(value)
}
return {
activityResultLauncher.launch(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
)
}
}
}

View File

@ -0,0 +1,19 @@
package me.rhunk.snapenhance.ui.util
import androidx.compose.runtime.MutableState
class ObservableMutableState<T>(
defaultValue: T,
inline val onChange: (T, T) -> Unit = { _, _ -> },
) : MutableState<T> {
private var mutableValue: T = defaultValue
override var value: T
get() = mutableValue
set(value) {
val oldValue = mutableValue
mutableValue = value
onChange(oldValue, value)
}
override fun component1() = value
override fun component2(): (T) -> Unit = { value = it }
}

View File

@ -83,11 +83,11 @@ interface BridgeInterface {
void clearMessageLogger();
/**
* Fetch the translations
* Fetch the locales
*
* @return the translations result
* @return the locale result
*/
Map<String, String> fetchTranslations();
Map<String, String> fetchLocales(String userLocale);
/**
* Get check for updates last time

View File

@ -1,4 +1,21 @@
{
"setup": {
"dialogs": {
"select_language": "Select Language",
"save_folder": "For downloading snapchat media, you'll need to choose a save location. This can be changed later in the application settings.",
"select_save_folder_button": "Select Save Folder",
"mappings": "To support a wide range of versions, mappings need to be generated for the current snapchat version."
},
"mappings": {
"dialog": "To support a wide range of versions, mappings need to be generated for the current snapchat version.",
"snapchat_not_found": "Snapchat could not be found on your device. Please install Snapchat and try again.",
"snapchat_not_supported": "Snapchat is not supported. Please update Snapchat and try again.",
"generate_button": "Generate",
"generate_error": "An error occurred while generating mappings. Please try again.",
"generate_success": "Mappings generated successfully."
}
},
"category": {
"spying_privacy": "Spying & Privacy",
"media_manager": "Media Manager",

View File

@ -1,4 +1,12 @@
{
"setup": {
"dialogs": {
"select_language": "Selectionner une langue",
"save_folder": "Pour télécharger les médias Snapchat, vous devez choisir un emplacement de sauvegarde. Cela peut être modifié plus tard dans les paramètres de l'application.",
"select_save_folder_button": "Choisir un emplacement de sauvegarde"
}
},
"category": {
"spying_privacy": "Espionnage et vie privée",
"media_manager": "Gestionnaire de média",

View File

@ -16,6 +16,11 @@ object Logger {
Log.d(TAG, message.toString())
}
fun debug(tag: String, message: Any?) {
if (!BuildConfig.DEBUG) return
Log.d(tag, message.toString())
}
fun error(throwable: Throwable) {
Log.e(TAG, "", throwable)
}

View File

@ -122,4 +122,8 @@ class ModContext {
fun reloadConfig() {
modConfig.loadFromBridge(bridgeClient)
}
fun getConfigLocale(): String {
return modConfig.locale
}
}

View File

@ -90,14 +90,14 @@ class SnapEnhance {
@OptIn(ExperimentalTime::class)
private suspend fun init() {
//load translations in a coroutine to speed up initialization
withContext(appContext.coroutineDispatcher) {
appContext.translation.loadFromBridge(appContext.bridgeClient)
}
measureTime {
with(appContext) {
reloadConfig()
withContext(appContext.coroutineDispatcher) {
translation.userLocale = getConfigLocale()
translation.loadFromBridge(appContext.bridgeClient)
}
mappings.init()
eventDispatcher.init()
//if mappings aren't loaded, we can't initialize features

View File

@ -36,8 +36,9 @@ class BridgeClient(
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
)
//TODO: randomize package name
val intent = Intent()
.setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name)
.setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.bridge.BridgeService")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
@ -103,7 +104,7 @@ class BridgeClient(
fun clearMessageLogger() = service.clearMessageLogger()
fun fetchTranslations() = service.fetchTranslations().map {
fun fetchLocales(userLocale: String) = service.fetchLocales(userLocale).map {
LocalePair(it.key, it.value)
}

View File

@ -13,15 +13,14 @@ class LocaleWrapper {
companion object {
const val DEFAULT_LOCALE = "en_US"
fun fetchLocales(context: Context): List<LocalePair> {
val deviceLocale = Locale.getDefault().toString()
val locales = mutableListOf<LocalePair>()
fun fetchLocales(context: Context, locale: String = DEFAULT_LOCALE): List<LocalePair> {
val locales = mutableListOf<LocalePair>().apply {
add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() }))
}
locales.add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() }))
if (locale == DEFAULT_LOCALE) return locales
if (deviceLocale == DEFAULT_LOCALE) return locales
val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(deviceLocale) }?.substring(0, 5) ?: return locales
val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(locale) }?.substring(0, 5) ?: return locales
context.resources.assets.open("lang/$compatibleLocale.json").use { inputStream ->
locales.add(LocalePair(compatibleLocale, inputStream.bufferedReader().use { it.readText() }))
@ -29,19 +28,24 @@ class LocaleWrapper {
return locales
}
fun fetchAvailableLocales(context: Context): List<String> {
return context.resources.assets.list("lang")?.map { it.substring(0, 5) } ?: listOf()
}
}
var userLocale = DEFAULT_LOCALE
private val translationMap = linkedMapOf<String, String>()
private lateinit var _locale: String
private lateinit var _loadedLocaleString: String
val locale by lazy {
Locale(_locale.substring(0, 2), _locale.substring(3, 5))
val loadedLocale by lazy {
Locale(_loadedLocaleString.substring(0, 2), _loadedLocaleString.substring(3, 5))
}
private fun load(localePair: LocalePair) {
if (!::_locale.isInitialized) {
_locale = localePair.locale
if (!::_loadedLocaleString.isInitialized) {
_loadedLocaleString = localePair.locale
}
val translations = JsonParser.parseString(localePair.content).asJsonObject
@ -64,17 +68,23 @@ class LocaleWrapper {
}
fun loadFromBridge(bridgeClient: BridgeClient) {
bridgeClient.fetchTranslations().forEach {
bridgeClient.fetchLocales(userLocale).forEach {
load(it)
}
}
fun loadFromContext(context: Context) {
fetchLocales(context).forEach {
fetchLocales(context, userLocale).forEach {
load(it)
}
}
fun reloadFromContext(context: Context, locale: String) {
userLocale = locale
translationMap.clear()
loadFromContext(context)
}
operator fun get(key: String): String {
return translationMap[key] ?: key.also { Logger.debug("Missing translation for $key") }
}

View File

@ -46,7 +46,6 @@ class MappingsWrapper(
private val mappings = ConcurrentHashMap<String, Any>()
private var snapBuildNumber: Long = 0
@Suppress("deprecation")
fun init() {
snapBuildNumber = getSnapchatVersionCode()
@ -54,6 +53,7 @@ class MappingsWrapper(
runCatching {
loadCached()
}.onFailure {
Logger.error("Failed to load cached mappings", it)
delete()
}
}
@ -100,6 +100,7 @@ class MappingsWrapper(
}
fun refresh() {
snapBuildNumber = getSnapchatVersionCode()
val mapper = Mapper(*mappers)
runCatching {
@ -114,7 +115,7 @@ class MappingsWrapper(
}
write(result.toString().toByteArray())
}.also {
Logger.xposedLog("Generated mappings in $it ms")
Logger.debug("Generated mappings in $it ms")
}
}

View File

@ -38,7 +38,7 @@ open class ConfigContainer(
vararg values: String = emptyArray(),
params: ConfigParamsBuilder = {}
) = registerProperty(key,
DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(emptyList<String>(), defaultValues = values.toList()), params)
DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(mutableListOf<String>(), defaultValues = values.toList()), params)
//null value is considered as Off/Disabled
protected fun unique(

View File

@ -10,22 +10,20 @@ import me.rhunk.snapenhance.bridge.FileLoaderWrapper
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.core.config.impl.RootConfig
import kotlin.properties.Delegates
class ModConfig {
var locale: String = LocaleWrapper.DEFAULT_LOCALE
set(value) {
field = value
writeConfig()
}
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val file = FileLoaderWrapper(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8))
var wasPresent by Delegates.notNull<Boolean>()
val root = RootConfig()
operator fun getValue(thisRef: Any?, property: Any?) = root
private fun load() {
wasPresent = file.isFileExists()
if (!file.isFileExists()) {
writeConfig()
return
@ -42,12 +40,13 @@ class ModConfig {
private fun loadConfig() {
val configFileContent = file.read()
val configObject = gson.fromJson(configFileContent.toString(Charsets.UTF_8), JsonObject::class.java)
locale = configObject.get("language")?.asString ?: LocaleWrapper.DEFAULT_LOCALE
locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE
root.fromJson(configObject)
}
fun writeConfig() {
val configObject = root.toJson()
configObject.addProperty("language", locale)
configObject.addProperty("_locale", locale)
file.write(configObject.toString().toByteArray(Charsets.UTF_8))
}

View File

@ -16,11 +16,16 @@ class DownloadManagerClient (
private val metadata: DownloadMetadata,
private val callback: DownloadCallback
) {
companion object {
const val DOWNLOAD_REQUEST_EXTRA = "request"
const val DOWNLOAD_METADATA_EXTRA = "metadata"
}
private fun enqueueDownloadRequest(request: DownloadRequest) {
context.bridgeClient.enqueueDownload(Intent().apply {
putExtras(Bundle().apply {
putString(DownloadProcessor.DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request))
putString(DownloadProcessor.DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata))
putString(DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request))
putString(DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata))
})
}, callback)
}

View File

@ -3,9 +3,9 @@ package me.rhunk.snapenhance.features
object FeatureLoadParams {
const val NO_INIT = 0
const val INIT_SYNC = 1
const val ACTIVITY_CREATE_SYNC = 2
const val INIT_SYNC = 0b0001
const val ACTIVITY_CREATE_SYNC = 0b0010
const val INIT_ASYNC = 3
const val ACTIVITY_CREATE_ASYNC = 4
const val INIT_ASYNC = 0b0100
const val ACTIVITY_CREATE_ASYNC = 0b1000
}

View File

@ -84,7 +84,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
${birthday.getDisplayName(
Calendar.MONTH,
Calendar.LONG,
context.translation.locale
context.translation.loadedLocale
)?.let {
context.translation.format("profile_info.birthday",
"month" to it,

View File

@ -47,9 +47,8 @@ class SettingsGearInjector : AbstractMenu() {
setOnClickListener {
val intent = Intent().apply {
setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.manager.MainActivity")
setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity")
putExtra("route", "features")
putExtra("lspatched", File(context.cacheDir, "lspatch/origin").exists())
}
context.startActivity(intent)
}