commit 641d66b208e67768e16beab0e54a84a332debcb3 parent 853ceec290c698ea7ba787abfb3e196832a575e5 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 3 Aug 2023 21:48:23 +0200 feat(ui): setup activity - remote side context - fix float dialogs - fix choose folder Diffstat:
41 files changed, 1137 insertions(+), 736 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -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" diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -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) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt @@ -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()!! + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -0,0 +1,114 @@ +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 +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() + } + + inner class BridgeBinder : BridgeInterface.Stub() { + override fun createAndReadFile(fileType: Int, defaultContent: ByteArray?): ByteArray { + val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) + ?: return defaultContent ?: ByteArray(0) + + if (!file.exists()) { + if (defaultContent == null) { + return ByteArray(0) + } + + file.writeBytes(defaultContent) + } + + return file.readBytes() + } + + override fun readFile(fileType: Int): ByteArray { + val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) + ?: return ByteArray(0) + + if (!file.exists()) { + return ByteArray(0) + } + + return file.readBytes() + } + + override fun writeFile(fileType: Int, content: ByteArray?): Boolean { + val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) + ?: return false + + if (content == null) { + return false + } + + file.writeBytes(content) + return true + } + + override fun deleteFile(fileType: Int): Boolean { + val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) + ?: return false + + if (!file.exists()) { + return false + } + + return file.delete() + } + + override fun isFileExists(fileType: Int): Boolean { + val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) + ?: return false + + return file.exists() + } + + override fun getLoggedMessageIds(conversationId: String, limit: Int) = messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray() + + override fun getMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.getMessage(conversationId, id).second + + override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) { + messageLoggerWrapper.addMessage(conversationId, id, message) + } + + override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.deleteMessage(conversationId, id) + + override fun clearMessageLogger() = messageLoggerWrapper.clearMessages() + + override fun fetchLocales(userLocale: String) = LocaleWrapper.fetchLocales(context = this@BridgeService, userLocale).associate { + it.locale to it.content + } + + override fun getAutoUpdaterTime(): Long { + throw UnsupportedOperationException() + } + + override fun setAutoUpdaterTime(time: Long) { + throw UnsupportedOperationException() + } + + 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) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -0,0 +1,384 @@ +package me.rhunk.snapenhance.download + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.documentfile.provider.DocumentFile +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.SharedContext +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.core.config.ModConfig +import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.download.data.DownloadMetadata +import me.rhunk.snapenhance.download.data.DownloadRequest +import me.rhunk.snapenhance.download.data.InputMedia +import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.enums.DownloadMediaType +import me.rhunk.snapenhance.download.enums.DownloadStage +import me.rhunk.snapenhance.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper +import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.zip.ZipInputStream +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import kotlin.coroutines.coroutineContext +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class DownloadedFile( + val file: File, + val fileType: FileType +) + +/** + * DownloadProcessor handles the download requests of the user + */ +@OptIn(ExperimentalEncodingApi::class) +class DownloadProcessor ( + private val context: Context, + private val callback: DownloadCallback +) { + + private val translation by lazy { + SharedContext.translation.getCategory("download_processor") + } + + private val gson by lazy { + GsonBuilder().setPrettyPrinting().create() + } + + private fun fallbackToast(message: Any) { + android.os.Handler(context.mainLooper).post { + Toast.makeText(context, message.toString(), Toast.LENGTH_SHORT).show() + } + } + + private fun extractZip(inputStream: InputStream): List<File> { + val files = mutableListOf<File>() + val zipInputStream = ZipInputStream(inputStream) + var entry = zipInputStream.nextEntry + + while (entry != null) { + createMediaTempFile().also { file -> + file.outputStream().use { outputStream -> + zipInputStream.copyTo(outputStream) + } + files += file + } + entry = zipInputStream.nextEntry + } + + return files + } + + private fun decryptInputStream(inputStream: InputStream, encryption: MediaEncryptionKeyPair): InputStream { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val key = Base64.UrlSafe.decode(encryption.key) + val iv = Base64.UrlSafe.decode(encryption.iv) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return CipherInputStream(inputStream, cipher) + } + + private fun createNeededDirectories(file: File): File { + val directory = file.parentFile ?: return file + if (!directory.exists()) { + directory.mkdirs() + } + return file + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { + if (coroutineContext.job.isCancelled) return + + val config by ModConfig().apply { loadFromContext(context) } + + runCatching { + val fileType = FileType.fromFile(inputFile) + if (fileType == FileType.UNKNOWN) { + callback.onFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null) + return + } + + val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension + + val outputFolder = DocumentFile.fromTreeUri(context, Uri.parse(config.downloader.saveFolder.get())) + ?: throw Exception("Failed to open output folder") + + val outputFileFolder = pendingDownload.metadata.outputPath.let { + if (it.contains("/")) { + it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> + folder.findFile(name) ?: folder.createDirectory(name)!! + } + } else { + outputFolder + } + } + + val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! + val outputStream = context.contentResolver.openOutputStream(outputFile.uri)!! + + inputFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + + pendingDownload.outputFile = outputFile.uri.toString() + pendingDownload.downloadStage = DownloadStage.SAVED + + runCatching { + val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") + mediaScanIntent.setData(outputFile.uri) + context.sendBroadcast(mediaScanIntent) + }.onFailure { + Logger.error("Failed to scan media file", it) + callback.onFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) + } + + Logger.debug("download complete") + fileName.let { + runCatching { callback.onSuccess(it) }.onFailure { fallbackToast(it) } + } + }.onFailure { exception -> + Logger.error(exception) + translation.format("failed_gallery_toast", "error" to exception.toString()).let { + runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } + } + pendingDownload.downloadStage = DownloadStage.FAILED + } + } + + private fun createMediaTempFile(): File { + return File.createTempFile("media", ".tmp") + } + + private fun downloadInputMedias(downloadRequest: DownloadRequest) = runBlocking { + val jobs = mutableListOf<Job>() + val downloadedMedias = mutableMapOf<InputMedia, File>() + + downloadRequest.inputMedias.forEach { inputMedia -> + fun handleInputStream(inputStream: InputStream) { + createMediaTempFile().apply { + if (inputMedia.encryption != null) { + decryptInputStream(inputStream, + inputMedia.encryption!! + ).use { decryptedInputStream -> + decryptedInputStream.copyTo(outputStream()) + } + } else { + inputStream.copyTo(outputStream()) + } + }.also { downloadedMedias[inputMedia] = it } + } + + launch { + when (inputMedia.type) { + DownloadMediaType.PROTO_MEDIA -> { + RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content))?.let { inputStream -> + handleInputStream(inputStream) + } + } + DownloadMediaType.DIRECT_MEDIA -> { + val decoded = Base64.UrlSafe.decode(inputMedia.content) + createMediaTempFile().apply { + writeBytes(decoded) + }.also { downloadedMedias[inputMedia] = it } + } + DownloadMediaType.REMOTE_MEDIA -> { + with(URL(inputMedia.content).openConnection() as HttpURLConnection) { + requestMethod = "GET" + setRequestProperty("User-Agent", Constants.USER_AGENT) + connect() + handleInputStream(inputStream) + } + } + else -> { + downloadedMedias[inputMedia] = File(inputMedia.content) + } + } + }.also { jobs.add(it) } + } + + jobs.joinAll() + downloadedMedias + } + + private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { + downloadRequest.inputMedias.first().let { inputMedia -> + val mediaType = inputMedia.type + val media = downloadedMedias[inputMedia]!! + + if (!downloadRequest.isDashPlaylist) { + saveMediaToGallery(media.file, pendingDownloadObject) + media.file.delete() + return + } + + assert(mediaType == DownloadMediaType.REMOTE_MEDIA) + + val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file) + val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") + for (i in 0 until baseUrlNodeList.length) { + val baseUrlNode = baseUrlNodeList.item(i) + val baseUrl = baseUrlNode.textContent + baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl" + } + + val dashOptions = downloadRequest.dashOptions!! + + val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) + val xmlData = dashPlaylistFile.outputStream() + TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) + + translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension).let { + runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) } + } + val outputFile = File.createTempFile("dash", ".mp4") + runCatching { + MediaDownloaderHelper.downloadDashChapterFile( + dashPlaylist = dashPlaylistFile, + output = outputFile, + startTime = dashOptions.offsetTime, + duration = dashOptions.duration) + saveMediaToGallery(outputFile, pendingDownloadObject) + }.onFailure { exception -> + if (coroutineContext.job.isCancelled) return@onFailure + Logger.error(exception) + translation.format("failed_processing_toast", "error" to exception.toString()).let { + runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } + } + pendingDownloadObject.downloadStage = DownloadStage.FAILED + } + + dashPlaylistFile.delete() + outputFile.delete() + media.file.delete() + } + } + + private fun renameFromFileType(file: File, fileType: FileType): File { + val newFile = File(file.parentFile, file.nameWithoutExtension + "." + fileType.fileExtension) + file.renameTo(newFile) + return newFile + } + + fun onReceive(intent: Intent) { + CoroutineScope(Dispatchers.IO).launch { + 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) { + "already_downloaded_toast" + } else { + "already_queued_toast" + }].let { + runCatching { callback.onFailure(it, null) }.onFailure { fallbackToast(it) } + } + return@launch + } + + val pendingDownloadObject = PendingDownload( + metadata = downloadMetadata + ) + + SharedContext.downloadTaskManager.addTask(pendingDownloadObject) + pendingDownloadObject.apply { + job = coroutineContext.job + downloadStage = DownloadStage.DOWNLOADING + } + + runCatching { + //first download all input medias into cache + val downloadedMedias = downloadInputMedias(downloadRequest).map { + it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) + }.toMap().toMutableMap() + Logger.debug("downloaded ${downloadedMedias.size} medias") + + var shouldMergeOverlay = downloadRequest.shouldMergeOverlay + + //if there is a zip file, extract it and replace the downloaded media with the extracted ones + downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { entry -> + val extractedMedias = extractZip(entry.file.inputStream()).map { + InputMedia( + type = DownloadMediaType.LOCAL_MEDIA, + content = it.absolutePath + ) to DownloadedFile(it, FileType.fromFile(it)) + } + + downloadedMedias.values.removeIf { + it.file.delete() + true + } + + downloadedMedias.putAll(extractedMedias) + shouldMergeOverlay = true + } + + if (shouldMergeOverlay) { + assert(downloadedMedias.size == 2) + val media = downloadedMedias.values.first { it.fileType.isVideo } + val overlayMedia = downloadedMedias.values.first { it.fileType.isImage } + + val renamedMedia = renameFromFileType(media.file, media.fileType) + val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType) + val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) + runCatching { + translation.format("download_toast", "path" to media.file.nameWithoutExtension).let { + runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) } + } + pendingDownloadObject.downloadStage = DownloadStage.MERGING + + MediaDownloaderHelper.mergeOverlayFile( + media = renamedMedia, + overlay = renamedOverlayMedia, + output = mergedOverlay + ) + + saveMediaToGallery(mergedOverlay, pendingDownloadObject) + }.onFailure { exception -> + if (coroutineContext.job.isCancelled) return@onFailure + Logger.error(exception) + translation.format("failed_processing_toast", "error" to exception.toString()).let { + runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } + } + pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED + } + + mergedOverlay.delete() + renamedOverlayMedia.delete() + renamedMedia.delete() + return@launch + } + + downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) + }.onFailure { exception -> + pendingDownloadObject.downloadStage = DownloadStage.FAILED + Logger.error(exception) + translation["failed_generic_toast"].let { + runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/MainActivity.kt @@ -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 + ) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/ManagerContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/ManagerContext.kt @@ -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 - ) -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt @@ -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) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt @@ -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() } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/HomeSection.kt @@ -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()) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/Dialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/Dialogs.kt @@ -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() }) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -1,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 -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/util/SaveFolderChecker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/util/SaveFolderChecker.kt @@ -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() - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt @@ -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" } } } + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt @@ -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, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupContext.kt @@ -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) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/SetupScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/SetupScreen.kt @@ -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() } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/LanguageScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/LanguageScreen.kt @@ -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() { - - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt @@ -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"]) + } } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt @@ -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) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt @@ -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"]) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ChooseFolderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ChooseFolderHelper.kt @@ -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) + ) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ObservableMutableState.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ObservableMutableState.kt @@ -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 } +}+ \ No newline at end of file diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -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 diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -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", diff --git a/core/src/main/assets/lang/fr_FR.json b/core/src/main/assets/lang/fr_FR.json @@ -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", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt @@ -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) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -122,4 +122,8 @@ class ModContext { fun reloadConfig() { modConfig.loadFromBridge(bridgeClient) } + + fun getConfigLocale(): String { + return modConfig.locale + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -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 diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt @@ -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) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -1,105 +0,0 @@ -package me.rhunk.snapenhance.bridge - -import android.app.Service -import android.content.Intent -import android.os.IBinder -import me.rhunk.snapenhance.SharedContext -import me.rhunk.snapenhance.bridge.types.BridgeFileType -import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper -import me.rhunk.snapenhance.download.DownloadProcessor - -class BridgeService : Service() { - private lateinit var messageLoggerWrapper: MessageLoggerWrapper - override fun onBind(intent: Intent): IBinder { - messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() } - return BridgeBinder() - } - - inner class BridgeBinder : BridgeInterface.Stub() { - override fun createAndReadFile(fileType: Int, defaultContent: ByteArray?): ByteArray { - val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) - ?: return defaultContent ?: ByteArray(0) - - if (!file.exists()) { - if (defaultContent == null) { - return ByteArray(0) - } - - file.writeBytes(defaultContent) - } - - return file.readBytes() - } - - override fun readFile(fileType: Int): ByteArray { - val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) - ?: return ByteArray(0) - - if (!file.exists()) { - return ByteArray(0) - } - - return file.readBytes() - } - - override fun writeFile(fileType: Int, content: ByteArray?): Boolean { - val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) - ?: return false - - if (content == null) { - return false - } - - file.writeBytes(content) - return true - } - - override fun deleteFile(fileType: Int): Boolean { - val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) - ?: return false - - if (!file.exists()) { - return false - } - - return file.delete() - } - - override fun isFileExists(fileType: Int): Boolean { - val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) - ?: return false - - return file.exists() - } - - override fun getLoggedMessageIds(conversationId: String, limit: Int) = messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray() - - override fun getMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.getMessage(conversationId, id).second - - override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) { - messageLoggerWrapper.addMessage(conversationId, id, message) - } - - override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.deleteMessage(conversationId, id) - - override fun clearMessageLogger() = messageLoggerWrapper.clearMessages() - - override fun fetchTranslations() = LocaleWrapper.fetchLocales(context = this@BridgeService).associate { - it.locale to it.content - } - - override fun getAutoUpdaterTime(): Long { - throw UnsupportedOperationException() - } - - override fun setAutoUpdaterTime(time: Long) { - throw UnsupportedOperationException() - } - - override fun enqueueDownload(intent: Intent, callback: DownloadCallback) { - SharedContext.ensureInitialized(this@BridgeService) - DownloadProcessor(this@BridgeService, callback).onReceive(intent) - } - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/LocaleWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/LocaleWrapper.kt @@ -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>() - - locales.add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() })) + 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() })) + } - if (deviceLocale == DEFAULT_LOCALE) return locales + if (locale == 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") } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MappingsWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MappingsWrapper.kt @@ -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") } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigContainer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigContainer.kt @@ -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( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ModConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ModConfig.kt @@ -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)) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt @@ -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) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -1,386 +0,0 @@ -package me.rhunk.snapenhance.download - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.widget.Toast -import androidx.documentfile.provider.DocumentFile -import com.google.gson.GsonBuilder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.job -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.SharedContext -import me.rhunk.snapenhance.bridge.DownloadCallback -import me.rhunk.snapenhance.core.config.ModConfig -import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.download.data.DownloadMetadata -import me.rhunk.snapenhance.download.data.DownloadRequest -import me.rhunk.snapenhance.download.data.InputMedia -import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair -import me.rhunk.snapenhance.download.data.PendingDownload -import me.rhunk.snapenhance.download.enums.DownloadMediaType -import me.rhunk.snapenhance.download.enums.DownloadStage -import me.rhunk.snapenhance.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper -import java.io.File -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.URL -import java.util.zip.ZipInputStream -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult -import kotlin.coroutines.coroutineContext -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -data class DownloadedFile( - val file: File, - val fileType: FileType -) - -/** - * DownloadProcessor handles the download requests of the user - */ -@OptIn(ExperimentalEncodingApi::class) -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") - } - - private val gson by lazy { - GsonBuilder().setPrettyPrinting().create() - } - - private fun fallbackToast(message: Any) { - android.os.Handler(context.mainLooper).post { - Toast.makeText(context, message.toString(), Toast.LENGTH_SHORT).show() - } - } - - private fun extractZip(inputStream: InputStream): List<File> { - val files = mutableListOf<File>() - val zipInputStream = ZipInputStream(inputStream) - var entry = zipInputStream.nextEntry - - while (entry != null) { - createMediaTempFile().also { file -> - file.outputStream().use { outputStream -> - zipInputStream.copyTo(outputStream) - } - files += file - } - entry = zipInputStream.nextEntry - } - - return files - } - - private fun decryptInputStream(inputStream: InputStream, encryption: MediaEncryptionKeyPair): InputStream { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - val key = Base64.UrlSafe.decode(encryption.key) - val iv = Base64.UrlSafe.decode(encryption.iv) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) - return CipherInputStream(inputStream, cipher) - } - - private fun createNeededDirectories(file: File): File { - val directory = file.parentFile ?: return file - if (!directory.exists()) { - directory.mkdirs() - } - return file - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { - if (coroutineContext.job.isCancelled) return - - val config by ModConfig().apply { loadFromContext(context) } - - runCatching { - val fileType = FileType.fromFile(inputFile) - if (fileType == FileType.UNKNOWN) { - callback.onFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null) - return - } - - val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension - - val outputFolder = DocumentFile.fromTreeUri(context, Uri.parse(config.downloader.saveFolder.get())) - ?: throw Exception("Failed to open output folder") - - val outputFileFolder = pendingDownload.metadata.outputPath.let { - if (it.contains("/")) { - it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> - folder.findFile(name) ?: folder.createDirectory(name)!! - } - } else { - outputFolder - } - } - - val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! - val outputStream = context.contentResolver.openOutputStream(outputFile.uri)!! - - inputFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - - pendingDownload.outputFile = outputFile.uri.toString() - pendingDownload.downloadStage = DownloadStage.SAVED - - runCatching { - val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") - mediaScanIntent.setData(outputFile.uri) - context.sendBroadcast(mediaScanIntent) - }.onFailure { - Logger.error("Failed to scan media file", it) - callback.onFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) - } - - Logger.debug("download complete") - fileName.let { - runCatching { callback.onSuccess(it) }.onFailure { fallbackToast(it) } - } - }.onFailure { exception -> - Logger.error(exception) - translation.format("failed_gallery_toast", "error" to exception.toString()).let { - runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } - } - pendingDownload.downloadStage = DownloadStage.FAILED - } - } - - private fun createMediaTempFile(): File { - return File.createTempFile("media", ".tmp") - } - - private fun downloadInputMedias(downloadRequest: DownloadRequest) = runBlocking { - val jobs = mutableListOf<Job>() - val downloadedMedias = mutableMapOf<InputMedia, File>() - - downloadRequest.inputMedias.forEach { inputMedia -> - fun handleInputStream(inputStream: InputStream) { - createMediaTempFile().apply { - if (inputMedia.encryption != null) { - decryptInputStream(inputStream, inputMedia.encryption).use { decryptedInputStream -> - decryptedInputStream.copyTo(outputStream()) - } - } else { - inputStream.copyTo(outputStream()) - } - }.also { downloadedMedias[inputMedia] = it } - } - - launch { - when (inputMedia.type) { - DownloadMediaType.PROTO_MEDIA -> { - RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content))?.let { inputStream -> - handleInputStream(inputStream) - } - } - DownloadMediaType.DIRECT_MEDIA -> { - val decoded = Base64.UrlSafe.decode(inputMedia.content) - createMediaTempFile().apply { - writeBytes(decoded) - }.also { downloadedMedias[inputMedia] = it } - } - DownloadMediaType.REMOTE_MEDIA -> { - with(URL(inputMedia.content).openConnection() as HttpURLConnection) { - requestMethod = "GET" - setRequestProperty("User-Agent", Constants.USER_AGENT) - connect() - handleInputStream(inputStream) - } - } - else -> { - downloadedMedias[inputMedia] = File(inputMedia.content) - } - } - }.also { jobs.add(it) } - } - - jobs.joinAll() - downloadedMedias - } - - private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { - downloadRequest.inputMedias.first().let { inputMedia -> - val mediaType = inputMedia.type - val media = downloadedMedias[inputMedia]!! - - if (!downloadRequest.isDashPlaylist) { - saveMediaToGallery(media.file, pendingDownloadObject) - media.file.delete() - return - } - - assert(mediaType == DownloadMediaType.REMOTE_MEDIA) - - val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file) - val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") - for (i in 0 until baseUrlNodeList.length) { - val baseUrlNode = baseUrlNodeList.item(i) - val baseUrl = baseUrlNode.textContent - baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl" - } - - val dashOptions = downloadRequest.dashOptions!! - - val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) - val xmlData = dashPlaylistFile.outputStream() - TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) - - translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension).let { - runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) } - } - val outputFile = File.createTempFile("dash", ".mp4") - runCatching { - MediaDownloaderHelper.downloadDashChapterFile( - dashPlaylist = dashPlaylistFile, - output = outputFile, - startTime = dashOptions.offsetTime, - duration = dashOptions.duration) - saveMediaToGallery(outputFile, pendingDownloadObject) - }.onFailure { exception -> - if (coroutineContext.job.isCancelled) return@onFailure - Logger.error(exception) - translation.format("failed_processing_toast", "error" to exception.toString()).let { - runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } - } - pendingDownloadObject.downloadStage = DownloadStage.FAILED - } - - dashPlaylistFile.delete() - outputFile.delete() - media.file.delete() - } - } - - private fun renameFromFileType(file: File, fileType: FileType): File { - val newFile = File(file.parentFile, file.nameWithoutExtension + "." + fileType.fileExtension) - file.renameTo(newFile) - return newFile - } - - 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) - - SharedContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage -> - translation[if (downloadStage.isFinalStage) { - "already_downloaded_toast" - } else { - "already_queued_toast" - }].let { - runCatching { callback.onFailure(it, null) }.onFailure { fallbackToast(it) } - } - return@launch - } - - val pendingDownloadObject = PendingDownload( - metadata = downloadMetadata - ) - - SharedContext.downloadTaskManager.addTask(pendingDownloadObject) - pendingDownloadObject.apply { - job = coroutineContext.job - downloadStage = DownloadStage.DOWNLOADING - } - - runCatching { - //first download all input medias into cache - val downloadedMedias = downloadInputMedias(downloadRequest).map { - it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) - }.toMap().toMutableMap() - Logger.debug("downloaded ${downloadedMedias.size} medias") - - var shouldMergeOverlay = downloadRequest.shouldMergeOverlay - - //if there is a zip file, extract it and replace the downloaded media with the extracted ones - downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { entry -> - val extractedMedias = extractZip(entry.file.inputStream()).map { - InputMedia( - type = DownloadMediaType.LOCAL_MEDIA, - content = it.absolutePath - ) to DownloadedFile(it, FileType.fromFile(it)) - } - - downloadedMedias.values.removeIf { - it.file.delete() - true - } - - downloadedMedias.putAll(extractedMedias) - shouldMergeOverlay = true - } - - if (shouldMergeOverlay) { - assert(downloadedMedias.size == 2) - val media = downloadedMedias.values.first { it.fileType.isVideo } - val overlayMedia = downloadedMedias.values.first { it.fileType.isImage } - - val renamedMedia = renameFromFileType(media.file, media.fileType) - val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType) - val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) - runCatching { - translation.format("download_toast", "path" to media.file.nameWithoutExtension).let { - runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) } - } - pendingDownloadObject.downloadStage = DownloadStage.MERGING - - MediaDownloaderHelper.mergeOverlayFile( - media = renamedMedia, - overlay = renamedOverlayMedia, - output = mergedOverlay - ) - - saveMediaToGallery(mergedOverlay, pendingDownloadObject) - }.onFailure { exception -> - if (coroutineContext.job.isCancelled) return@onFailure - Logger.error(exception) - translation.format("failed_processing_toast", "error" to exception.toString()).let { - runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } - } - pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED - } - - mergedOverlay.delete() - renamedOverlayMedia.delete() - renamedMedia.delete() - return@launch - } - - downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) - }.onFailure { exception -> - pendingDownloadObject.downloadStage = DownloadStage.FAILED - Logger.error(exception) - translation["failed_generic_toast"].let { - runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) } - } - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt @@ -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 } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt @@ -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, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt @@ -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) }