avoid loading uris from content resolver on main thread

because uris might point to network locations, which triggers an exception
main thread is still blocked by loading, this is purely to get around the crash (files are small, and usually stored locally)
This commit is contained in:
Helium314 2024-02-20 10:33:47 +01:00
parent 2dfc824319
commit 5e4361b119
6 changed files with 140 additions and 80 deletions

View file

@ -6,12 +6,18 @@
package helium314.keyboard.latin.common; package helium314.keyboard.latin.common;
import android.content.Context;
import android.net.Uri;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.FilenameFilter; import java.io.FilenameFilter;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.concurrent.CountDownLatch;
import helium314.keyboard.latin.utils.ExecutorUtils;
/** /**
* A simple class to help with removing directories recursively. * A simple class to help with removing directories recursively.
@ -47,7 +53,32 @@ public class FileUtils {
return hasDeletedAllFiles; return hasDeletedAllFiles;
} }
public static void copyStreamToNewFile(InputStream in, File outfile) throws IOException { /**
* copy data to file on different thread to avoid NetworkOnMainThreadException
* still effectively blocking, as we only use small files which are mostly stored locally
*/
public static void copyContentUriToNewFile(final Uri uri, final Context context, final File outfile) throws IOException {
final boolean[] allOk = new boolean[] { true };
final CountDownLatch wait = new CountDownLatch(1);
ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(() -> {
try {
copyStreamToNewFile(context.getContentResolver().openInputStream(uri), outfile);
} catch (IOException e) {
allOk[0] = false;
} finally {
wait.countDown();
}
});
try {
wait.await();
} catch (InterruptedException e) {
allOk[0] = false;
}
if (!allOk[0])
throw new IOException("could not copy from uri");
}
public static void copyStreamToNewFile(final InputStream in, final File outfile) throws IOException {
File parentFile = outfile.getParentFile(); File parentFile = outfile.getParentFile();
if (parentFile == null || (!parentFile.exists() && !parentFile.mkdirs())) { if (parentFile == null || (!parentFile.exists() && !parentFile.mkdirs())) {
throw new IOException("could not create parent folder"); throw new IOException("could not create parent folder");
@ -57,7 +88,7 @@ public class FileUtils {
out.close(); out.close();
} }
public static void copyStreamToOtherStream(InputStream in, OutputStream out) throws IOException { public static void copyStreamToOtherStream(final InputStream in, final OutputStream out) throws IOException {
byte[] buf = new byte[1024]; byte[] buf = new byte[1024];
int len; int len;
while ((len = in.read(buf)) > 0) { while ((len = in.read(buf)) > 0) {

View file

@ -17,6 +17,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.BuildConfig
import helium314.keyboard.latin.R import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.ExecutorUtils
import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.SpannableStringUtils import helium314.keyboard.latin.utils.SpannableStringUtils
@ -46,8 +47,10 @@ class AboutFragment : SubScreenFragment() {
private val logFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val logFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val uri = result.data?.data ?: return@registerForActivityResult val uri = result.data?.data ?: return@registerForActivityResult
activity?.contentResolver?.openOutputStream(uri)?.use { os -> ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) } activity?.contentResolver?.openOutputStream(uri)?.use { os ->
os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) }
}
} }
} }

View file

