Support scoped storage

This commit is contained in:
vvb2060 2020-08-21 22:36:59 +08:00 committed by John Wu
parent 1ed67eed35
commit 9e81db8692
18 changed files with 248 additions and 166 deletions

View File

@ -4,7 +4,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application <application
android:label="Magisk Manager" android:label="Magisk Manager"

View File

@ -1,6 +1,7 @@
package com.topjohnwu.magisk.arch package com.topjohnwu.magisk.arch
import android.Manifest import android.Manifest
import android.os.Build
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.databinding.Bindable import androidx.databinding.Bindable
@ -86,6 +87,10 @@ abstract class BaseViewModel(
} }
fun withExternalRW(callback: () -> Unit) { fun withExternalRW(callback: () -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
callback()
return
}
withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) { withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) {
if (!it) { if (!it) {
SnackbarEvent(R.string.external_rw_permission_denied).publish() SnackbarEvent(R.string.external_rw_permission_denied).publish()

View File

@ -2,7 +2,6 @@ package com.topjohnwu.magisk.core
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Environment
import android.util.Xml import android.util.Xml
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit import androidx.core.content.edit
@ -17,7 +16,6 @@ import com.topjohnwu.magisk.di.Protected
import com.topjohnwu.magisk.ktx.get import com.topjohnwu.magisk.ktx.get
import com.topjohnwu.magisk.ktx.inject import com.topjohnwu.magisk.ktx.inject
import com.topjohnwu.magisk.ui.theme.Theme import com.topjohnwu.magisk.ui.theme.Theme
import com.topjohnwu.magisk.utils.Utils
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.io.SuFile import com.topjohnwu.superuser.io.SuFile
import com.topjohnwu.superuser.io.SuFileInputStream import com.topjohnwu.superuser.io.SuFileInputStream
@ -111,7 +109,7 @@ object Config : PreferenceModel, DBConfig {
var bootId by preference(Key.BOOT_ID, "") var bootId by preference(Key.BOOT_ID, "")
var askedHome by preference(Key.ASKED_HOME, false) var askedHome by preference(Key.ASKED_HOME, false)
var downloadPath by preference(Key.DOWNLOAD_PATH, Environment.DIRECTORY_DOWNLOADS) var downloadPath by preference(Key.DOWNLOAD_PATH, "Magisk Manager")
var repoOrder by preference(Key.REPO_ORDER, Value.ORDER_DATE) var repoOrder by preference(Key.REPO_ORDER, Value.ORDER_DATE)
var suDefaultTimeout by preferenceStrInt(Key.SU_REQUEST_TIMEOUT, 10) var suDefaultTimeout by preferenceStrInt(Key.SU_REQUEST_TIMEOUT, 10)
@ -143,10 +141,6 @@ object Config : PreferenceModel, DBConfig {
var suManager by dbStrings(Key.SU_MANAGER, "", true) var suManager by dbStrings(Key.SU_MANAGER, "", true)
var keyStoreRaw by dbStrings(Key.KEYSTORE, "", true) var keyStoreRaw by dbStrings(Key.KEYSTORE, "", true)
// Always return a path in external storage where we can write
val downloadDirectory get() =
Utils.ensureDownloadPath(downloadPath) ?: get<Context>().getExternalFilesDir(null)!!
private const val SU_FINGERPRINT = "su_fingerprint" private const val SU_FINGERPRINT = "su_fingerprint"
fun initialize() { fun initialize() {

View File

@ -10,8 +10,9 @@ import com.topjohnwu.magisk.core.ForegroundTracker
import com.topjohnwu.magisk.core.base.BaseService import com.topjohnwu.magisk.core.base.BaseService
import com.topjohnwu.magisk.core.utils.ProgressInputStream import com.topjohnwu.magisk.core.utils.ProgressInputStream
import com.topjohnwu.magisk.data.network.GithubRawServices import com.topjohnwu.magisk.data.network.GithubRawServices
import com.topjohnwu.magisk.ktx.checkSum
import com.topjohnwu.magisk.ktx.writeTo import com.topjohnwu.magisk.ktx.writeTo
import com.topjohnwu.magisk.utils.MediaStoreUtils.checkSum
import com.topjohnwu.magisk.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.view.Notifications import com.topjohnwu.magisk.view.Notifications
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -68,14 +69,14 @@ abstract class BaseDownloadService : BaseService(), KoinComponent {
// -- Download logic // -- Download logic
private suspend fun Subject.startDownload() { private suspend fun Subject.startDownload() {
val skip = this is Subject.Magisk && file.exists() && file.checkSum("MD5", magisk.md5) val skip = this is Subject.Magisk && file.checkSum("MD5", magisk.md5)
if (!skip) { if (!skip) {
val stream = service.fetchFile(url).toProgressStream(this) val stream = service.fetchFile(url).toProgressStream(this)
when (this) { when (this) {
is Subject.Module -> // Download and process on-the-fly is Subject.Module -> // Download and process on-the-fly
stream.toModule(file, service.fetchInstaller().byteStream()) stream.toModule(file, service.fetchInstaller().byteStream())
else -> else ->
stream.writeTo(file) stream.copyTo(file.outputStream())
} }
} }
val newId = notifyFinish(this) val newId = notifyFinish(this)

View File

@ -5,7 +5,9 @@ import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.net.toFile
import com.topjohnwu.magisk.core.download.Action.* import com.topjohnwu.magisk.core.download.Action.*
import com.topjohnwu.magisk.core.download.Action.Flash.Secondary import com.topjohnwu.magisk.core.download.Action.Flash.Secondary
import com.topjohnwu.magisk.core.download.Subject.* import com.topjohnwu.magisk.core.download.Subject.*
@ -73,7 +75,7 @@ open class DownloadService : BaseDownloadService() {
private fun Notification.Builder.setIntent(subject: Manager) private fun Notification.Builder.setIntent(subject: Manager)
= when (subject.action) { = when (subject.action) {
APK.Upgrade -> setContentIntent(APKInstall.installIntent(context, subject.file)) APK.Upgrade -> setContentIntent(APKInstall.installIntent(context, subject.file.toFile()))
else -> setContentIntent(Intent()) else -> setContentIntent(Intent())
} }

View File

@ -1,5 +1,6 @@
package com.topjohnwu.magisk.core.download package com.topjohnwu.magisk.core.download
import androidx.core.net.toFile
import com.topjohnwu.magisk.BuildConfig import com.topjohnwu.magisk.BuildConfig
import com.topjohnwu.magisk.DynAPK import com.topjohnwu.magisk.DynAPK
import com.topjohnwu.magisk.ProcessPhoenix import com.topjohnwu.magisk.ProcessPhoenix
@ -61,13 +62,11 @@ private fun DownloadService.restore(apk: File, id: Int) {
.setContentText("") .setContentText("")
} }
Config.export() Config.export()
// Make it world readable
apk.setReadable(true, false)
Shell.su("pm install $apk && pm uninstall $packageName").exec() Shell.su("pm install $apk && pm uninstall $packageName").exec()
} }
suspend fun DownloadService.handleAPK(subject: Subject.Manager) = suspend fun DownloadService.handleAPK(subject: Subject.Manager) =
when (subject.action) { when (subject.action) {
is Upgrade -> upgrade(subject.file, subject.notifyID()) is Upgrade -> upgrade(subject.file.toFile(), subject.notifyID())
is Restore -> restore(subject.file, subject.notifyID()) is Restore -> restore(subject.file.toFile(), subject.notifyID())
} }

View File

@ -1,13 +1,14 @@
package com.topjohnwu.magisk.core.download package com.topjohnwu.magisk.core.download
import android.net.Uri
import com.topjohnwu.magisk.ktx.withStreams import com.topjohnwu.magisk.ktx.withStreams
import java.io.File import com.topjohnwu.magisk.utils.MediaStoreUtils.outputStream
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
fun InputStream.toModule(file: File, installer: InputStream) { fun InputStream.toModule(file: Uri, installer: InputStream) {
val input = ZipInputStream(buffered()) val input = ZipInputStream(buffered())
val output = ZipOutputStream(file.outputStream().buffered()) val output = ZipOutputStream(file.outputStream().buffered())

View File

@ -1,24 +1,25 @@
package com.topjohnwu.magisk.core.download package com.topjohnwu.magisk.core.download
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import com.topjohnwu.magisk.core.Config import androidx.core.net.toUri
import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.model.MagiskJson import com.topjohnwu.magisk.core.model.MagiskJson
import com.topjohnwu.magisk.core.model.ManagerJson import com.topjohnwu.magisk.core.model.ManagerJson
import com.topjohnwu.magisk.core.model.module.Repo import com.topjohnwu.magisk.core.model.module.Repo
import com.topjohnwu.magisk.ktx.cachedFile import com.topjohnwu.magisk.ktx.cachedFile
import com.topjohnwu.magisk.ktx.get import com.topjohnwu.magisk.ktx.get
import com.topjohnwu.magisk.utils.MediaStoreUtils
import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.android.parcel.IgnoredOnParcel
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import java.io.File
sealed class Subject : Parcelable { sealed class Subject : Parcelable {
abstract val url: String abstract val url: String
abstract val file: File abstract val file: Uri
abstract val action: Action abstract val action: Action
open val title: String get() = file.name abstract val title: String
@Parcelize @Parcelize
class Module( class Module(
@ -26,10 +27,11 @@ sealed class Subject : Parcelable {
override val action: Action override val action: Action
) : Subject() { ) : Subject() {
override val url: String get() = module.zipUrl override val url: String get() = module.zipUrl
override val title: String get() = module.downloadFilename
@IgnoredOnParcel @IgnoredOnParcel
override val file by lazy { override val file by lazy {
File(Config.downloadDirectory, module.downloadFilename) MediaStoreUtils.newFile(title).uri
} }
} }
@ -49,7 +51,7 @@ sealed class Subject : Parcelable {
@IgnoredOnParcel @IgnoredOnParcel
override val file by lazy { override val file by lazy {
get<Context>().cachedFile("manager.apk") get<Context>().cachedFile("manager.apk").toUri()
} }
} }
@ -67,7 +69,7 @@ sealed class Subject : Parcelable {
@IgnoredOnParcel @IgnoredOnParcel
override val file by lazy { override val file by lazy {
get<Context>().cachedFile("magisk.zip") get<Context>().cachedFile("magisk.zip").toUri()
} }
} }
@ -75,21 +77,24 @@ sealed class Subject : Parcelable {
private class Uninstall : Magisk() { private class Uninstall : Magisk() {
override val action get() = Action.Uninstall override val action get() = Action.Uninstall
override val url: String get() = Info.remote.uninstaller.link override val url: String get() = Info.remote.uninstaller.link
override val title: String get() = "uninstall.zip"
@IgnoredOnParcel @IgnoredOnParcel
override val file by lazy { override val file by lazy {
get<Context>().cachedFile("uninstall.zip") get<Context>().cachedFile(title).toUri()
} }
} }
@Parcelize @Parcelize
private class Download : Magisk() { private class Download : Magisk() {
override val action get() = Action.Download override val action get() = Action.Download
override val url: String get() = magisk.link override val url: String get() = magisk.link
override val title: String get() = "Magisk-${magisk.version}(${magisk.versionCode}).zip"
@IgnoredOnParcel @IgnoredOnParcel
override val file by lazy { override val file by lazy {
File(Config.downloadDirectory, "Magisk-${magisk.version}(${magisk.versionCode}).zip") MediaStoreUtils.getFile(title).uri
} }
} }

View File

@ -5,8 +5,8 @@ import android.net.Uri
import androidx.core.os.postDelayed import androidx.core.os.postDelayed
import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.utils.unzip import com.topjohnwu.magisk.core.utils.unzip
import com.topjohnwu.magisk.ktx.fileName import com.topjohnwu.magisk.utils.MediaStoreUtils.getDisplayName
import com.topjohnwu.magisk.ktx.readUri import com.topjohnwu.magisk.utils.MediaStoreUtils.inputStream
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.internal.UiThreadHandler import com.topjohnwu.superuser.internal.UiThreadHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -47,7 +47,7 @@ open class FlashZip(
console.add("- Copying zip to temp directory") console.add("- Copying zip to temp directory")
runCatching { runCatching {
context.readUri(mUri).use { input -> mUri.inputStream().use { input ->
tmpFile.outputStream().use { out -> input.copyTo(out) } tmpFile.outputStream().use { out -> input.copyTo(out) }
} }
}.getOrElse { }.getOrElse {
@ -70,7 +70,7 @@ open class FlashZip(
return false return false
} }
console.add("- Installing ${mUri.fileName}") console.add("- Installing ${mUri.getDisplayName()}")
val parentFile = tmpFile.parent ?: return false val parentFile = tmpFile.parent ?: return false

View File

@ -6,18 +6,18 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.net.toUri
import androidx.core.os.postDelayed import androidx.core.os.postDelayed
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.topjohnwu.magisk.R import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.data.network.GithubRawServices import com.topjohnwu.magisk.data.network.GithubRawServices
import com.topjohnwu.magisk.di.Protected import com.topjohnwu.magisk.di.Protected
import com.topjohnwu.magisk.events.dialog.EnvFixDialog import com.topjohnwu.magisk.events.dialog.EnvFixDialog
import com.topjohnwu.magisk.ktx.readUri
import com.topjohnwu.magisk.ktx.reboot import com.topjohnwu.magisk.ktx.reboot
import com.topjohnwu.magisk.ktx.withStreams import com.topjohnwu.magisk.ktx.withStreams
import com.topjohnwu.magisk.utils.MediaStoreUtils
import com.topjohnwu.magisk.utils.MediaStoreUtils.inputStream
import com.topjohnwu.magisk.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.utils.Utils import com.topjohnwu.magisk.utils.Utils
import com.topjohnwu.signing.SignBoot import com.topjohnwu.signing.SignBoot
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
@ -49,7 +49,7 @@ abstract class MagiskInstallImpl : KoinComponent {
protected lateinit var installDir: File protected lateinit var installDir: File
private lateinit var srcBoot: String private lateinit var srcBoot: String
private lateinit var destFile: File private lateinit var destFile: MediaStoreUtils.MediaStoreFile
private lateinit var zipUri: Uri private lateinit var zipUri: Uri
protected val console: MutableList<String> protected val console: MutableList<String>
@ -113,7 +113,7 @@ abstract class MagiskInstallImpl : KoinComponent {
console.add("- Device platform: " + Build.CPU_ABI) console.add("- Device platform: " + Build.CPU_ABI)
try { try {
ZipInputStream(context.readUri(zipUri).buffered()).use { zi -> ZipInputStream(zipUri.inputStream().buffered()).use { zi ->
lateinit var ze: ZipEntry lateinit var ze: ZipEntry
while (zi.nextEntry?.let { ze = it } != null) { while (zi.nextEntry?.let { ze = it } != null) {
if (ze.isDirectory) if (ze.isDirectory)
@ -166,7 +166,7 @@ abstract class MagiskInstallImpl : KoinComponent {
private fun handleTar(input: InputStream) { private fun handleTar(input: InputStream) {
console.add("- Processing tar file") console.add("- Processing tar file")
var vbmeta = false var vbmeta = false
val tarOut = TarOutputStream(destFile) val tarOut = TarOutputStream(destFile.uri.outputStream())
this.tarOut = tarOut this.tarOut = tarOut
TarInputStream(input).use { tarIn -> TarInputStream(input).use { tarIn ->
lateinit var entry: TarEntry lateinit var entry: TarEntry
@ -224,7 +224,7 @@ abstract class MagiskInstallImpl : KoinComponent {
private fun handleFile(uri: Uri): Boolean { private fun handleFile(uri: Uri): Boolean {
try { try {
context.readUri(uri).buffered().use { uri.inputStream().buffered().use {
it.mark(500) it.mark(500)
val magic = ByteArray(5) val magic = ByteArray(5)
if (it.skip(257) != 257L || it.read(magic) != magic.size) { if (it.skip(257) != 257L || it.read(magic) != magic.size) {
@ -233,12 +233,12 @@ abstract class MagiskInstallImpl : KoinComponent {
} }
it.reset() it.reset()
if (magic.contentEquals("ustar".toByteArray())) { if (magic.contentEquals("ustar".toByteArray())) {
destFile = File(Config.downloadDirectory, "magisk_patched.tar") destFile = MediaStoreUtils.newFile("magisk_patched.tar")
handleTar(it) handleTar(it)
} else { } else {
// Raw image // Raw image
srcBoot = File(installDir, "boot.img").path srcBoot = File(installDir, "boot.img").path
destFile = File(Config.downloadDirectory, "magisk_patched.img") destFile = MediaStoreUtils.newFile("magisk_patched.img")
console.add("- Copying image to cache") console.add("- Copying image to cache")
FileOutputStream(srcBoot).use { out -> it.copyTo(out) } FileOutputStream(srcBoot).use { out -> it.copyTo(out) }
} }
@ -324,7 +324,7 @@ abstract class MagiskInstallImpl : KoinComponent {
patched.length())) patched.length()))
tarOut = null tarOut = null
it it
} ?: destFile.outputStream() } ?: destFile.uri.outputStream()
SuFileInputStream(patched).use { it.copyTo(os); os.close() } SuFileInputStream(patched).use { it.copyTo(os); os.close() }
} catch (e: IOException) { } catch (e: IOException) {
console.add("! Failed to output to $destFile") console.add("! Failed to output to $destFile")
@ -344,9 +344,7 @@ abstract class MagiskInstallImpl : KoinComponent {
private suspend fun postOTA(): Boolean { private suspend fun postOTA(): Boolean {
val bootctl = SuFile("/data/adb/bootctl") val bootctl = SuFile("/data/adb/bootctl")
try { try {
withStreams(service.fetchBootctl().byteStream(), SuFileOutputStream(bootctl)) { service.fetchBootctl().byteStream().copyTo(SuFileOutputStream(bootctl))
input, out -> input.copyTo(out)
}
} catch (e: IOException) { } catch (e: IOException) {
console.add("! Unable to download bootctl") console.add("! Unable to download bootctl")
Timber.e(e) Timber.e(e)
@ -374,10 +372,10 @@ abstract class MagiskInstallImpl : KoinComponent {
protected suspend fun secondSlot() = protected suspend fun secondSlot() =
findSecondaryImage() && extractZip() && patchBoot() && flashBoot() && postOTA() findSecondaryImage() && extractZip() && patchBoot() && flashBoot() && postOTA()
protected fun fixEnv(zip: File): Boolean { protected fun fixEnv(zip: Uri): Boolean {
installDir = SuFile("/data/adb/magisk") installDir = SuFile("/data/adb/magisk")
Shell.su("rm -rf /data/adb/magisk/*").exec() Shell.su("rm -rf /data/adb/magisk/*").exec()
zipUri = zip.toUri() zipUri = zip
return extractZip() && Shell.su("fix_env").exec().isSuccess return extractZip() && Shell.su("fix_env").exec().isSuccess
} }
@ -432,7 +430,7 @@ sealed class MagiskInstaller(
} }
class EnvFixTask( class EnvFixTask(
private val zip: File private val zip: Uri
) : MagiskInstallImpl() { ) : MagiskInstallImpl() {
override suspend fun operations() = fixEnv(zip) override suspend fun operations() = fixEnv(zip)

View File

@ -21,7 +21,6 @@ import android.graphics.drawable.LayerDrawable
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION.SDK_INT
import android.provider.OpenableColumns
import android.text.PrecomputedText import android.text.PrecomputedText
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -33,7 +32,6 @@ import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.PrecomputedTextCompat import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isGone import androidx.core.view.isGone
@ -43,7 +41,6 @@ import androidx.fragment.app.Fragment
import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.transition.AutoTransition import androidx.transition.AutoTransition
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.topjohnwu.magisk.FileProvider
import com.topjohnwu.magisk.R import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.ResMgr import com.topjohnwu.magisk.core.ResMgr
@ -56,7 +53,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.lang.reflect.Array as JArray import java.lang.reflect.Array as JArray
val packageName: String get() = get<Context>().packageName val packageName: String get() = get<Context>().packageName
@ -92,23 +88,6 @@ val ApplicationInfo.packageInfo: PackageInfo?
} }
} }
val Uri.fileName: String
get() {
var name: String? = null
get<Context>().contentResolver.query(this, null, null, null, null)?.use { c ->
val nameIndex = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
c.moveToFirst()
name = c.getString(nameIndex)
}
}
if (name == null && path != null) {
val idx = path!!.lastIndexOf('/')
name = path!!.substring(idx + 1)
}
return name.orEmpty()
}
fun PackageManager.activities(packageName: String) = fun PackageManager.activities(packageName: String) =
getPackageInfo(packageName, GET_ACTIVITIES) getPackageInfo(packageName, GET_ACTIVITIES)
@ -123,9 +102,6 @@ fun PackageManager.providers(packageName: String) =
fun Context.rawResource(id: Int) = resources.openRawResource(id) fun Context.rawResource(id: Int) = resources.openRawResource(id)
fun Context.readUri(uri: Uri) =
contentResolver.openInputStream(uri) ?: throw FileNotFoundException()
fun Context.getBitmap(id: Int): Bitmap { fun Context.getBitmap(id: Int): Bitmap {
var drawable = AppCompatResources.getDrawable(this, id)!! var drawable = AppCompatResources.getDrawable(this, id)!!
if (drawable is BitmapDrawable) if (drawable is BitmapDrawable)
@ -249,17 +225,6 @@ fun Intent.toCommand(args: MutableList<String> = mutableListOf()): MutableList<S
return args return args
} }
fun File.provide(context: Context = get()): Uri {
return FileProvider.getUriForFile(context, context.packageName + ".provider", this)
}
fun File.mv(destination: File) {
inputStream().writeTo(destination)
deleteRecursively()
}
fun String.toFile() = File(this)
fun Intent.chooser(title: String = "Pick an app") = Intent.createChooser(this, title) fun Intent.chooser(title: String = "Pick an app") = Intent.createChooser(this, title)
fun Context.cachedFile(name: String) = File(cacheDir, name) fun Context.cachedFile(name: String) = File(cacheDir, name)
@ -329,8 +294,6 @@ fun Context.unwrap(): Context {
return context return context
} }
fun Uri.writeTo(file: File) = toFile().copyTo(file)
fun Context.hasPermissions(vararg permissions: String) = permissions.all { fun Context.hasPermissions(vararg permissions: String) = permissions.all {
ContextCompat.checkSelfPermission(this, it) == PERMISSION_GRANTED ContextCompat.checkSelfPermission(this, it) == PERMISSION_GRANTED
} }

View File

@ -8,12 +8,10 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method import java.lang.reflect.Method
import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import kotlin.experimental.and
fun ZipInputStream.forEach(callback: (ZipEntry) -> Unit) { fun ZipInputStream.forEach(callback: (ZipEntry) -> Unit) {
var entry: ZipEntry? = nextEntry var entry: ZipEntry? = nextEntry
@ -23,25 +21,7 @@ fun ZipInputStream.forEach(callback: (ZipEntry) -> Unit) {
} }
} }
fun InputStream.writeTo(file: File) = fun InputStream.writeTo(file: File) = this.copyTo(file.outputStream())
withStreams(this, file.outputStream()) { reader, writer -> reader.copyTo(writer) }
fun File.checkSum(alg: String, reference: String) = runCatching {
inputStream().use {
val digest = MessageDigest.getInstance(alg)
it.copyTo(object : OutputStream() {
override fun write(b: Int) {
digest.update(b.toByte())
}
override fun write(b: ByteArray, off: Int, len: Int) {
digest.update(b, off, len)
}
})
val sb = StringBuilder()
digest.digest().forEach { b -> sb.append("%02x".format(b and 0xff.toByte())) }
sb.toString() == reference
}
}.getOrElse { false }
inline fun <In : InputStream, Out : OutputStream> withStreams( inline fun <In : InputStream, Out : OutputStream> withStreams(
inStream: In, inStream: In,

View File

@ -6,7 +6,6 @@ import android.content.pm.ActivityInfo
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.core.net.toUri
import androidx.navigation.NavDeepLinkBuilder import androidx.navigation.NavDeepLinkBuilder
import com.topjohnwu.magisk.R import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseUIActivity import com.topjohnwu.magisk.arch.BaseUIActivity
@ -17,7 +16,6 @@ import com.topjohnwu.magisk.databinding.FragmentFlashMd2Binding
import com.topjohnwu.magisk.ui.MainActivity import com.topjohnwu.magisk.ui.MainActivity
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import java.io.File
import com.topjohnwu.magisk.MainDirections.Companion.actionFlashFragment as toFlash import com.topjohnwu.magisk.MainDirections.Companion.actionFlashFragment as toFlash
import com.topjohnwu.magisk.ui.flash.FlashFragmentArgs as args import com.topjohnwu.magisk.ui.flash.FlashFragmentArgs as args
@ -89,29 +87,29 @@ class FlashFragment : BaseUIFragment<FlashViewModel, FragmentFlashMd2Binding>()
/* Flashing is understood as installing / flashing magisk itself */ /* Flashing is understood as installing / flashing magisk itself */
fun flashIntent(context: Context, file: File, isSecondSlot: Boolean, id: Int = -1) = args( fun flashIntent(context: Context, file: Uri, isSecondSlot: Boolean, id: Int = -1) = args(
installer = file.toUri(), installer = file,
action = flashType(isSecondSlot), action = flashType(isSecondSlot),
dismissId = id dismissId = id
).let { createIntent(context, it) } ).let { createIntent(context, it) }
fun flash(file: File, isSecondSlot: Boolean, id: Int) = toFlash( fun flash(file: Uri, isSecondSlot: Boolean, id: Int) = toFlash(
installer = file.toUri(), installer = file,
action = flashType(isSecondSlot), action = flashType(isSecondSlot),
dismissId = id dismissId = id
).let { BaseUIActivity.postDirections(it) } ).let { BaseUIActivity.postDirections(it) }
/* Patching is understood as injecting img files with magisk */ /* Patching is understood as injecting img files with magisk */
fun patchIntent(context: Context, file: File, uri: Uri, id: Int = -1) = args( fun patchIntent(context: Context, file: Uri, uri: Uri, id: Int = -1) = args(
installer = file.toUri(), installer = file,
action = Const.Value.PATCH_FILE, action = Const.Value.PATCH_FILE,
additionalData = uri, additionalData = uri,
dismissId = id dismissId = id
).let { createIntent(context, it) } ).let { createIntent(context, it) }
fun patch(file: File, uri: Uri, id: Int) = toFlash( fun patch(file: Uri, uri: Uri, id: Int) = toFlash(
installer = file.toUri(), installer = file,
action = Const.Value.PATCH_FILE, action = Const.Value.PATCH_FILE,
additionalData = uri, additionalData = uri,
dismissId = id dismissId = id
@ -119,28 +117,28 @@ class FlashFragment : BaseUIFragment<FlashViewModel, FragmentFlashMd2Binding>()
/* Uninstalling is understood as removing magisk entirely */ /* Uninstalling is understood as removing magisk entirely */
fun uninstallIntent(context: Context, file: File, id: Int = -1) = args( fun uninstallIntent(context: Context, file: Uri, id: Int = -1) = args(
installer = file.toUri(), installer = file,
action = Const.Value.UNINSTALL, action = Const.Value.UNINSTALL,
dismissId = id dismissId = id
).let { createIntent(context, it) } ).let { createIntent(context, it) }
fun uninstall(file: File, id: Int) = toFlash( fun uninstall(file: Uri, id: Int) = toFlash(
installer = file.toUri(), installer = file,
action = Const.Value.UNINSTALL, action = Const.Value.UNINSTALL,
dismissId = id dismissId = id
).let { BaseUIActivity.postDirections(it) } ).let { BaseUIActivity.postDirections(it) }
/* Installing is understood as flashing modules / zips */ /* Installing is understood as flashing modules / zips */
fun installIntent(context: Context, file: File, id: Int = -1) = args( fun installIntent(context: Context, file: Uri, id: Int = -1) = args(
installer = file.toUri(), installer = file,
action = Const.Value.FLASH_ZIP, action = Const.Value.FLASH_ZIP,
dismissId = id dismissId = id
).let { createIntent(context, it) } ).let { createIntent(context, it) }
fun install(file: File, id: Int) = toFlash( fun install(file: Uri, id: Int) = toFlash(
installer = file.toUri(), installer = file,
action = Const.Value.FLASH_ZIP, action = Const.Value.FLASH_ZIP,
dismissId = id dismissId = id
).let { BaseUIActivity.postDirections(it) } ).let { BaseUIActivity.postDirections(it) }

View File

@ -10,13 +10,14 @@ import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.arch.diffListOf import com.topjohnwu.magisk.arch.diffListOf
import com.topjohnwu.magisk.arch.itemBindingOf import com.topjohnwu.magisk.arch.itemBindingOf
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.tasks.FlashZip import com.topjohnwu.magisk.core.tasks.FlashZip
import com.topjohnwu.magisk.core.tasks.MagiskInstaller import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.magisk.databinding.RvBindingAdapter import com.topjohnwu.magisk.databinding.RvBindingAdapter
import com.topjohnwu.magisk.events.SnackbarEvent import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.ktx.* import com.topjohnwu.magisk.ktx.*
import com.topjohnwu.magisk.utils.MediaStoreUtils
import com.topjohnwu.magisk.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.utils.set import com.topjohnwu.magisk.utils.set
import com.topjohnwu.magisk.view.Notifications import com.topjohnwu.magisk.view.Notifications
import com.topjohnwu.superuser.CallbackList import com.topjohnwu.superuser.CallbackList
@ -24,7 +25,6 @@ import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
class FlashViewModel( class FlashViewModel(
args: FlashFragmentArgs, args: FlashFragmentArgs,
@ -107,17 +107,17 @@ class FlashViewModel(
private fun savePressed() = withExternalRW { private fun savePressed() = withExternalRW {
viewModelScope.launch { viewModelScope.launch {
val name = Const.MAGISK_INSTALL_LOG_FILENAME.format(now.toTime(timeFormatStandard))
val file = File(Config.downloadDirectory, name)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
file.bufferedWriter().use { writer -> val name = Const.MAGISK_INSTALL_LOG_FILENAME.format(now.toTime(timeFormatStandard))
val file = MediaStoreUtils.newFile(name)
file.uri.outputStream().bufferedWriter().use { writer ->
logItems.forEach { logItems.forEach {
writer.write(it) writer.write(it)
writer.newLine() writer.newLine()
} }
} }
SnackbarEvent(file.toString()).publish()
} }
SnackbarEvent(file.path).publish()
} }
} }

View File

@ -7,19 +7,15 @@ import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.arch.diffListOf import com.topjohnwu.magisk.arch.diffListOf
import com.topjohnwu.magisk.arch.itemBindingOf import com.topjohnwu.magisk.arch.itemBindingOf
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.data.repository.LogRepository import com.topjohnwu.magisk.data.repository.LogRepository
import com.topjohnwu.magisk.events.SnackbarEvent import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.utils.MediaStoreUtils
import com.topjohnwu.magisk.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.utils.set import com.topjohnwu.magisk.utils.set
import com.topjohnwu.magisk.view.TextItem import com.topjohnwu.magisk.view.TextItem
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.util.* import java.util.*
class LogViewModel( class LogViewModel(
@ -40,7 +36,7 @@ class LogViewModel(
// --- magisk log // --- magisk log
@get:Bindable @get:Bindable
var consoleText= " " var consoleText = " "
set(value) = set(value, field, { field = it }, BR.consoleText) set(value) = set(value, field, { field = it }, BR.consoleText)
override fun refresh() = viewModelScope.launch { override fun refresh() = viewModelScope.launch {
@ -57,23 +53,18 @@ class LogViewModel(
} }
fun saveMagiskLog() = withExternalRW { fun saveMagiskLog() = withExternalRW {
val now = Calendar.getInstance() viewModelScope.launch {
val filename = "magisk_log_%04d%02d%02d_%02d%02d%02d.log".format( withContext(Dispatchers.IO) {
now.get(Calendar.YEAR), now.get(Calendar.MONTH) + 1, val now = Calendar.getInstance()
now.get(Calendar.DAY_OF_MONTH), now.get(Calendar.HOUR_OF_DAY), val filename = "magisk_log_%04d%02d%02d_%02d%02d%02d.log".format(
now.get(Calendar.MINUTE), now.get(Calendar.SECOND) now.get(Calendar.YEAR), now.get(Calendar.MONTH) + 1,
) now.get(Calendar.DAY_OF_MONTH), now.get(Calendar.HOUR_OF_DAY),
now.get(Calendar.MINUTE), now.get(Calendar.SECOND)
val logFile = File(Config.downloadDirectory, filename) )
try { val logFile = MediaStoreUtils.newFile(filename)
logFile.createNewFile() logFile.uri.outputStream().writer().use { it.write(consoleText) }
} catch (e: IOException) { SnackbarEvent(logFile.toString()).publish()
Timber.e(e) }
return@withExternalRW
}
Shell.su("cat ${Const.MAGISK_LOG} > $logFile").submit {
SnackbarEvent(logFile.path).publish()
} }
} }

View File

@ -2,7 +2,6 @@ package com.topjohnwu.magisk.ui.settings
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Environment
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.databinding.Bindable import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR import com.topjohnwu.magisk.BR
@ -19,13 +18,13 @@ import com.topjohnwu.magisk.databinding.DialogSettingsAppNameBinding
import com.topjohnwu.magisk.databinding.DialogSettingsDownloadPathBinding import com.topjohnwu.magisk.databinding.DialogSettingsDownloadPathBinding
import com.topjohnwu.magisk.databinding.DialogSettingsUpdateChannelBinding import com.topjohnwu.magisk.databinding.DialogSettingsUpdateChannelBinding
import com.topjohnwu.magisk.ktx.get import com.topjohnwu.magisk.ktx.get
import com.topjohnwu.magisk.utils.MediaStoreUtils
import com.topjohnwu.magisk.utils.Utils import com.topjohnwu.magisk.utils.Utils
import com.topjohnwu.magisk.utils.asTransitive import com.topjohnwu.magisk.utils.asTransitive
import com.topjohnwu.magisk.utils.set import com.topjohnwu.magisk.utils.set
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
// --- Customization // --- Customization
@ -115,8 +114,7 @@ object DownloadPath : BaseSettingsItem.Input() {
override val title = R.string.settings_download_path_title.asTransitive() override val title = R.string.settings_download_path_title.asTransitive()
override val description get() = path.asTransitive() override val description get() = path.asTransitive()
override val inputResult: String? override val inputResult: String get() = result
get() = if (Utils.ensureDownloadPath(result) != null) result else null
@get:Bindable @get:Bindable
var result = value var result = value
@ -124,7 +122,7 @@ object DownloadPath : BaseSettingsItem.Input() {
@get:Bindable @get:Bindable
val path val path
get() = File(Environment.getExternalStorageDirectory(), result).absolutePath.orEmpty() get() = MediaStoreUtils.relativePath(result)
override fun getView(context: Context) = DialogSettingsDownloadPathBinding override fun getView(context: Context) = DialogSettingsDownloadPathBinding
.inflate(LayoutInflater.from(context)).also { it.data = this }.root .inflate(LayoutInflater.from(context)).also { it.data = this }.root

View File

@ -0,0 +1,153 @@
package com.topjohnwu.magisk.utils
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.ktx.get
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
import java.security.MessageDigest
import kotlin.experimental.and
@Suppress("DEPRECATION")
object MediaStoreUtils {
private val cr: ContentResolver by lazy { get<Context>().contentResolver }
@SuppressLint("InlinedApi")
private val tableUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Downloads.EXTERNAL_CONTENT_URI
} else {
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
fun relativePath(appName: String): String {
var path = Environment.DIRECTORY_DOWNLOADS
if (appName.isNotEmpty()) {
path += File.separator + appName
}
return path
}
@Throws(IOException::class)
private fun insertFile(displayName: String): MediaStoreFile {
val values = ContentValues()
val relativePath = relativePath(Config.downloadPath)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
} else {
val parent = File(Environment.getExternalStorageDirectory(), relativePath)
values.put(MediaStore.MediaColumns.DATA, File(parent, displayName).path)
parent.mkdirs()
}
values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
// before Android 11, MediaStore can not rename new file when file exists,
// insert will return null. use newFile() instead.
val fileUri = cr.insert(tableUri, values) ?: throw IOException("Can't insert $displayName.")
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA)
cr.query(fileUri, projection, null, null, null)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
if (cursor.moveToFirst()) {
val id = cursor.getLong(idIndex)
val data = cursor.getString(dataColumn)
return MediaStoreFile(id, data)
}
}
throw IOException("Can't insert $displayName.")
}
private fun queryFile(displayName: String): MediaStoreFile? {
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA)
// Before Android 10, we wrote the DISPLAY_NAME field when insert, so it can be used.
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} == ?"
val selectionArgs = arrayOf(displayName)
val sortOrder = "${MediaStore.MediaColumns.DATE_ADDED} DESC"
cr.query(tableUri, projection, selection, selectionArgs, sortOrder)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val data = cursor.getString(dataColumn)
val relativePath = relativePath(Config.downloadPath)
if (data.endsWith(relativePath + File.separator + displayName)) {
return MediaStoreFile(id, data)
}
}
}
return null
}
@Throws(IOException::class)
fun newFile(displayName: String): MediaStoreFile {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
insertFile(displayName)
} else {
queryFile(displayName)?.delete()
insertFile(displayName)
}
}
@Throws(IOException::class)
fun getFile(displayName: String): MediaStoreFile {
return queryFile(displayName) ?: insertFile(displayName)
}
fun Uri.inputStream() = cr.openInputStream(this) ?: throw FileNotFoundException()
fun Uri.outputStream() = cr.openOutputStream(this) ?: throw FileNotFoundException()
fun Uri.getDisplayName(): String {
require(scheme == "content") { "Uri lacks 'content' scheme: $this" }
val projection = arrayOf(OpenableColumns.DISPLAY_NAME)
cr.query(this, projection, null, null, null)?.use { cursor ->
val displayNameColumn = cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst()) {
return cursor.getString(displayNameColumn)
}
}
return this.toString()
}
fun Uri.checkSum(alg: String, reference: String) = runCatching {
this.inputStream().use {
val digest = MessageDigest.getInstance(alg)
it.copyTo(object : OutputStream() {
override fun write(b: Int) {
digest.update(b.toByte())
}
override fun write(b: ByteArray, off: Int, len: Int) {
digest.update(b, off, len)
}
})
val sb = StringBuilder()
digest.digest().forEach { b -> sb.append("%02x".format(b and 0xff.toByte())) }
sb.toString() == reference
}
}.getOrElse { false }
data class MediaStoreFile(val id: Long, private val data: String) {
val uri: Uri = ContentUris.withAppendedId(tableUri, id)
override fun toString() = data
fun delete(): Boolean {
val selection = "${MediaStore.MediaColumns._ID} == ?"
val selectionArgs = arrayOf(id.toString())
return cr.delete(uri, selection, selectionArgs) == 1
}
}
}

View File

@ -3,7 +3,6 @@ package com.topjohnwu.magisk.utils
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.widget.Toast import android.widget.Toast
import com.topjohnwu.magisk.R import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Config
@ -11,7 +10,6 @@ import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.ktx.get import com.topjohnwu.magisk.ktx.get
import com.topjohnwu.superuser.internal.UiThreadHandler import com.topjohnwu.superuser.internal.UiThreadHandler
import java.io.File
object Utils { object Utils {
@ -40,10 +38,4 @@ object Utils {
) )
} }
} }
fun ensureDownloadPath(path: String) =
File(Environment.getExternalStorageDirectory(), path).run {
if ((exists() && isDirectory) || mkdirs()) this else null
}
} }