From 5e4361b119ba7f4c29b100894dacf1384f585def Mon Sep 17 00:00:00 2001 From: Helium314 Date: Tue, 20 Feb 2024 10:33:47 +0100 Subject: [PATCH] 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) --- .../keyboard/latin/common/FileUtils.java | 35 +++- .../keyboard/latin/settings/AboutFragment.kt | 7 +- .../settings/AdvancedSettingsFragment.kt | 166 ++++++++++-------- .../latin/settings/SettingsFragment.java | 2 +- .../keyboard/latin/utils/CustomLayoutUtils.kt | 7 +- .../latin/utils/NewDictionaryAdder.kt | 3 +- 6 files changed, 140 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java b/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java index 71ba4213d..a69d76170 100644 --- a/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java +++ b/app/src/main/java/helium314/keyboard/latin/common/FileUtils.java @@ -6,12 +6,18 @@ package helium314.keyboard.latin.common; +import android.content.Context; +import android.net.Uri; + import java.io.File; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; + +import helium314.keyboard.latin.utils.ExecutorUtils; /** * A simple class to help with removing directories recursively. @@ -47,7 +53,32 @@ public class FileUtils { 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(); if (parentFile == null || (!parentFile.exists() && !parentFile.mkdirs())) { throw new IOException("could not create parent folder"); @@ -57,7 +88,7 @@ public class FileUtils { 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]; int len; while ((len = in.read(buf)) > 0) { diff --git a/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt b/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt index e05d8e0fd..51f9ca982 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/AboutFragment.kt @@ -17,6 +17,7 @@ import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.ExecutorUtils import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SpannableStringUtils @@ -46,8 +47,10 @@ class AboutFragment : SubScreenFragment() { private val logFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = result.data?.data ?: return@registerForActivityResult - activity?.contentResolver?.openOutputStream(uri)?.use { os -> - os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) } + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + activity?.contentResolver?.openOutputStream(uri)?.use { os -> + os.bufferedWriter().use { it.write(Log.getLog().joinToString("\n")) } + } } } diff --git a/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt b/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt index a95f836cf..1512420ff 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/AdvancedSettingsFragment.kt @@ -43,6 +43,7 @@ import helium314.keyboard.latin.settings.SeekBarDialogPreference.ValueProxy import helium314.keyboard.latin.utils.AdditionalSubtypeUtils import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX import helium314.keyboard.latin.utils.DeviceProtectedUtils +import helium314.keyboard.latin.utils.ExecutorUtils import helium314.keyboard.latin.utils.JniUtils import helium314.keyboard.latin.utils.editCustomLayout import helium314.keyboard.latin.utils.getStringResourceOrName @@ -54,6 +55,7 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream +import java.util.concurrent.CountDownLatch import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream @@ -207,12 +209,15 @@ class AdvancedSettingsFragment : SubScreenFragment() { private fun copyLibrary(uri: Uri) { val tmpfile = File(requireContext().filesDir.absolutePath + File.separator + "tmplib") 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) outputStream.use { tmpfile.setReadOnly() // as per recommendations in https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading FileUtils.copyStreamToOtherStream(inputStream, it) } + otherTemporaryFile.delete() val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: "" if (checksum == JniUtils.expectedDefaultChecksum()) { @@ -269,7 +274,7 @@ class AdvancedSettingsFragment : SubScreenFragment() { private fun loadImage(uri: Uri, night: Boolean) { val imageFile = Settings.getCustomBackgroundFile(requireContext(), night) - FileUtils.copyStreamToNewFile(requireContext().contentResolver.openInputStream(uri), imageFile) + FileUtils.copyContentUriToNewFile(uri, requireContext(), imageFile) try { BitmapFactory.decodeFile(imageFile.absolutePath) } catch (_: Exception) { @@ -334,91 +339,110 @@ class AdvancedSettingsFragment : SubScreenFragment() { if (backupFilePatterns.any { path.matches(it) }) protectedFiles.add(file) } - try { - activity?.contentResolver?.openOutputStream(uri)?.use { os -> - // write files to zip - val zipStream = ZipOutputStream(os) - files.forEach { - val fileStream = FileInputStream(it).buffered() - zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, ""))) - fileStream.copyTo(zipStream, 1024) - fileStream.close() + var error: String? = "" + val wait = CountDownLatch(1) + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + try { + activity?.contentResolver?.openOutputStream(uri)?.use { os -> + // write files to zip + val zipStream = ZipOutputStream(os) + files.forEach { + 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() - } - protectedFiles.forEach { - val fileStream = FileInputStream(it).buffered() - zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected"))) - fileStream.copyTo(zipStream, 1024) - fileStream.close() + zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) + settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream) zipStream.closeEntry() + zipStream.close() } - zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) - settingsToJsonStream(sharedPreferences.all, zipStream) - zipStream.closeEntry() - zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) - settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream) - zipStream.closeEntry() - zipStream.close() + } catch (t: Throwable) { + error = t.message + Log.w(TAG, "error during backup", t) + } finally { + wait.countDown() } - } catch (t: Throwable) { + } + wait.await() + if (!error.isNullOrBlank()) { // inform about every error - Log.w(TAG, "error during backup", t) - infoDialog(requireContext(), requireContext().getString(R.string.backup_error, t.message)) + infoDialog(requireContext(), requireContext().getString(R.string.backup_error, error)) } } private fun restore(uri: Uri) { - try { - activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> - ZipInputStream(inputStream).use { zip -> - var entry: ZipEntry? = zip.nextEntry - val filesDir = requireContext().filesDir?.path ?: return - val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(requireContext()).path - Settings.getInstance().stopListener() - while (entry != null) { - if (entry.name.startsWith("unprotected${File.separator}")) { - val adjustedName = entry.name.substringAfter("unprotected${File.separator}") - if (backupFilePatterns.any { adjustedName.matches(it) }) { - val targetFileName = upgradeFileNames(adjustedName) - val file = File(deviceProtectedFilesDir, targetFileName) + var error: String? = "" + val wait = CountDownLatch(1) + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { + try { + activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zip -> + var entry: ZipEntry? = zip.nextEntry + val filesDir = requireContext().filesDir?.path ?: return@execute + val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(requireContext()).path + Settings.getInstance().stopListener() + while (entry != null) { + if (entry.name.startsWith("unprotected${File.separator}")) { + 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) + } 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) }) { - val targetFileName = upgradeFileNames(entry.name) - 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 } - 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 diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java index 7e8c6b633..dc984b85a 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsFragment.java @@ -128,7 +128,7 @@ public final class SettingsFragment extends PreferenceFragmentCompat { if (intent.getResultCode() != Activity.RESULT_OK || intent.getData() == null) return; final Uri uri = intent.getData().getData(); if (uri != null) - saveCrashReport(uri); + ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute(() -> saveCrashReport(uri)); }); private void saveCrashReport(final Uri uri) { diff --git a/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt index b3b367a97..cfa863104 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/CustomLayoutUtils.kt @@ -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.addLocaleKeyTextsToParams import helium314.keyboard.latin.R +import helium314.keyboard.latin.common.FileUtils import helium314.keyboard.latin.settings.Settings import java.io.File 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")) val layoutContent: String try { - val i = context.contentResolver.openInputStream(uri) - layoutContent = i?.use { it.reader().readText() } ?: throw IOException() + val tmpFile = File(context.filesDir.absolutePath + File.separator + "tmpfile") + FileUtils.copyContentUriToNewFile(uri, context, tmpFile) + layoutContent = tmpFile.readText() + tmpFile.delete() } catch (e: IOException) { return infoDialog(context, context.getString(R.string.layout_error, "cannot read layout file")) } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt b/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt index a2fa2370a..c7343c9fa 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/NewDictionaryAdder.kt @@ -30,8 +30,7 @@ class NewDictionaryAdder(private val context: Context, private val onAdded: ((Bo cachedDictionaryFile.delete() try { - val i = context.contentResolver.openInputStream(uri) - FileUtils.copyStreamToNewFile(i, cachedDictionaryFile) + FileUtils.copyContentUriToNewFile(uri, context, cachedDictionaryFile) } catch (e: IOException) { return onDictionaryLoadingError(R.string.dictionary_load_error) }