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:
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