diff --git a/app/build.gradle b/app/build.gradle index 441532dd..39388d03 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,14 +54,7 @@ android { abiFilters "armeabi-v7a" } packagingOptions { - exclude 'lib/armeabi-v7a/libswscale_neon.so' - exclude 'lib/armeabi-v7a/libswresample_neon.so' - exclude 'lib/armeabi-v7a/libffmpegkit_armv7a_neon.so' - exclude 'lib/armeabi-v7a/libavutil_neon.so' - exclude 'lib/armeabi-v7a/libavformat_neon.so' - exclude 'lib/armeabi-v7a/libavfilter_neon.so' - exclude 'lib/armeabi-v7a/libavdevice_neon.so' - exclude 'lib/armeabi-v7a/libavcodec_neon.so' + exclude 'lib/armeabi-v7a/*_neon.so' } dimension "release" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1d9f8b34..4c698c9a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,8 +10,11 @@ + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt index 67a793de..b2588e6c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt @@ -1,8 +1,15 @@ package me.rhunk.snapenhance +import android.app.Activity +import android.app.AlertDialog import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.provider.Settings import me.rhunk.snapenhance.bridge.TranslationWrapper import me.rhunk.snapenhance.download.DownloadTaskManager +import kotlin.system.exitProcess /** * Used to store objects between activities and receivers @@ -11,6 +18,24 @@ object SharedContext { lateinit var downloadTaskManager: DownloadTaskManager lateinit var translation: TranslationWrapper + private fun askForStoragePermission(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.addCategory("android.intent.category.DEFAULT") + intent.data = android.net.Uri.parse("package:${context.packageName}") + if (context !is Activity) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + exitProcess(0) + } + if (context !is Activity) { + Logger.log("Storage permission not granted, exiting") + exitProcess(0) + } + context.requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.READ_EXTERNAL_STORAGE), 0) + } + fun ensureInitialized(context: Context) { if (!this::downloadTaskManager.isInitialized) { downloadTaskManager = DownloadTaskManager().apply { @@ -22,5 +47,29 @@ object SharedContext { loadFromContext(context) } } + + //ask for storage permission + val hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + context.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + + if (hasStoragePermission) return + + if (context !is Activity) { + askForStoragePermission(context) + return + } + AlertDialog.Builder(context) + .setTitle("Storage permission") + .setMessage("App needs storage permission to download files and save them to your device. Please allow it in the next screen.") + .setPositiveButton("Grant") { _, _ -> + askForStoragePermission(context) + } + .setNegativeButton("Cancel") { _, _ -> + exitProcess(0) + } + .show() } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt index 670a0560..518e2c98 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt @@ -1,14 +1,14 @@ package me.rhunk.snapenhance.download +import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.media.MediaScannerConnection +import android.net.Uri import android.os.Handler import android.widget.Toast -import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.job import kotlinx.coroutines.joinAll @@ -16,6 +16,7 @@ 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.data.FileType import me.rhunk.snapenhance.download.data.DownloadRequest import me.rhunk.snapenhance.download.data.InputMedia @@ -23,9 +24,8 @@ 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.SharedContext -import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper 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 @@ -113,6 +113,7 @@ class DownloadManagerReceiver : BroadcastReceiver() { return file } + @SuppressLint("UnspecifiedRegisterReceiverFlag") private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { if (coroutineContext.job.isCancelled) return @@ -122,11 +123,24 @@ class DownloadManagerReceiver : BroadcastReceiver() { longToast(translation.format("failed_gallery_toast", "error" to "Unknown media type")) return } - val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) } + inputFile.copyTo(outputFile, overwrite = true) - MediaScannerConnection.scanFile(context, arrayOf(outputFile.absolutePath), null, null) + pendingDownload.outputFile = outputFile.absolutePath + pendingDownload.downloadStage = DownloadStage.SAVED + + runCatching { + val contentUri = Uri.fromFile(outputFile) + val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") + mediaScanIntent.setData(contentUri) + context.sendBroadcast(mediaScanIntent) + }.onFailure { + Logger.error("Failed to scan media file", it) + longToast(translation.format("failed_gallery_toast", "error" to it.toString())) + } + + Logger.debug("download complete") //print the path of the saved media val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let { @@ -136,9 +150,6 @@ class DownloadManagerReceiver : BroadcastReceiver() { shortToast( translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", "")) ) - - pendingDownload.outputFile = outputFile.absolutePath - pendingDownload.downloadStage = DownloadStage.SAVED }.onFailure { Logger.error(it) longToast(translation.format("failed_gallery_toast", "error" to it.toString())) @@ -254,10 +265,11 @@ class DownloadManagerReceiver : BroadcastReceiver() { return newFile } - @OptIn(DelicateCoroutinesApi::class) override fun onReceive(context: Context, intent: Intent) { if (intent.action != DOWNLOAD_ACTION) return this.context = context + Logger.debug("onReceive download") + SharedContext.ensureInitialized(context) val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) @@ -273,7 +285,7 @@ class DownloadManagerReceiver : BroadcastReceiver() { return } - GlobalScope.launch(Dispatchers.IO) { + CoroutineScope(Dispatchers.IO).launch { val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!) SharedContext.downloadTaskManager.addTask(pendingDownloadObject) @@ -287,6 +299,7 @@ class DownloadManagerReceiver : BroadcastReceiver() { 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 diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index 88eaa3be..c44d5fb2 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -152,7 +152,8 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam if (uri.scheme == "file") { return@let suspendCoroutine { continuation -> context.downloadServer.ensureServerStarted { - val url = putDownloadableContent(Paths.get(uri.path).inputStream()) + val file = Paths.get(uri.path).toFile() + val url = putDownloadableContent(file.inputStream(), file.length()) continuation.resumeWith(Result.success(url)) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt index a6b9d3e5..6f899bea 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.AlertDialog import android.content.res.ColorStateList import android.os.Bundle +import android.text.Html import android.text.InputType import android.view.View import android.view.ViewGroup @@ -12,6 +13,7 @@ import android.widget.ImageButton import android.widget.Switch import android.widget.TextView import android.widget.Toast +import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.R import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.bridge.ConfigWrapper @@ -104,6 +106,26 @@ class ConfigActivity : Activity() { val propertyListLayout = findViewById(R.id.property_list) + if (intent.getBooleanExtra("lspatched", false) || + applicationInfo.packageName != "me.rhunk.snapenhance" || + BuildConfig.DEBUG) { + propertyListLayout.addView( + layoutInflater.inflate( + R.layout.config_activity_debug_item, + propertyListLayout, + false + ).apply { + findViewById(R.id.debug_item_content).apply { + text = Html.fromHtml( + "You are using a debug/unofficial build!\n" + + "Please consider downloading stable builds from GitHub.", + Html.FROM_HTML_MODE_COMPACT + ) + movementMethod = android.text.method.LinkMovementMethod.getInstance() + } + }) + } + var currentCategory: ConfigCategory? = null config.entries().forEach { (property, value) -> @@ -248,5 +270,12 @@ class ConfigActivity : Activity() { propertyListLayout.addView(configItem) addSeparator() } + + propertyListLayout.addView(layoutInflater.inflate(R.layout.config_activity_debug_item, propertyListLayout, false).apply { + findViewById(R.id.debug_item_content).apply { + text = Html.fromHtml("Made by rhunk on GitHub", Html.FROM_HTML_MODE_COMPACT) + movementMethod = android.text.method.LinkMovementMethod.getInstance() + } + }) } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt index d65691da..c96683c2 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt @@ -10,6 +10,7 @@ import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.ui.config.ConfigActivity import me.rhunk.snapenhance.ui.menu.AbstractMenu +import java.io.File @SuppressLint("DiscouragedApi") @@ -49,6 +50,7 @@ class SettingsGearInjector : AbstractMenu() { val intent = Intent().apply { setClassName(BuildConfig.APPLICATION_ID, ConfigActivity::class.java.name) } + intent.putExtra("lspatched", File(context.cacheDir, "lspatch/origin").exists()) context.startActivity(intent) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt index 16dc8028..26c30e73 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt @@ -1,22 +1,27 @@ package me.rhunk.snapenhance.util.download +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.Logger.debug import java.io.BufferedReader import java.io.InputStream import java.io.InputStreamReader import java.io.PrintWriter import java.net.ServerSocket import java.net.Socket +import java.net.SocketTimeoutException import java.util.Locale import java.util.StringTokenizer import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ThreadLocalRandom -class DownloadServer { +class DownloadServer( + private val timeout: Int = 10000 +) { private val port = ThreadLocalRandom.current().nextInt(10000, 65535) - private val cachedData = ConcurrentHashMap() + private val cachedData = ConcurrentHashMap>() private var serverSocket: ServerSocket? = null fun ensureServerStarted(callback: DownloadServer.() -> Unit) { @@ -24,28 +29,37 @@ class DownloadServer { callback(this) return } - Thread { - try { - debug("started web server on 127.0.0.1:$port") - serverSocket = ServerSocket(port) - callback(this) - while (!serverSocket!!.isClosed) { - try { - val socket = serverSocket!!.accept() - Thread { handleRequest(socket) }.start() - } catch (e: Throwable) { - Logger.xposedLog(e) + + CoroutineScope(Dispatchers.IO).launch { + Logger.debug("starting download server on port $port") + serverSocket = ServerSocket(port) + serverSocket!!.soTimeout = timeout + callback(this@DownloadServer) + while (!serverSocket!!.isClosed) { + try { + val socket = serverSocket!!.accept() + launch(Dispatchers.IO) { + handleRequest(socket) } + } catch (e: SocketTimeoutException) { + serverSocket?.close() + serverSocket = null + Logger.debug("download server closed") + break; + } catch (e: Exception) { + Logger.error("failed to handle request", e) } - } catch (e: Throwable) { - Logger.xposedLog(e) } - }.start() + } } - fun putDownloadableContent(inputStream: InputStream): String { + fun close() { + serverSocket?.close() + } + + fun putDownloadableContent(inputStream: InputStream, size: Long): String { val key = System.nanoTime().toString(16) - cachedData[key] = inputStream + cachedData[key] = inputStream to size return "http://127.0.0.1:$port/$key" } @@ -96,14 +110,11 @@ class DownloadServer { with(writer) { println("HTTP/1.1 200 OK") println("Content-type: " + "application/octet-stream") + println("Content-length: " + requestedData.second) println() flush() } - val buffer = ByteArray(1024) - var bytesRead: Int - while (requestedData.read(buffer).also { bytesRead = it } != -1) { - outputStream.write(buffer, 0, bytesRead) - } + requestedData.first.copyTo(outputStream) outputStream.flush() cachedData.remove(fileRequested) close() diff --git a/app/src/main/res/layout/config_activity.xml b/app/src/main/res/layout/config_activity.xml index 2cec15c6..822d1e3a 100644 --- a/app/src/main/res/layout/config_activity.xml +++ b/app/src/main/res/layout/config_activity.xml @@ -1,5 +1,6 @@ + + + + + \ No newline at end of file