Repository: florisboard/florisboard
Issue: Error when trying to import big file

<!--
- Describe the bug in a short but concise way.
- If you have a screenshot or screen recording of the bug, link them at
  the end of this issue.
- Please search existing bug reports to avoid creating duplicates.
- Thank you for your help in making FlorisBoard better!
-->

#### Short description
The theme import lets you select any file and when selecting a big (non-JSON) file it crashes. There should probably be a filter for JSON files and an error dialog that tells you when the selected file is not a valid theme JSON.
Edit: I see there already is an error snackbar when it fails to parse the JSON so this is not related to the file type.
Nevertheless I'm not sure if such a filter is even possible with the API you're using

#### Steps to reproduce
1. Go to the theme manager
2. Tab on the plus and then on Import
3. Select a relatively large file
4. See error

#### Environment information
- FlorisBoard Version: 0.3.8
- Install Source: GitHub
- Device: OnePlus 8T
- Android version, ROM: 11, Paranoid Android Ruby Beta 4


```
~~~ 1615989180585.stacktrace ~~~

java.lang.OutOfMemoryError: Failed to allocate a 268501000 byte allocation with 100663296 free bytes and 119MB until OOM, target footprint 243324296, growth limit 268435456
	at java.util.Arrays.copyOf(Arrays.java:3257)
	at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
	at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:596)
	at java.lang.StringBuffer.append(StringBuffer.java:367)
	at java.io.StringWriter.write(StringWriter.java:94)
	at kotlin.io.TextStreamsKt.copyTo(ReadWrite.kt:125)
	at kotlin.io.TextStreamsKt.copyTo$default(ReadWrite.kt:120)
	at kotlin.io.TextStreamsKt.readText(ReadWrite.kt:107)
	at dev.patrickgold.florisboard.ime.extension.ExternalContentUtils$Companion.readTextFromUri-gIAlu-s(ExternalContentUtils.kt:29)
	at dev.patrickgold.florisboard.ime.extension.AssetManager.loadAsset-gIAlu-s(AssetManager.kt:180)
	at dev.patrickgold.florisboard.ime.theme.ThemeManager.loadTheme-IoAF18A(ThemeManager.kt:268)
	at dev.patrickgold.florisboard.settings.ThemeManagerActivity$importTheme$1.onActivityResult(ThemeManagerActivity.kt:73)
	at dev.patrickgold.florisboard.settings.ThemeManagerActivity$importTheme$1.onActivityResult(ThemeManagerActivity.kt:52)
	at androidx.activity.result.ActivityResultRegistry$1.onStateChanged(ActivityResultRegistry.java:148)
	at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354)
	at androidx.lifecycle.LifecycleRegistry.forwardPass(LifecycleRegistry.java:265)
	at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.java:307)
	at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.java:148)
	at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.java:134)
	at androidx.lifecycle.ReportFragment.dispatch(ReportFragment.java:68)
	at androidx.lifecycle.ReportFragment$LifecycleCallbacks.onActivityPostStarted(ReportFragment.java:187)
	at android.app.Activity.dispatchActivityPostStarted(Activity.java:1362)
	at android.app.Activity.performStart(Activity.java:8061)
	at android.app.ActivityThread.handleStartActivity(ActivityThread.java:3458)
	at android.app.servertransaction.TransactionExecutor.performLifecycleSequence(TransactionExecutor.java:221)
	at android.app.servertransaction.TransactionExecutor.cycleToPath(TransactionExecutor.java:201)
	at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:173)
	at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:97)
	at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2067)
	at android.os.Handler.dispatchMessage(Handler.java:106)
	at android.os.Looper.loop(Looper.java:223)
	at android.app.ActivityThread.main(ActivityThread.java:7701)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:952)
```



Changed files:
app/src/main/java/dev/patrickgold/florisboard/settings/ThemeManagerActivity.kt
app/src/main/java/dev/patrickgold/florisboard/ime/theme/ThemeManager.kt
app/src/main/java/dev/patrickgold/florisboard/ime/extension/ExternalContentUtils.kt
app/src/main/java/dev/patrickgold/florisboard/ime/extension/AssetManager.kt

diff --git a/app/src/main/java/dev/patrickgold/florisboard/ime/extension/AssetManager.kt b/app/src/main/java/dev/patrickgold/florisboard/ime/extension/AssetManager.kt
index 20ad8bc3..50a7335d 100644
--- a/app/src/main/java/dev/patrickgold/florisboard/ime/extension/AssetManager.kt
+++ b/app/src/main/java/dev/patrickgold/florisboard/ime/extension/AssetManager.kt
@@ -176,13 +176,13 @@ class AssetManager private constructor(private val applicationContext: Context)
         }
     }
 
