commit d8625b4e805c2d90139c11a1cde62999d7b7a8e8
parent 19ec7463b0257c8bb624cfabf868f0d7d5f3f4b4
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Wed, 28 Jun 2023 14:31:36 +0200

fix(downloader): major improvement  (#110)

* fix: use download manager when no write permissions

* fix: download manager & config
- use coroutines

* fix(download_manager): scan media

* fix: download server blocking request

* fix(download/manager_receiver): IO dispatcher
- add debug messages

* build: packaging options
- add armv7 wildcard filter

* feat: build notices
- add notice for debug/lspatch builds and different package name
- add project author watermark

* fix(downloader): manage external storage permission

* fix(download/receiver): scan media file

* fix(context): storage permission for legacy devices
Diffstat:
Mapp/build.gradle | 9+--------
Mapp/src/main/AndroidManifest.xml | 3+++
Mapp/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt | 37+++++++++++++++++++++++++------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 3++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt | 29+++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt | 2++
Mapp/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt | 59+++++++++++++++++++++++++++++++++++------------------------
Mapp/src/main/res/layout/config_activity.xml | 1+
Aapp/src/main/res/layout/config_activity_debug_item.xml | 19+++++++++++++++++++
10 files changed, 166 insertions(+), 45 deletions(-)

diff --git 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 @@ -10,8 +10,11 @@ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> + <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" + tools:ignore="ScopedStorage" /> <application android:usesCleartextTraffic="true" + android:requestLegacyExternalStorage="true" android:label="@string/app_name" tools:targetApi="31" android:icon="@mipmap/launcher_icon"> diff --git 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 @@ -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 @@ -152,7 +152,8 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam if (uri.scheme == "file") { return@let suspendCoroutine<String> { 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 @@ -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<ViewGroup>(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<TextView>(R.id.debug_item_content).apply { + text = Html.fromHtml( + "You are using a <u><b>debug/unofficial</b></u> build!\n" + + "Please consider downloading stable builds from <a href=\"https://github.com/rhunk/SnapEnhance\">GitHub</a>.", + 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<TextView>(R.id.debug_item_content).apply { + text = Html.fromHtml("Made by rhunk on <a href=\"https://github.com/rhunk/SnapEnhance\">GitHub</a>", 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 @@ -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 @@ -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<String, InputStream>() + private val cachedData = ConcurrentHashMap<String, Pair<InputStream, Long>>() 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 @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:background="@color/primaryBackground" android:layout_width="match_parent" diff --git a/app/src/main/res/layout/config_activity_debug_item.xml b/app/src/main/res/layout/config_activity_debug_item.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:gravity="center" + android:layout_width="match_parent" + android:layout_height="60dp"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:id="@+id/debug_item_content" + android:text="" + android:linksClickable="true" + android:textSize="15sp" + android:textColor="@color/primaryText" + android:gravity="center" /> + +</LinearLayout>+ \ No newline at end of file