Skip to content

Commit 07c3d52

Browse files
author
UserGreen
committed
fix(mail): make attachment save work below api 29
open with reused an unguarded MediaStore.Downloads copy, and the legacy save path needed a permission never requested at runtime. route both through save_attachment_to_storage, add an app-external files fallback with a FileProvider uri, drop WRITE_EXTERNAL_STORAGE.
1 parent 60ade6b commit 07c3d52

5 files changed

Lines changed: 76 additions & 103 deletions

File tree

app/lint-baseline.xml

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,6 @@
11
<?xml version='1.0' encoding='utf-8'?>
22
<issues format="6" by="lint 8.7.3" type="baseline" client="gradle" dependencies="false" name="AGP (8.7.3)" variant="all" version="8.7.3">
33

4-
<issue id="NewApi" message="Call requires API level 29 (current min is 26): `android.widget.TextView#setTextCursorDrawable`" errorLine1=" textCursorDrawable = cursor_drawable" errorLine2=" ~~~~~~~~~~~~~~~~~~">
5-
<location file="src/main/kotlin/org/astermail/android/ui/compose/compose_screen.kt" line="1331" column="33" />
6-
</issue>
7-
8-
<issue id="NewApi" message="Call requires API level 29 (current min is 26): `android.widget.TextView#setTextSelectHandle`" errorLine1=" setTextSelectHandle(handle_drawable)" errorLine2=" ~~~~~~~~~~~~~~~~~~~">
9-
<location file="src/main/kotlin/org/astermail/android/ui/compose/compose_screen.kt" line="1340" column="33" />
10-
</issue>
11-
12-
<issue id="NewApi" message="Call requires API level 29 (current min is 26): `android.widget.TextView#setTextSelectHandleLeft`" errorLine1=" setTextSelectHandleLeft(handle_drawable)" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
13-
<location file="src/main/kotlin/org/astermail/android/ui/compose/compose_screen.kt" line="1341" column="33" />
14-
</issue>
15-
16-
<issue id="NewApi" message="Call requires API level 29 (current min is 26): `android.widget.TextView#setTextSelectHandleRight`" errorLine1=" setTextSelectHandleRight(handle_drawable)" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
17-
<location file="src/main/kotlin/org/astermail/android/ui/compose/compose_screen.kt" line="1342" column="33" />
18-
</issue>
19-
20-
<issue id="NewApi" message="Field requires API level 29 (current min is 26): `android.provider.MediaStore.Downloads#EXTERNAL_CONTENT_URI`" errorLine1=" val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
21-
<location file="src/main/kotlin/org/astermail/android/ui/mail/mail_detail_screen.kt" line="3976" column="70" />
22-
</issue>
23-
24-
<issue id="NewApi" message="Field requires API level 29 (current min is 26): `android.provider.MediaStore.Downloads#EXTERNAL_CONTENT_URI`" errorLine1=" val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)" errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
25-
<location file="src/main/kotlin/org/astermail/android/ui/mail/mail_detail_screen.kt" line="4118" column="90" />
26-
</issue>
27-
284
<issue id="ProduceStateDoesNotAssignValue" message="produceState calls should assign `value` inside the producer lambda" errorLine1=" val view_state = produceState&lt;identity_key_view?&gt;(initialValue = null, deps) {" errorLine2=" ~~~~~~~~~~~~">
295
<location file="src/main/kotlin/org/astermail/android/ui/settings/detail/encryption_screen.kt" line="152" column="22" />
306
</issue>

app/src/main/AndroidManifest.xml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
88
<uses-permission android:name="android.permission.WAKE_LOCK" />
99
<uses-permission android:name="android.permission.READ_CONTACTS" />
10-
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
11-
android:maxSdkVersion="28" />
1210

1311
<application
1412
android:name=".AsterApplication"

