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
This commit is contained in:
rhunk 2023-06-28 14:31:36 +02:00 committed by GitHub
parent 19ec7463b0
commit d8625b4e80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 165 additions and 45 deletions

View File

@ -54,14 +54,7 @@ android {
abiFilters "armeabi-v7a" abiFilters "armeabi-v7a"
} }
packagingOptions { packagingOptions {
exclude 'lib/armeabi-v7a/libswscale_neon.so' exclude 'lib/armeabi-v7a/*_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'
} }
dimension "release" dimension "release"
} }

View File

@ -10,8 +10,11 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application <application
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
android:label="@string/app_name" android:label="@string/app_name"
tools:targetApi="31" tools:targetApi="31"
android:icon="@mipmap/launcher_icon"> android:icon="@mipmap/launcher_icon">

View File

@ -1,8 +1,15 @@
package me.rhunk.snapenhance package me.rhunk.snapenhance
import android.app.Activity
import android.app.AlertDialog
import android.content.Context 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.bridge.TranslationWrapper
import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.download.DownloadTaskManager
import kotlin.system.exitProcess
/** /**
* Used to store objects between activities and receivers * Used to store objects between activities and receivers
@ -11,6 +18,24 @@ object SharedContext {
lateinit var downloadTaskManager: DownloadTaskManager lateinit var downloadTaskManager: DownloadTaskManager
lateinit var translation: TranslationWrapper 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) { fun ensureInitialized(context: Context) {
if (!this::downloadTaskManager.isInitialized) { if (!this::downloadTaskManager.isInitialized) {
downloadTaskManager = DownloadTaskManager().apply { downloadTaskManager = DownloadTaskManager().apply {
@ -22,5 +47,29 @@ object SharedContext {
loadFromContext(context) 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()
} }
} }

View File

@ -1,14 +1,14 @@
package me.rhunk.snapenhance.download package me.rhunk.snapenhance.download
import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.MediaScannerConnection import android.net.Uri
import android.os.Handler import android.os.Handler
import android.widget.Toast import android.widget.Toast
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.job import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.DownloadRequest import me.rhunk.snapenhance.download.data.DownloadRequest
import me.rhunk.snapenhance.download.data.InputMedia 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.data.PendingDownload
import me.rhunk.snapenhance.download.enums.DownloadMediaType import me.rhunk.snapenhance.download.enums.DownloadMediaType
import me.rhunk.snapenhance.download.enums.DownloadStage 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.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
@ -113,6 +113,7 @@ class DownloadManagerReceiver : BroadcastReceiver() {
return file return file
} }
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) {
if (coroutineContext.job.isCancelled) return if (coroutineContext.job.isCancelled) return
@ -122,11 +123,24 @@ class DownloadManagerReceiver : BroadcastReceiver() {
longToast(translation.format("failed_gallery_toast", "error" to "Unknown media type")) longToast(translation.format("failed_gallery_toast", "error" to "Unknown media type"))
return return
} }
val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) } val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) }
inputFile.copyTo(outputFile, overwrite = true) 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 //print the path of the saved media
val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let { val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let {
@ -136,9 +150,6 @@ class DownloadManagerReceiver : BroadcastReceiver() {
shortToast( shortToast(
translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", "")) translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", ""))
) )
pendingDownload.outputFile = outputFile.absolutePath
pendingDownload.downloadStage = DownloadStage.SAVED
}.onFailure { }.onFailure {
Logger.error(it) Logger.error(it)
longToast(translation.format("failed_gallery_toast", "error" to it.toString())) longToast(translation.format("failed_gallery_toast", "error" to it.toString()))
@ -254,10 +265,11 @@ class DownloadManagerReceiver : BroadcastReceiver() {
return newFile return newFile
} }
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != DOWNLOAD_ACTION) return if (intent.action != DOWNLOAD_ACTION) return
this.context = context this.context = context
Logger.debug("onReceive download")
SharedContext.ensureInitialized(context) SharedContext.ensureInitialized(context)
val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) val downloadRequest = DownloadRequest.fromBundle(intent.extras!!)
@ -273,7 +285,7 @@ class DownloadManagerReceiver : BroadcastReceiver() {
return return
} }
GlobalScope.launch(Dispatchers.IO) { CoroutineScope(Dispatchers.IO).launch {
val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!) val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!)
SharedContext.downloadTaskManager.addTask(pendingDownloadObject) SharedContext.downloadTaskManager.addTask(pendingDownloadObject)
@ -287,6 +299,7 @@ class DownloadManagerReceiver : BroadcastReceiver() {
val downloadedMedias = downloadInputMedias(downloadRequest).map { val downloadedMedias = downloadInputMedias(downloadRequest).map {
it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
}.toMap().toMutableMap() }.toMap().toMutableMap()
Logger.debug("downloaded ${downloadedMedias.size} medias")
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay var shouldMergeOverlay = downloadRequest.shouldMergeOverlay

View File

@ -152,7 +152,8 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
if (uri.scheme == "file") { if (uri.scheme == "file") {
return@let suspendCoroutine<String> { continuation -> return@let suspendCoroutine<String> { continuation ->
context.downloadServer.ensureServerStarted { 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)) continuation.resumeWith(Result.success(url))
} }
} }

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Bundle import android.os.Bundle
import android.text.Html
import android.text.InputType import android.text.InputType
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -12,6 +13,7 @@ import android.widget.ImageButton
import android.widget.Switch import android.widget.Switch
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.R import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.ConfigWrapper import me.rhunk.snapenhance.bridge.ConfigWrapper
@ -104,6 +106,26 @@ class ConfigActivity : Activity() {
val propertyListLayout = findViewById<ViewGroup>(R.id.property_list) 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 var currentCategory: ConfigCategory? = null
config.entries().forEach { (property, value) -> config.entries().forEach { (property, value) ->
@ -248,5 +270,12 @@ class ConfigActivity : Activity() {
propertyListLayout.addView(configItem) propertyListLayout.addView(configItem)
addSeparator() 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()
}
})
} }
} }

View File

@ -10,6 +10,7 @@ import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.ui.config.ConfigActivity import me.rhunk.snapenhance.ui.config.ConfigActivity
import me.rhunk.snapenhance.ui.menu.AbstractMenu import me.rhunk.snapenhance.ui.menu.AbstractMenu
import java.io.File
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
@ -49,6 +50,7 @@ class SettingsGearInjector : AbstractMenu() {
val intent = Intent().apply { val intent = Intent().apply {
setClassName(BuildConfig.APPLICATION_ID, ConfigActivity::class.java.name) setClassName(BuildConfig.APPLICATION_ID, ConfigActivity::class.java.name)
} }
intent.putExtra("lspatched", File(context.cacheDir, "lspatch/origin").exists())
context.startActivity(intent) context.startActivity(intent)
} }

View File

@ -1,22 +1,27 @@
package me.rhunk.snapenhance.util.download 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
import me.rhunk.snapenhance.Logger.debug
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStream import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.PrintWriter import java.io.PrintWriter
import java.net.ServerSocket import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.net.SocketTimeoutException
import java.util.Locale import java.util.Locale
import java.util.StringTokenizer import java.util.StringTokenizer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
class DownloadServer { class DownloadServer(
private val timeout: Int = 10000
) {
private val port = ThreadLocalRandom.current().nextInt(10000, 65535) 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 private var serverSocket: ServerSocket? = null
fun ensureServerStarted(callback: DownloadServer.() -> Unit) { fun ensureServerStarted(callback: DownloadServer.() -> Unit) {
@ -24,28 +29,37 @@ class DownloadServer {
callback(this) callback(this)
return return
} }
Thread {
try { CoroutineScope(Dispatchers.IO).launch {
debug("started web server on 127.0.0.1:$port") Logger.debug("starting download server on port $port")
serverSocket = ServerSocket(port) serverSocket = ServerSocket(port)
callback(this) serverSocket!!.soTimeout = timeout
while (!serverSocket!!.isClosed) { callback(this@DownloadServer)
try { while (!serverSocket!!.isClosed) {
val socket = serverSocket!!.accept() try {
Thread { handleRequest(socket) }.start() val socket = serverSocket!!.accept()
} catch (e: Throwable) { launch(Dispatchers.IO) {
Logger.xposedLog(e) 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) val key = System.nanoTime().toString(16)
cachedData[key] = inputStream cachedData[key] = inputStream to size
return "http://127.0.0.1:$port/$key" return "http://127.0.0.1:$port/$key"
} }
@ -96,14 +110,11 @@ class DownloadServer {
with(writer) { with(writer) {
println("HTTP/1.1 200 OK") println("HTTP/1.1 200 OK")
println("Content-type: " + "application/octet-stream") println("Content-type: " + "application/octet-stream")
println("Content-length: " + requestedData.second)
println() println()
flush() flush()
} }
val buffer = ByteArray(1024) requestedData.first.copyTo(outputStream)
var bytesRead: Int
while (requestedData.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
outputStream.flush() outputStream.flush()
cachedData.remove(fileRequested) cachedData.remove(fileRequested)
close() close()

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical" android:orientation="vertical"
android:background="@color/primaryBackground" android:background="@color/primaryBackground"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

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