@ -43,6 +43,7 @@ import helium314.keyboard.latin.settings.SeekBarDialogPreference.ValueProxy
import helium314.keyboard.latin.utils.AdditionalSubtypeUtils import helium314.keyboard.latin.utils.AdditionalSubtypeUtils
import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX
import helium314.keyboard.latin.utils.DeviceProtectedUtils import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.latin.utils.ExecutorUtils
import helium314.keyboard.latin.utils.JniUtils import helium314.keyboard.latin.utils.JniUtils
import helium314.keyboard.latin.utils.editCustomLayout import helium314.keyboard.latin.utils.editCustomLayout
import helium314.keyboard.latin.utils.getStringResourceOrName import helium314.keyboard.latin.utils.getStringResourceOrName
@ -54,6 +55,7 @@ import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.CountDownLatch
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
@ -207,12 +209,15 @@ class AdvancedSettingsFragment : SubScreenFragment() {
private fun copyLibrary(uri: Uri) { private fun copyLibrary(uri: Uri) {
val tmpfile = File(requireContext().filesDir.absolutePath + File.separator + "tmplib") val tmpfile = File(requireContext().filesDir.absolutePath + File.separator + "tmplib")
try { try {
val inputStream = requireContext().contentResolver.openInputStream(uri) val otherTemporaryFile = File(requireContext().filesDir.absolutePath + File.separator + "tmpfile")
FileUtils.copyContentUriToNewFile(uri, requireContext(), otherTemporaryFile)
val inputStream = FileInputStream(otherTemporaryFile)
val outputStream = FileOutputStream(tmpfile) val outputStream = FileOutputStream(tmpfile)
outputStream.use { outputStream.use {
tmpfile.setReadOnly() // as per recommendations in https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading tmpfile.setReadOnly() // as per recommendations in https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading
FileUtils.copyStreamToOtherStream(inputStream, it) FileUtils.copyStreamToOtherStream(inputStream, it)
} }
otherTemporaryFile.delete()
val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: "" val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: ""
if (checksum == JniUtils.expectedDefaultChecksum()) { if (checksum == JniUtils.expectedDefaultChecksum()) {
@ -269,7 +274,7 @@ class AdvancedSettingsFragment : SubScreenFragment() {
private fun loadImage(uri: Uri, night: Boolean) { private fun loadImage(uri: Uri, night: Boolean) {
val imageFile = Settings.getCustomBackgroundFile(requireContext(), night) val imageFile = Settings.getCustomBackgroundFile(requireContext(), night)
FileUtils.copyStreamToNewFile(requireContext().contentResolver.openInputStream(uri), imageFile) FileUtils.copyContentUriToNewFile(uri, requireContext(), imageFile)
try { try {
BitmapFactory.decodeFile(imageFile.absolutePath) BitmapFactory.decodeFile(imageFile.absolutePath)
} catch (_: Exception) { } catch (_: Exception) {
@ -334,91 +339,110 @@ class AdvancedSettingsFragment : SubScreenFragment() {
if (backupFilePatterns.any { path.matches(it) }) if (backupFilePatterns.any { path.matches(it) })
protectedFiles.add(file) protectedFiles.add(file)
} }
try { var error: String? = ""
activity?.contentResolver?.openOutputStream(uri)?.use { os -> val wait = CountDownLatch(1)
// write files to zip ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
val zipStream = ZipOutputStream(os) try {
files.forEach { activity?.contentResolver?.openOutputStream(uri)?.use { os ->
val fileStream = FileInputStream(it).buffered() // write files to zip
zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, ""))) val zipStream = ZipOutputStream(os)
fileStream.copyTo(zipStream, 1024) files.forEach {
fileStream.close() val fileStream = FileInputStream(it).buffered()
zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, "")))
fileStream.copyTo(zipStream, 1024)
fileStream.close()
zipStream.closeEntry()
}
protectedFiles.forEach {
val fileStream = FileInputStream(it).buffered()
zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected")))
fileStream.copyTo(zipStream, 1024)
fileStream.close()
zipStream.closeEntry()
}
zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME))
settingsToJsonStream(sharedPreferences.all, zipStream)
zipStream.closeEntry() zipStream.closeEntry()
} zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME))
protectedFiles.forEach { settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream)
val fileStream = FileInputStream(it).buffered()
zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected")))
fileStream.copyTo(zipStream, 1024)
fileStream.close()
zipStream.closeEntry() zipStream.closeEntry()
zipStream.close()
} }
zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) } catch (t: Throwable) {
settingsToJsonStream(sharedPreferences.all, zipStream) error = t.message
zipStream.closeEntry() Log.w(TAG, "error during backup", t)
zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) } finally {
settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream) wait.countDown()
zipStream.closeEntry()
zipStream.close()
} }
} catch (t: Throwable) { }
wait.await()
if (!error.isNullOrBlank()) {
// inform about every error // inform about every error
Log.w(TAG, "error during backup", t) infoDialog(requireContext(), requireContext().getString(R.string.backup_error, error))
infoDialog(requireContext(), requireContext().getString(R.string.backup_error, t.message))
} }
} }
private fun restore(uri: Uri) { private fun restore(uri: Uri) {
try { var error: String? = ""
activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> val wait = CountDownLatch(1)
ZipInputStream(inputStream).use { zip -> ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
var entry: ZipEntry? = zip.nextEntry try {
val filesDir = requireContext().filesDir?.path ?: return activity?.contentResolver?.openInputStream(uri)?.use { inputStream ->
val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(requireContext()).path ZipInputStream(inputStream).use { zip ->
Settings.getInstance().stopListener() var entry: ZipEntry? = zip.nextEntry
while (entry != null) { val filesDir = requireContext().filesDir?.path ?: return@execute
if (entry.name.startsWith("unprotected${File.separator}")) { val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(requireContext()).path
val adjustedName = entry.name.substringAfter("unprotected${File.separator}") Settings.getInstance().stopListener()
if (backupFilePatterns.any { adjustedName.matches(it) }) { while (entry != null) {
val targetFileName = upgradeFileNames(adjustedName) if (entry.name.startsWith("unprotected${File.separator}")) {
val file = File(deviceProtectedFilesDir, targetFileName) val adjustedName = entry.name.substringAfter("unprotected${File.separator}")
if (backupFilePatterns.any { adjustedName.matches(it) }) {
val targetFileName = upgradeFileNames(adjustedName)
val file = File(deviceProtectedFilesDir, targetFileName)
FileUtils.copyStreamToNewFile(zip, file)
}
} else if (backupFilePatterns.any { entry!!.name.matches(it) }) {
val targetFileName = upgradeFileNames(entry.name)
val file = File(filesDir, targetFileName)
FileUtils.copyStreamToNewFile(zip, file) FileUtils.copyStreamToNewFile(zip, file)
} else if (entry.name == PREFS_FILE_NAME) {
val prefLines = String(zip.readBytes()).split("\n")
sharedPreferences.edit().clear().apply()
readJsonLinesToSettings(prefLines, sharedPreferences)
} else if (entry.name == PROTECTED_PREFS_FILE_NAME) {
val prefLines = String(zip.readBytes()).split("\n")
val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
protectedPrefs.edit().clear().apply()
readJsonLinesToSettings(prefLines, protectedPrefs)
} }
} else if (backupFilePatterns.any { entry!!.name.matches(it) }) { zip.closeEntry()
val targetFileName = upgradeFileNames(entry.name) entry = zip.nextEntry
val file = File(filesDir, targetFileName)
FileUtils.copyStreamToNewFile(zip, file)
} else if (entry.name == PREFS_FILE_NAME) {
val prefLines = String(zip.readBytes()).split("\n")
sharedPreferences.edit().clear().apply()
readJsonLinesToSettings(prefLines, sharedPreferences)
} else if (entry.name == PROTECTED_PREFS_FILE_NAME) {
val prefLines = String(zip.readBytes()).split("\n")
val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
protectedPrefs.edit().clear().apply()
readJsonLinesToSettings(prefLines, protectedPrefs)
} }
zip.closeEntry()
entry = zip.nextEntry
} }
} }
} catch (t: Throwable) {
error = t.message
Log.w(TAG, "error during restore", t)
} finally {
wait.countDown()
} }
} catch (t: Throwable) {
// inform about every error
Log.w(TAG, "error during restore", t)
infoDialog(requireContext(), requireContext().getString(R.string.restore_error, t.message))
} finally {
checkVersionUpgrade(requireContext())
Settings.getInstance().startListener()
val additionalSubtypes = Settings.readPrefAdditionalSubtypes(sharedPreferences, resources);
updateAdditionalSubtypes(AdditionalSubtypeUtils.createAdditionalSubtypesArray(additionalSubtypes));
reloadEnabledSubtypes(requireContext())
val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)
activity?.sendBroadcast(newDictBroadcast)
// reload current prefs screen
preferenceScreen.removeAll()
setupPreferences()
KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext())
} }
wait.await()
if (!error.isNullOrBlank()) {
// inform about every error
infoDialog(requireContext(), requireContext().getString(R.string.restore_error, error))
}
checkVersionUpgrade(requireContext())
Settings.getInstance().startListener()
val additionalSubtypes = Settings.readPrefAdditionalSubtypes(sharedPreferences, resources);
updateAdditionalSubtypes(AdditionalSubtypeUtils.createAdditionalSubtypesArray(additionalSubtypes));
reloadEnabledSubtypes(requireContext())
val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)
activity?.sendBroadcast(newDictBroadcast)
// reload current prefs screen
preferenceScreen.removeAll()
setupPreferences()
KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext())
} }
// todo (later): remove this when new package name has been in use for long enough, this is only for migrating from old openboard name // todo (later): remove this when new package name has been in use for long enough, this is only for migrating from old openboard name