app/src/main/kotlin/org/astermail/android/ui/compose/compose_screen.kt

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,24 +1323,28 @@ fun ComposeScreen(
13231323
android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
13241324
imeOptions = EditorInfo.IME_ACTION_NONE or EditorInfo.IME_FLAG_NO_FULLSCREEN
13251325
minHeight = (200 * resources.displayMetrics.density).toInt()
1326-
try {
1327-
val cursor_drawable = android.graphics.drawable.GradientDrawable().apply {
1328-
setSize((2 * resources.displayMetrics.density).toInt(), 0)
1329-
setColor(cursor_color_argb)
1330-
}
1331-
textCursorDrawable = cursor_drawable
1332-
} catch (_: Throwable) {}
1326+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1327+
try {
1328+
val cursor_drawable = android.graphics.drawable.GradientDrawable().apply {
1329+
setSize((2 * resources.displayMetrics.density).toInt(), 0)
1330+
setColor(cursor_color_argb)
1331+
}
1332+
textCursorDrawable = cursor_drawable
1333+
} catch (_: Throwable) {}
1334+
}
13331335
highlightColor = (cursor_color_argb and 0x00FFFFFF) or 0x55000000.toInt()
1334-
try {
1335-
val handle_drawable = android.graphics.drawable.GradientDrawable().apply {
1336-
setColor(cursor_color_argb)
1337-
cornerRadius = 8f * resources.displayMetrics.density
1338-
setSize((20 * resources.displayMetrics.density).toInt(), (20 * resources.displayMetrics.density).toInt())
1339-
}
1340-
setTextSelectHandle(handle_drawable)
1341-
setTextSelectHandleLeft(handle_drawable)
1342-
setTextSelectHandleRight(handle_drawable)
1343-
} catch (_: Throwable) {}
1336+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1337+
try {
1338+
val handle_drawable = android.graphics.drawable.GradientDrawable().apply {
1339+
setColor(cursor_color_argb)
1340+
cornerRadius = 8f * resources.displayMetrics.density
1341+
setSize((20 * resources.displayMetrics.density).toInt(), (20 * resources.displayMetrics.density).toInt())
1342+
}
1343+
setTextSelectHandle(handle_drawable)
1344+
setTextSelectHandleLeft(handle_drawable)
1345+
setTextSelectHandleRight(handle_drawable)
1346+
} catch (_: Throwable) {}
1347+
}
13441348
on_image_received = { uri ->
13451349
if (insert_image_inline(uri)) schedule_draft_save()
13461350
}

app/src/main/kotlin/org/astermail/android/ui/mail/mail_detail_screen.kt