-    fun <T : Asset> loadAsset(uri: Uri, assetClass: KClass<T>): Result<T> {
-        val rawJsonData = ExternalContentUtils.readTextFromUri(applicationContext, uri).onFailure {
+    fun <T : Asset> loadAsset(uri: Uri, assetClass: KClass<T>, maxSize: Int): Result<T> {
+        val rawJsonData = ExternalContentUtils.readTextFromUri(applicationContext, uri, maxSize).getOrElse {
             return Result.failure(it)
         }
         return try {
             val adapter = moshi.adapter(assetClass.java)
-            val asset = adapter.fromJson(rawJsonData.getOrNull()!!)
+            val asset = adapter.fromJson(rawJsonData)
             if (asset != null) {
                 Result.success(asset)
             } else {
@@ -193,7 +193,6 @@ class AssetManager private constructor(private val applicationContext: Context)
         }
     }
 
-
     fun loadAssetRaw(ref: AssetRef): Result<String> {
         return when (ref.source) {
             is AssetSource.Assets -> {
diff --git a/app/src/main/java/dev/patrickgold/florisboard/ime/extension/ExternalContentUtils.kt b/app/src/main/java/dev/patrickgold/florisboard/ime/extension/ExternalContentUtils.kt
index 1cb02665..cef19360 100644
--- a/app/src/main/java/dev/patrickgold/florisboard/ime/extension/ExternalContentUtils.kt
+++ b/app/src/main/java/dev/patrickgold/florisboard/ime/extension/ExternalContentUtils.kt
@@ -21,11 +21,16 @@ import android.net.Uri
 
 class ExternalContentUtils private constructor() {
     companion object {
-        fun readTextFromUri(context: Context, uri: Uri): Result<String> {
+        fun readTextFromUri(context: Context, uri: Uri, maxSize: Int): Result<String> {
             val contentResolver = context.contentResolver
                 ?: return Result.failure(NullPointerException("System content resolver not available"))
             val inputStream = contentResolver.openInputStream(uri)
                 ?: return Result.failure(NullPointerException("Cannot open input stream for given uri '$uri'"))
+            val assetFileDescriptor = contentResolver.openAssetFileDescriptor(uri, "r")
+                ?: return Result.failure(NullPointerException("Cannot open asset file descriptor for given uri '$uri'"))
+            if (assetFileDescriptor.length > maxSize) {
+                return Result.failure(Exception("Contents of given uri '$uri' exceeds maximum size of $maxSize bytes!"))
+            }
             val rawText = inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
             return Result.success(rawText)
         }
@@ -36,7 +41,7 @@ class ExternalContentUtils private constructor() {
             // Must use "rwt" mode to ensure destination file length is truncated after writing.
             val outputStream = contentResolver.openOutputStream(uri, "rwt")
                 ?: return Result.failure(NullPointerException("Cannot open output stream for given uri '$uri'"))
-            outputStream.bufferedWriter(Charsets.UTF_8).use { it.flush(); it.write(text) }
+            outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(text) }
             return Result.success(Unit)
         }
     }
diff --git a/app/src/main/java/dev/patrickgold/florisboard/ime/theme/ThemeManager.kt b/app/src/main/java/dev/patrickgold/florisboard/ime/theme/ThemeManager.kt
index 6350c98b..fe316018 100644
--- a/app/src/main/java/dev/patrickgold/florisboard/ime/theme/ThemeManager.kt
+++ b/app/src/main/java/dev/patrickgold/florisboard/ime/theme/ThemeManager.kt
@@ -58,10 +58,10 @@ class ThemeManager private constructor(
         private set
 
     companion object {
-        /**
-         * The static relative path where a theme is located, regardless of the [AssetSource].
-         */
+        /** The static relative path where a theme is located, regardless of the [AssetSource]. */
         const val THEME_PATH_REL: String = "ime/theme"
+        /** Maximum size in bytes a theme file may have when loaded. */
+        const val THEME_MAX_SIZE: Int = 512_000
 
         private var defaultInstance: ThemeManager? = null
 
@@ -94,7 +94,7 @@ class ThemeManager private constructor(
     }
 
     /**
-     * Updates the current theme ref and loads the corresponding theme, as well as notfies all
+     * Updates the current theme ref and loads the corresponding theme, as well as notifies all
      * callback receivers about the new theme.
      */
     fun update() {
@@ -254,25 +254,21 @@ class ThemeManager private constructor(
     }
 
     fun loadTheme(ref: AssetRef): Result<Theme> {
-        assetManager.loadAsset(ref, ThemeJson::class).onSuccess { themeJson ->
-            val theme = themeJson.toTheme()
-            return Result.success(theme)
-        }.onFailure {
+        val themeJson = assetManager.loadAsset(ref, ThemeJson::class).getOrElse {
             Timber.e(it.toString())
             return Result.failure(it)
         }
-        return Result.failure(Exception("Unreachable code"))
+        val theme = themeJson.toTheme()
+        return Result.success(theme)
     }
 
     fun loadTheme(uri: Uri): Result<Theme> {
-        assetManager.loadAsset(uri, ThemeJson::class).onSuccess { themeJson ->
-            val theme = themeJson.toTheme()
-            return Result.success(theme)
-        }.onFailure {
+        val themeJson = assetManager.loadAsset(uri, ThemeJson::class, THEME_MAX_SIZE).getOrElse {
             Timber.e(it.toString())
             return Result.failure(it)
         }
-        return Result.failure(Exception("Unreachable code"))
+        val theme = themeJson.toTheme()
+        return Result.success(theme)
     }
 
     fun writeTheme(ref: AssetRef, theme: Theme): Result<Unit> {
diff --git a/app/src/main/java/dev/patrickgold/florisboard/settings/ThemeManagerActivity.kt b/app/src/main/java/dev/patrickgold/florisboard/settings/ThemeManagerActivity.kt
index c84b9268..afbdd964 100644
--- a/app/src/main/java/dev/patrickgold/florisboard/settings/ThemeManagerActivity.kt
+++ b/app/src/main/java/dev/patrickgold/florisboard/settings/ThemeManagerActivity.kt
@@ -67,7 +67,7 @@ class ThemeManagerActivity : FlorisActivity<ThemeManagerActivityBinding>() {
     }
 
     private val importTheme = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
-        // If uri is null it indicates that the selection activity was cancelled (mostly by pressing the back button,
+        // If uri is null it indicates that the selection activity was cancelled (mostly by pressing the back button),
         // so we don't display an error message here.
         if (uri == null) return@registerForActivityResult
         val toBeImportedTheme = themeManager.loadTheme(uri)