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"
}
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"
}

View File

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

View File

@ -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()
}
}

View File

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

View File

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

View File

@ -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()
}
})
}
}

View File

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

View File

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

View File

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

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>