DownloadsFileSaver: encapsulate permission checks

Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
Jason A. Donenfeld 2020-09-16 17:56:07 +02:00
parent eebeece856
commit a9ec828506
6 changed files with 70 additions and 91 deletions

View File

@ -1,5 +1,6 @@
buildscript { buildscript {
ext { ext {
activityVersion = '1.2.0-alpha08'
agpVersion = '4.0.1' agpVersion = '4.0.1'
annotationsVersion = '1.1.0' annotationsVersion = '1.1.0'
appcompatVersion = '1.2.0' appcompatVersion = '1.2.0'
@ -11,7 +12,7 @@ buildscript {
coreKtxVersion = '1.3.1' coreKtxVersion = '1.3.1'
coroutinesVersion = '1.3.9' coroutinesVersion = '1.3.9'
desugarVersion = '1.0.10' desugarVersion = '1.0.10'
fragmentVersion = '1.2.5' fragmentVersion = '1.3.0-alpha08'
jsr305Version = '3.0.2' jsr305Version = '3.0.2'
junitVersion = '4.13' junitVersion = '4.13'
kotlinVersion = '1.4.10' kotlinVersion = '1.4.10'

View File

@ -57,6 +57,7 @@ android {
dependencies { dependencies {
implementation project(":tunnel") implementation project(":tunnel")
implementation "androidx.activity:activity-ktx:$activityVersion"
implementation "androidx.annotation:annotation:$annotationsVersion" implementation "androidx.annotation:annotation:$annotationsVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"

View File

@ -60,7 +60,6 @@ import java.util.regex.Matcher
import java.util.regex.Pattern import java.util.regex.Pattern
class LogViewerActivity : AppCompatActivity() { class LogViewerActivity : AppCompatActivity() {
private lateinit var binding: LogViewerActivityBinding private lateinit var binding: LogViewerActivityBinding
private lateinit var logAdapter: LogEntryAdapter private lateinit var logAdapter: LogEntryAdapter
private var logLines = arrayListOf<LogLine>() private var logLines = arrayListOf<LogLine>()
@ -161,15 +160,15 @@ class LogViewerActivity : AppCompatActivity() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
outputFile = DownloadsFileSaver.save(this@LogViewerActivity, "wireguard-log.txt", "text/plain", true) outputFile = DownloadsFileSaver.save(this@LogViewerActivity, "wireguard-log.txt", "text/plain", true)
outputFile?.outputStream.use { outputFile?.outputStream?.write(rawLogLines.toString().toByteArray(Charsets.UTF_8))
it?.write(rawLogLines.toString().toByteArray(Charsets.UTF_8))
}
} catch (e: Throwable) { } catch (e: Throwable) {
outputFile?.delete() outputFile?.delete()
exception = e exception = e
} }
} }
saveButton?.isEnabled = true saveButton?.isEnabled = true
if (outputFile == null)
return
Snackbar.make(findViewById(android.R.id.content), Snackbar.make(findViewById(android.R.id.content),
if (exception == null) getString(R.string.log_export_success, outputFile?.fileName) if (exception == null) getString(R.string.log_export_success, outputFile?.fileName)
else getString(R.string.log_export_error, ErrorMessages[exception]), else getString(R.string.log_export_error, ErrorMessages[exception]),

View File

@ -5,13 +5,9 @@
package com.wireguard.android.activity package com.wireguard.android.activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.SparseArray
import android.view.MenuItem import android.view.MenuItem
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
@ -23,35 +19,11 @@ import com.wireguard.android.util.ModuleLoader
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.util.ArrayList
import java.util.Arrays
/** /**
* Interface for changing application-global persistent settings. * Interface for changing application-global persistent settings.
*/ */
class SettingsActivity : ThemeChangeAwareActivity() { class SettingsActivity : ThemeChangeAwareActivity() {
private val permissionRequestCallbacks = SparseArray<(permissions: Array<String>, granted: IntArray) -> Unit>()
private var permissionRequestCounter = 0
fun ensurePermissions(permissions: Array<String>, cb: (permissions: Array<String>, granted: IntArray) -> Unit) {
val needPermissions: MutableList<String> = ArrayList(permissions.size)
permissions.forEach {
if (ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED) {
needPermissions.add(it)
}
}
if (needPermissions.isEmpty()) {
val granted = IntArray(permissions.size)
Arrays.fill(granted, PackageManager.PERMISSION_GRANTED)
cb.invoke(permissions, granted)
return
}
val idx = permissionRequestCounter++
permissionRequestCallbacks.put(idx, cb)
ActivityCompat.requestPermissions(this,
needPermissions.toTypedArray(), idx)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (supportFragmentManager.findFragmentById(android.R.id.content) == null) { if (supportFragmentManager.findFragmentById(android.R.id.content) == null) {
@ -69,16 +41,6 @@ class SettingsActivity : ThemeChangeAwareActivity() {
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>,
grantResults: IntArray) {
val f = permissionRequestCallbacks[requestCode]
if (f != null) {
permissionRequestCallbacks.remove(requestCode)
f.invoke(permissions, grantResults)
}
}
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, key: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, key: String?) {
addPreferencesFromResource(R.xml.preferences) addPreferencesFromResource(R.xml.preferences)

View File

@ -4,9 +4,7 @@
*/ */
package com.wireguard.android.preference package com.wireguard.android.preference
import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log import android.util.Log
import androidx.preference.Preference import androidx.preference.Preference
@ -43,7 +41,13 @@ class ZipExporterPreference(context: Context, attrs: AttributeSet?) : Preference
if (configs.isEmpty()) { if (configs.isEmpty()) {
throw IllegalArgumentException(context.getString(R.string.no_tunnels_error)) throw IllegalArgumentException(context.getString(R.string.no_tunnels_error))
} }
val outputFile = DownloadsFileSaver.save(context, "wireguard-export.zip", "application/zip", true) val outputFile = DownloadsFileSaver.save(activity, "wireguard-export.zip", "application/zip", true)
if (outputFile == null) {
withContext(Dispatchers.Main.immediate) {
isEnabled = true
}
return@withContext null
}
try { try {
ZipOutputStream(outputFile.outputStream).use { zip -> ZipOutputStream(outputFile.outputStream).use { zip ->
for (i in configs.indices) { for (i in configs.indices) {
@ -82,17 +86,8 @@ class ZipExporterPreference(context: Context, attrs: AttributeSet?) : Preference
when (it) { when (it) {
// When we have successful authentication, or when there is no biometric hardware available. // When we have successful authentication, or when there is no biometric hardware available.
is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { is BiometricAuthenticator.Result.Success, is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
if (DownloadsFileSaver.needsWriteExternalStoragePermission) { isEnabled = false
activity.ensurePermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> exportZip()
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
isEnabled = false
exportZip()
}
}
} else {
isEnabled = false
exportZip()
}
} }
is BiometricAuthenticator.Result.Failure -> { is BiometricAuthenticator.Result.Failure -> {
Snackbar.make( Snackbar.make(

View File

@ -4,67 +4,88 @@
*/ */
package com.wireguard.android.util package com.wireguard.android.util
import android.Manifest
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns import android.provider.MediaStore.MediaColumns
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.wireguard.android.R import com.wireguard.android.R
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
object DownloadsFileSaver { object DownloadsFileSaver {
val needsWriteExternalStoragePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
@Throws(Exception::class) @Throws(Exception::class)
fun save(context: Context, name: String, mimeType: String?, overwriteExisting: Boolean) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { suspend fun save(context: ComponentActivity, name: String, mimeType: String?, overwriteExisting: Boolean) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentResolver = context.contentResolver withContext(Dispatchers.IO) {
if (overwriteExisting) val contentResolver = context.contentResolver
contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), arrayOf(name)) if (overwriteExisting)
val contentValues = ContentValues() contentResolver.delete(MediaStore.Downloads.EXTERNAL_CONTENT_URI, String.format("%s = ?", MediaColumns.DISPLAY_NAME), arrayOf(name))
contentValues.put(MediaColumns.DISPLAY_NAME, name) val contentValues = ContentValues()
contentValues.put(MediaColumns.MIME_TYPE, mimeType) contentValues.put(MediaColumns.DISPLAY_NAME, name)
val contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) contentValues.put(MediaColumns.MIME_TYPE, mimeType)
?: throw IOException(context.getString(R.string.create_downloads_file_error)) val contentUri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
val contentStream = contentResolver.openOutputStream(contentUri) ?: throw IOException(context.getString(R.string.create_downloads_file_error))
?: throw IOException(context.getString(R.string.create_downloads_file_error)) val contentStream = contentResolver.openOutputStream(contentUri)
@Suppress("DEPRECATION") var cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DATA), null, null, null) ?: throw IOException(context.getString(R.string.create_downloads_file_error))
var path: String? = null @Suppress("DEPRECATION") var cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DATA), null, null, null)
if (cursor != null) { var path: String? = null
try {
if (cursor.moveToFirst())
path = cursor.getString(0)
} finally {
cursor.close()
}
}
if (path == null) {
path = "Download/"
cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DISPLAY_NAME), null, null, null)
if (cursor != null) { if (cursor != null) {
try { try {
if (cursor.moveToFirst()) if (cursor.moveToFirst())
path += cursor.getString(0) path = cursor.getString(0)
} finally { } finally {
cursor.close() cursor.close()
} }
} }
if (path == null) {
path = "Download/"
cursor = contentResolver.query(contentUri, arrayOf(MediaColumns.DISPLAY_NAME), null, null, null)
if (cursor != null) {
try {
if (cursor.moveToFirst())
path += cursor.getString(0)
} finally {
cursor.close()
}
}
}
DownloadsFile(context, contentStream, path, contentUri)
} }
DownloadsFile(context, contentStream, path, contentUri)
} else { } else {
@Suppress("DEPRECATION") val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) withContext(Dispatchers.Main.immediate) {
val file = File(path, name) if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
if (!path.isDirectory && !path.mkdirs()) val futureGrant = CompletableDeferred<Boolean>()
throw IOException(context.getString(R.string.create_output_dir_error)) val activityResult = context.registerForActivityResult(ActivityResultContracts.RequestPermission(), futureGrant::complete)
DownloadsFile(context, FileOutputStream(file), file.absolutePath, null) activityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
val granted = futureGrant.await()
activityResult.unregister()
if (!granted)
return@withContext null
}
@Suppress("DEPRECATION") val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
withContext(Dispatchers.IO) {
val file = File(path, name)
if (!path.isDirectory && !path.mkdirs())
throw IOException(context.getString(R.string.create_output_dir_error))
DownloadsFile(context, FileOutputStream(file), file.absolutePath, null)
}
}
} }
class DownloadsFile(private val context: Context, val outputStream: OutputStream, val fileName: String, private val uri: Uri?) { class DownloadsFile(private val context: Context, val outputStream: OutputStream, val fileName: String, private val uri: Uri?) {
fun delete() { suspend fun delete() = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
context.contentResolver.delete(uri!!, null, null) context.contentResolver.delete(uri!!, null, null)
else else