Skip to content

Forms: Microapp optimizations #239

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Dec 19, 2023
2 changes: 0 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ androidxMaterialIcons = "1.4.3"
androidxTestExt = "1.1.2"
androidxViewmodelCompose = "2.6.1"
androidxWindow = "1.2.0"
coil = "2.4.0"
compileSdk = "34"
compose-material3 = "1.1.0"
compose-navigation = "2.7.5"
Expand Down Expand Up @@ -50,7 +49,6 @@ androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.r
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" }
androidx-window-core = { group = "androidx.window", name = "window-core", version.ref = "androidxWindow" }
androidx-window = { group = "androidx.window", name = "window", version.ref = "androidxWindow" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
hilt-android-core = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" }
Expand Down
2 changes: 0 additions & 2 deletions microapps/FeatureFormsApp/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ dependencies {
annotationProcessor(libs.room.compiler)
implementation(libs.room.ext)
kapt(libs.room.compiler)
// coil
implementation(libs.coil.compose)
// jetpack window manager
implementation(libs.androidx.window)
implementation(libs.androidx.window.core)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.arcgismaps.toolkit.featureformsapp.data

import android.graphics.Bitmap
import android.util.Log
import com.arcgismaps.LoadStatus
import com.arcgismaps.mapping.PortalItem
import com.arcgismaps.portal.Portal
import com.arcgismaps.toolkit.featureformsapp.data.local.ItemCacheDao
Expand All @@ -14,16 +14,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream

data class PortalItemData(
val portalItem: PortalItem,
val thumbnailUri: String
)

/**
* A repository to map the data source items into loaded PortalItems. This is the primary repository
Expand All @@ -43,38 +38,38 @@ class PortalItemRepository(
private val mutex = Mutex()

@OptIn(ExperimentalCoroutinesApi::class)
private val portalItemsFlow: Flow<List<PortalItemData>> =
private val portalItemsFlow: Flow<List<PortalItem>> =
itemCacheDao.observeAll().mapLatest { entries ->
// map the cache entries into loaded portal items
entries.mapNotNull { entry ->
val portal = Portal(entry.portalUrl)
val portalItem = PortalItem.fromJsonOrNull(entry.json, portal)
portalItem?.let {
portalItems[portalItem.itemId] = portalItem
PortalItemData(portalItem, entry.thumbnailUri)
portalItem.also {
portalItems[portalItem.itemId] = it
}
}
}
}.flowOn(dispatcher)

/**
* Returns the list of loaded PortalItemData as a flow.
*/
fun observe(): Flow<List<PortalItemData>> = portalItemsFlow
fun observe(): Flow<List<PortalItem>> = portalItemsFlow

/**
* Refreshes the underlying data source to fetch the latest content. [forceUpdate] when set to
* true, will clear the existing cache.
* Refreshes the underlying data source to fetch the latest content.
*
* This operation is suspending and will wait until the underlying data source has finished
* AND the repository has finished loading the portal items.
*/
suspend fun refresh(
portalUri: String,
connection: Portal.Connection,
forceUpdate: Boolean = false
connection: Portal.Connection
) = withContext(dispatcher) {
mutex.withLock {
if (forceUpdate) deleteAllCacheEntries()
// delete existing cache items
itemCacheDao.deleteAll()
portalItems.clear()
// get local items
val localItems = getListOfMaps().map { ItemData(it) }
Expand All @@ -85,9 +80,12 @@ class PortalItemRepository(
}
}

suspend fun deleteAll() = withContext(dispatcher) {
/**
* Deletes all the portal items from the local cache storage.
*/
suspend fun deleteAll() = withContext(Dispatchers.IO) {
mutex.withLock {
deleteAllCacheEntries()
itemCacheDao.deleteAll()
portalItems.clear()
}
}
Expand All @@ -102,71 +100,46 @@ class PortalItemRepository(
/**
* Loads the list of [items] into loaded portal items and adds them to the Cache.
*/
private suspend fun loadAndCachePortalItems(items: List<ItemData>) {
val entries = items.mapNotNull { itemData ->
val portalItem = PortalItem(itemData.url)
// ignore if the portal items fails to load
val result = portalItem.load().onFailure {
Log.e("PortalItemRepository", "loadAndCachePortalItems: $it")
private suspend fun loadAndCachePortalItems(items: List<ItemData>) = withContext(dispatcher) {
// create PortalItems from the urls
val portalItems = items.map {
PortalItem(it.url)
}
portalItems.map { item ->
// load each portal item and its thumbnail in a new coroutine
launch {
item.load().onFailure {
Log.e("PortalItemRepository", "loadAndCachePortalItems: $it")
}
item.thumbnail?.load()
}
if (result.isFailure) {
// suspend till all the portal loading jobs are complete
}.joinAll()
// create entries to be inserted into the local cache storage.
val entries = portalItems.mapNotNull { item ->
// ignore if the portal item failed to load
if (item.loadStatus.value is LoadStatus.FailedToLoad) {
null
} else {
val thumbnailUri = portalItem.thumbnail?.let { thumbnail ->
thumbnail.load()
thumbnail.image?.bitmap?.let { bitmap ->
createThumbnail(
portalItem.itemId,
bitmap
)
}
} ?: ""
ItemCacheEntry(
itemData.url,
portalItem.toJson(),
thumbnailUri,
portalItem.portal.url
itemId = item.itemId,
json = item.toJson(),
portalUrl = item.portal.url
)
}
}
// purge existing items and add the updated items
createCacheEntries(entries)
// insert all the items into the local cache storage
insertCacheEntries(entries)
}

/**
* Deletes and inserts the list of [entries] using the [ItemCacheDao].
*/
private suspend fun createCacheEntries(entries: List<ItemCacheEntry>) =
private suspend fun insertCacheEntries(entries: List<ItemCacheEntry>) =
withContext(dispatcher) {
itemCacheDao.deleteAndInsert(entries)
}

/**
* Deletes all entries in the database using the [ItemCacheDao].
*/
private suspend fun deleteAllCacheEntries() =
withContext(Dispatchers.IO) {
itemCacheDao.deleteAll()
val thumbsDir = File("$filesDir/thumbs")
if (thumbsDir.exists()) thumbsDir.deleteRecursively()
}

/**
* Creates a JPEG thumbnail using the [bitmap] with [name] filename in the local files
* directory and returns the absolute path to the file.
*/
private suspend fun createThumbnail(name: String, bitmap: Bitmap): String =
withContext(Dispatchers.IO) {
val thumbsDir = File("$filesDir/thumbs")
if (!thumbsDir.exists()) thumbsDir.mkdirs()
val file = File("${thumbsDir.absolutePath}/${name}.jpg")
file.createNewFile()
FileOutputStream(file).use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it)
}
file.absolutePath
}

operator fun invoke(itemId: String): PortalItem? = portalItems[itemId]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.Flow
data class ItemCacheEntry(
@PrimaryKey val itemId: String,
val json: String,
val thumbnailUri: String,
val portalUrl: String
)

Expand Down Expand Up @@ -83,7 +82,7 @@ interface ItemCacheDao {
/**
* The room database that contains the ItemCacheEntry table.
*/
@Database(entities = [ItemCacheEntry::class], version = 1, exportSchema = false)
@Database(entities = [ItemCacheEntry::class], version = 2, exportSchema = false)
abstract class ItemCacheDatabase : RoomDatabase() {
abstract fun itemCacheDao() : ItemCacheDao
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright 2023 Esri
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.arcgismaps.toolkit.featureformsapp.screens.browse

import android.graphics.drawable.BitmapDrawable
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import com.arcgismaps.portal.LoadableImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
import kotlinx.coroutines.launch

/**
* Loads an image asynchronously using the [ImageLoader].
*/
@Composable
fun AsyncImage(
imageLoader: ImageLoader,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null
) {
val painter = imageLoader.image.value
Image(
painter = painter,
contentDescription = null,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter
)
}

/**
* A model to asynchronously load the image from a [LoadableImage]. Once the loading is complete
* the loaded image is presented via [image] State.
*
* @param loadable the [LoadableImage] to load.
* @param scope the CoroutineScope to run the loading job on.
* @param placeholder the placeholder image to show until the loading is complete.
*/
class ImageLoader(
private val loadable: LoadableImage,
scope: CoroutineScope,
placeholder: Painter,
) {
private val _image: MutableState<Painter> = mutableStateOf(placeholder)
val image: State<Painter> = _image

init {
scope.launch(start = UNDISPATCHED) {
load()
}
}

private suspend fun load() {
loadable.load().onSuccess {
loadable.image?.let {
_image.value = BitmapPainter(it.bitmap.asImageBitmap())
}
}
}
}
Loading