View file

@ -128,7 +128,7 @@ public final class SettingsFragment extends PreferenceFragmentCompat {
if (intent.getResultCode() != Activity.RESULT_OK || intent.getData() == null) return; if (intent.getResultCode() != Activity.RESULT_OK || intent.getData() == null) return;
final Uri uri = intent.getData().getData(); final Uri uri = intent.getData().getData();
if (uri != null) if (uri != null)
saveCrashReport(uri); ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(() -> saveCrashReport(uri));
}); });
private void saveCrashReport(final Uri uri) { private void saveCrashReport(final Uri uri) {

View file

@ -18,6 +18,7 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.POPUP_KEYS_NORMAL
import helium314.keyboard.keyboard.internal.keyboard_parser.SimpleKeyboardParser import helium314.keyboard.keyboard.internal.keyboard_parser.SimpleKeyboardParser
import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams
import helium314.keyboard.latin.R import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.settings.Settings
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -28,8 +29,10 @@ fun loadCustomLayout(uri: Uri?, languageTag: String, context: Context, onAdded:
return infoDialog(context, context.getString(R.string.layout_error, "layout file not found")) return infoDialog(context, context.getString(R.string.layout_error, "layout file not found"))
val layoutContent: String val layoutContent: String
try { try {
val i = context.contentResolver.openInputStream(uri) val tmpFile = File(context.filesDir.absolutePath + File.separator + "tmpfile")
layoutContent = i?.use { it.reader().readText() } ?: throw IOException() FileUtils.copyContentUriToNewFile(uri, context, tmpFile)
layoutContent = tmpFile.readText()
tmpFile.delete()
} catch (e: IOException) { } catch (e: IOException) {
return infoDialog(context, context.getString(R.string.layout_error, "cannot read layout file")) return infoDialog(context, context.getString(R.string.layout_error, "cannot read layout file"))
} }

View file

@ -30,8 +30,7 @@ class NewDictionaryAdder(private val context: Context, private val onAdded: ((Bo
cachedDictionaryFile.delete() cachedDictionaryFile.delete()
try { try {
val i = context.contentResolver.openInputStream(uri) FileUtils.copyContentUriToNewFile(uri, context, cachedDictionaryFile)
FileUtils.copyStreamToNewFile(i, cachedDictionaryFile)
} catch (e: IOException) { } catch (e: IOException) {
return onDictionaryLoadingError(R.string.dictionary_load_error) return onDictionaryLoadingError(R.string.dictionary_load_error)
} }