Lines changed: 54 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -914,7 +914,7 @@ fun MailDetailScreen(
914914
toast_message = context.getString(R.string.downloading_file, att.filename)
915915
mail_vm.download_attachment(att) { result ->
916916
result.onSuccess { (resolved_att, bytes) ->
917-
val saved = save_attachment_to_storage(context, resolved_att, bytes)
917+
val saved = save_attachment_to_storage(context, resolved_att, bytes) != null
918918
toast_message = if (saved) context.getString(R.string.saved_file, resolved_att.filename) else context.getString(R.string.failed_to_save)
919919
}.onFailure {
920920
toast_message = context.getString(R.string.failed_to_download, att.filename)
@@ -1125,7 +1125,7 @@ fun MailDetailScreen(
11251125
preview_bytes = null
11261126
},
11271127
on_download = {
1128-
val saved = save_attachment_to_storage(context, att, byt)
1128+
val saved = save_attachment_to_storage(context, att, byt) != null
11291129
Toast.makeText(
11301130
context,
11311131
if (saved) context.getString(R.string.saved_file, att.filename) else context.getString(R.string.failed_to_save),
@@ -3826,46 +3826,64 @@ private fun save_attachment_to_storage(
38263826
context: android.content.Context,
38273827
attachment: MessageAttachment,
38283828
bytes: ByteArray,
3829-
): Boolean {
3829+
): android.net.Uri? {
38303830
return try {
38313831
val mime = attachment.content_type.ifBlank { "application/octet-stream" }
38323832
val safe_name = sanitize_filename(attachment.filename)
3833-
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
3834-
val values = ContentValues().apply {
3835-
put(MediaStore.Downloads.DISPLAY_NAME, safe_name)
3836-
put(MediaStore.Downloads.MIME_TYPE, mime)
3837-
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
3838-
put(MediaStore.Downloads.IS_PENDING, 1)
3839-
}
3840-
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
3841-
if (uri != null) {
3842-
context.contentResolver.openOutputStream(uri)?.use { out ->
3843-
out.write(bytes)
3844-
out.flush()
3845-
}
3846-
val done = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
3847-
context.contentResolver.update(uri, done, null, null)
3848-
show_download_notification(context, safe_name, uri, mime)
3849-
true
3850-
} else {
3851-
false
3852-
}
3833+
val uri = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
3834+
save_via_media_store(context, safe_name, mime, bytes)
38533835
} else {
3854-
@Suppress("DEPRECATION")
3855-
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
3856-
dir.mkdirs()
3857-
val file = java.io.File(dir, safe_name)
3858-
if (!file.canonicalPath.startsWith(dir.canonicalPath + java.io.File.separator)) {
3859-
return false
3860-
}
3861-
file.writeBytes(bytes)
3862-
val uri = android.net.Uri.fromFile(file)
3836+
save_via_app_external_files(context, safe_name, bytes)
3837+
}
3838+
if (uri != null) {
38633839
show_download_notification(context, safe_name, uri, mime)
3864-
true
38653840
}
3841+
uri
38663842
} catch (_: Throwable) {
3867-
false
3843+
null
3844+
}
3845+
}
3846+
3847+
@androidx.annotation.RequiresApi(android.os.Build.VERSION_CODES.Q)
3848+
private fun save_via_media_store(
3849+
context: android.content.Context,
3850+
safe_name: String,
3851+
mime: String,
3852+
bytes: ByteArray,
3853+
): android.net.Uri? {
3854+
val values = ContentValues().apply {
3855+
put(MediaStore.Downloads.DISPLAY_NAME, safe_name)
3856+
put(MediaStore.Downloads.MIME_TYPE, mime)
3857+
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
3858+
put(MediaStore.Downloads.IS_PENDING, 1)
3859+
}
3860+
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return null
3861+
context.contentResolver.openOutputStream(uri)?.use { out ->
3862+
out.write(bytes)
3863+
out.flush()
3864+
}
3865+
val done = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
3866+
context.contentResolver.update(uri, done, null, null)
3867+
return uri
3868+
}
3869+
3870+
private fun save_via_app_external_files(
3871+
context: android.content.Context,
3872+
safe_name: String,
3873+
bytes: ByteArray,
3874+
): android.net.Uri? {
3875+
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return null
3876+
dir.mkdirs()
3877+
val file = java.io.File(dir, safe_name)
3878+
if (!file.canonicalPath.startsWith(dir.canonicalPath + java.io.File.separator)) {
3879+
return null
38683880
}
3881+
file.writeBytes(bytes)
3882+
return androidx.core.content.FileProvider.getUriForFile(
3883+
context,
3884+
"${context.packageName}.fileprovider",
3885+
file,
3886+
)
38693887
}
38703888

38713889
private fun show_download_notification(
@@ -3967,20 +3985,8 @@ private fun attachment_preview_dialog(
39673985
onClick = {
39683986
try {
39693987
val mime = safe_view_mime(attachment.filename, attachment.content_type)
3970-
val values = ContentValues().apply {
3971-
put(MediaStore.Downloads.DISPLAY_NAME, sanitize_filename(attachment.filename))
3972-
put(MediaStore.Downloads.MIME_TYPE, mime)
3973-
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
3974-
put(MediaStore.Downloads.IS_PENDING, 1)
3975-
}
3976-
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
3988+
val uri = save_attachment_to_storage(context, attachment, bytes)
39773989
if (uri != null) {
3978-
context.contentResolver.openOutputStream(uri)?.use {
3979-
it.write(bytes)
3980-
it.flush()
3981-
}
3982-
val done = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
3983-
context.contentResolver.update(uri, done, null, null)
39843990
val intent = Intent(Intent.ACTION_VIEW).apply {
39853991
setDataAndType(uri, mime)
39863992
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
@@ -4109,20 +4115,8 @@ private fun attachment_preview_dialog(
41094115
.clickable {
41104116
try {
41114117
val mime = attachment.content_type.ifBlank { "application/octet-stream" }
4112-
val values = ContentValues().apply {
4113-
put(MediaStore.Downloads.DISPLAY_NAME, sanitize_filename(attachment.filename))
4114-
put(MediaStore.Downloads.MIME_TYPE, mime)
4115-
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
4116-
put(MediaStore.Downloads.IS_PENDING, 1)
4117-
}
4118-
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
4118+
val uri = save_attachment_to_storage(context, attachment, bytes)
41194119
if (uri != null) {
4120-
context.contentResolver.openOutputStream(uri)?.use {
4121-
it.write(bytes)
4122-
it.flush()
4123-
}
4124-
val done = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
4125-
context.contentResolver.update(uri, done, null, null)
41264120
val intent = Intent(Intent.ACTION_VIEW).apply {
41274121
setDataAndType(uri, mime)
41284122
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<paths>
33
<cache-path name="cache" path="." />
4+
<external-files-path name="downloads" path="Download/" />
45
</paths>

0 commit comments

Comments
 (0)