From 932067888c90f7d5818512b84873eea5858e8e91 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 1 May 2025 09:52:39 -0700 Subject: [PATCH 001/132] Create offline toolkit module & microapp (#835) --- README.md | 2 + microapps/OfflineMapAreasApp/.gitignore | 15 ++ microapps/OfflineMapAreasApp/README.md | 9 + microapps/OfflineMapAreasApp/app/.gitignore | 1 + .../OfflineMapAreasApp/app/build.gradle.kts | 94 +++++++++ .../OfflineMapAreasApp/app/proguard-rules.pro | 21 ++ .../app/src/main/AndroidManifest.xml | 47 +++++ .../offlinemapareasapp/MainActivity.kt | 60 ++++++ .../offlinemapareasapp/screens/MainScreen.kt | 56 ++++++ .../drawable-v24/ic_launcher_foreground.xml | 48 +++++ .../res/drawable/ic_launcher_background.xml | 188 ++++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 24 +++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 24 +++ .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../app/src/main/res/values/colors.xml | 28 +++ .../app/src/main/res/values/strings.xml | 21 ++ .../app/src/main/res/values/themes.xml | 23 +++ .../app/src/main/res/xml/backup_rules.xml | 31 +++ .../main/res/xml/data_extraction_rules.xml | 37 ++++ settings.gradle.kts | 4 + toolkit/offline/.gitignore | 1 + toolkit/offline/README.md | 11 + toolkit/offline/api/offline.api | 11 + toolkit/offline/build.gradle.kts | 95 +++++++++ toolkit/offline/consumer-rules.pro | 0 .../toolkit/offline/OfflineMapAreasTests.kt | 38 ++++ toolkit/offline/src/main/AndroidManifest.xml | 22 ++ .../toolkit/offline/OfflineMapAreas.kt | 41 ++++ 37 files changed, 952 insertions(+) create mode 100644 microapps/OfflineMapAreasApp/.gitignore create mode 100644 microapps/OfflineMapAreasApp/README.md create mode 100644 microapps/OfflineMapAreasApp/app/.gitignore create mode 100644 microapps/OfflineMapAreasApp/app/build.gradle.kts create mode 100644 microapps/OfflineMapAreasApp/app/proguard-rules.pro create mode 100644 microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt create mode 100644 microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/values/colors.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/values/themes.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/xml/backup_rules.xml create mode 100644 microapps/OfflineMapAreasApp/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 toolkit/offline/.gitignore create mode 100644 toolkit/offline/README.md create mode 100644 toolkit/offline/api/offline.api create mode 100644 toolkit/offline/build.gradle.kts create mode 100644 toolkit/offline/consumer-rules.pro create mode 100644 toolkit/offline/src/androidTest/java/com/arcgismaps/toolkit/offline/OfflineMapAreasTests.kt create mode 100644 toolkit/offline/src/main/AndroidManifest.xml create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt diff --git a/README.md b/README.md index abeb00735..91a00182c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The ArcGIS Maps SDK for Kotlin Toolkit contains components that will simplify yo * **[GeoView-Compose](toolkit/geoview-compose)** - Compose wrappers for the MapView and SceneView. * **[Callout](toolkit/geoview-compose#display-a-callout)** - Draws a callout on the GeoView to display Composable content. * **[Legend](toolkit/legend)** - Displays a legend for a map or a scene. +* **[OfflineMapAreas](toolkit/offline)** - Allows you to take a web map offline by downloading map areas. * **[Popup](toolkit/popup)** - View field values of features in a layer using the Popup API. * **[Scalebar](toolkit/scalebar)** - Displays current scale reference. * **[UtilityNetworkTrace](toolkit/utilitynetworks)** - Configure, run, and visualize UtilityNetworkTraces on a composable MapView. @@ -55,6 +56,7 @@ implementation("com.esri:arcgis-maps-kotlin-toolkit-compass") implementation("com.esri:arcgis-maps-kotlin-toolkit-featureforms") implementation("com.esri:arcgis-maps-kotlin-toolkit-geoview-compose") implementation("com.esri:arcgis-maps-kotlin-toolkit-legend") +implementation("com.esri:arcgis-maps-kotlin-toolkit-offline") implementation("com.esri:arcgis-maps-kotlin-toolkit-indoors") implementation("com.esri:arcgis-maps-kotlin-toolkit-popup") implementation("com.esri:arcgis-maps-kotlin-toolkit-scalebar") diff --git a/microapps/OfflineMapAreasApp/.gitignore b/microapps/OfflineMapAreasApp/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/microapps/OfflineMapAreasApp/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/microapps/OfflineMapAreasApp/README.md b/microapps/OfflineMapAreasApp/README.md new file mode 100644 index 000000000..e56580df4 --- /dev/null +++ b/microapps/OfflineMapAreasApp/README.md @@ -0,0 +1,9 @@ +# OfflineMapAreas Micro-app + +This micro-app demonstrates the use of the `OfflineMapAreas` toolkit component to take a web map offline by downloading map areas. + +## Usage + +ToDo… + +For more information on the `OfflineMapAreas` component and how it works, see its [Readme](../../toolkit/offline). diff --git a/microapps/OfflineMapAreasApp/app/.gitignore b/microapps/OfflineMapAreasApp/app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/microapps/OfflineMapAreasApp/app/build.gradle.kts b/microapps/OfflineMapAreasApp/app/build.gradle.kts new file mode 100644 index 000000000..d240c3399 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/build.gradle.kts @@ -0,0 +1,94 @@ +/* + * + * Copyright 2025 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. + * + */ + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") +} + +secrets { + // this file doesn't contain secrets, it just provides defaults which can be committed into git. + defaultPropertiesFileName = "secrets.defaults.properties" +} + +android { + namespace = "com.arcgismaps.toolkit.offlinemapareasapp" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId ="com.arcgismaps.toolkit.offlinemapareasapp" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.compileSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner ="androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + // Avoids an empty test report showing up in the CI integration test report. + // Remove this if tests will be added. + tasks.withType { + enabled = false + } +} + +dependencies { + implementation(project(":geoview-compose")) + implementation(arcgis.mapsSdk) + implementation(project(":offline")) + implementation(project(":microapps-lib")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.composeCore) + implementation(libs.bundles.core) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + testImplementation(libs.bundles.unitTest) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.bundles.composeTest) + debugImplementation(libs.bundles.debug) +} diff --git a/microapps/OfflineMapAreasApp/app/proguard-rules.pro b/microapps/OfflineMapAreasApp/app/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml b/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ca1c93c80 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt new file mode 100644 index 000000000..4a756d654 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/MainActivity.kt @@ -0,0 +1,60 @@ +/* + * + * Copyright 2025 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.offlinemapareasapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.arcgismaps.ApiKey +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.toolkit.offlinemapareasapp.screens.MainScreen +import com.esri.microappslib.theme.MicroAppTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.API_KEY) + setContent { + MicroAppTheme { + OfflineMapAreasApp() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OfflineMapAreasApp() { + Scaffold( + topBar = { TopAppBar(title = { Text("OfflineMapAreasApp") }) } + ) { + Box(Modifier.padding(it)) { + MainScreen() + } + } + +} diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt new file mode 100644 index 000000000..bd21a970e --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt @@ -0,0 +1,56 @@ +/* + * + * Copyright 2025 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.offlinemapareasapp.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.BasemapStyle +import com.arcgismaps.mapping.Viewpoint +import com.arcgismaps.toolkit.geoviewcompose.MapView +import com.arcgismaps.toolkit.offline.OfflineMapAreas + +@Composable +fun MainScreen() { + val arcGISMap by remember { + mutableStateOf( + ArcGISMap(BasemapStyle.ArcGISTopographic).apply { + initialViewpoint = Viewpoint( + latitude = 39.8, + longitude = -98.6, + scale = 10e7 + ) + } + ) + } + Column { + MapView( + modifier = Modifier + .fillMaxSize() + .weight(1f), + arcGISMap = arcGISMap + ) + OfflineMapAreas() + } +} diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/microapps/OfflineMapAreasApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..68c82a860 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_launcher_background.xml b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..9455d6d31 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..96082265f --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..96082265f --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1QO zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j90A<=am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Zp#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64z)@Q*%s3_Xd5>S4dg$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Yk0j&h3e*a5>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*fx+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oKDKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$TevUD5@?*P8)voa?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ9=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 literal 0 HcmV?d00001 diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/microapps/OfflineMapAreasApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbvOO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ zSbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPfidh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4P;c8$Q|KU?Joh zIkA^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zpU5ND^P*RoEkbD5o#az(-g=Y)L>HH>Oc%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=ep!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!isi6vTPLJ4@(|o=%NHYjo0_S&q*UQIROw@*N-By@PaQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjnx zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j#A4mU}enR_!cGmIYQ;qwfchWtFEXL)AK%*;=j znYne+hS4EMy3S)C*mZ1KI>!+)0V@9!N6H$Y}~MJ{rYuf zz^KljIWvFi-?#?V@LPR&c6Nn{!=XM z>}-h$S76;$H{E{Y%@^zlmOl^efBwa%UU+jJD9UVukQ3ti_kH-?H*RC0?M1W%FCvMB zM_+v6fk$6X2sx)-p~B3&Kl{nscK}pNLM*qjtpaf9>AU{-iPKQZR8yCg!TY}Qg*(;) z)gdvCcB%kppZc$VdvsK@)3l1{&DG!d_6OHOS`y=ITLEVu`unSKA2E%JD*DVX{LJ}K z9l>hMRDqxQh0lnpGHpVYneX}eA3Pt|2v%=q;rt)``R|#bDyB)OXY&vI_@|*}h}G?^ z@aZ4_!7cQPX`!fW_?{oT1NTwHs#l5L-0`E|y@48<3Q^HFf8=Idi zpJYD%1MkII!~|7I^WGo)IF=?{>ACnjJ_WUi39C}!Q{QnheVJqeKKqq5^o5CBde(g9 zvw$X6^jz_^E2$wSw4!q5*RG(C2_^XO$HBn_55vbl44OnTTRwRaePP0vo{K)U1#99& z<>rq7V&V(<&@I%MFoN5zrY}sz=(*-L&}1QQ*a%`u25h{cFj===17eB_uGuzG&byQ< zrm8BJZl4r_E$3k|Wo6FW0-6M7>qac5uFQsQcmkLWGfeH74S3Z_rJ!jgN++!@i=HW8 zkyjI(oPH-+-N#Qc^-mpNO`bc6r=2-<%&Wy5K1vfFJB(L_IkpS6fY^NmuL8qsgj>MD zn~BHH9WM~32_3vd=W&B)k7F9q%stJx+b_L_X-4zr^LVUMCmyCTA3sWtkvsmME?Xiy z?xOSfB=_$oY06~J-HcCq&)qcW{j;uP;?Dm}=hkq?zh&n!;m((-G-u_t|6x399Q;>A zgNpxoJNj{u|MFDH7Rhq@FCAl0dE|ddnl!oh9{Lq?@JDoR6L;C941IK`ISfdE$4S zE0AUQ8+2|Ncl_q5QkSp#AODp~(^mfP&%Au@@|TBQwoP`UU+V{6u8|)6ZA{~uKmQ*M zmrMTDU8S~8Eqi{^v0Ug&5Upcm#y7Z1(RbgZAG8jB$eRwCspQ)>5;U)oGZ&E5aeR*K z8Yt`Y0$G))Yd(Y3KH}tA4`-_QmNke5hU_|nq=xtyjwW(_o?itz>B>WM&^63bNdQ)k@-IgDHW*RW$Xo9#RzrTrCn7L2H{9Amq|qNg@#eZY=|P zCoI?2s+L)zsM%WX(NbVEY^`C>lFjIBYmJ6@DKJ0ZT4&F&WHW!dwa%QzOG!?jY_2(S zDcEzZbz*2Q!43|z))9yOP9X1Xt%DXzwY(3tl-TR=Qb_MbZYRrooh;dYYmS!U_as1(=YVB?Q_A|tNu5Ut&_q3jbfDM zoFxT^uEuH`nX3*sB%K?GuHUkweYReBwnHqh3P)~`+s3+Tj!rDA1e)8vuBv5J*IsxC zkd^~b(aGzArj08{>cnzOuy04C+C`}gb|Yz-1avxeWzev3NzcHbz_&4W@QCr$z3~w=8Ua- z`;vfG1~BP8CyLb=F7t1am~ph_#|O%$khSJ9%Vtcn)YmpgQxF?xM^_Vb+5fnpB^W0I`f%X8gb9#X{Q-yJG0{Z56aWeI&zPxnf5pdJA38bM`cYnS#x)% z`n1tFf$i)W-hGm(f9mde^=X@NcV_lFb=P`4&CI&H=IArijGwdCk&X@uQ$5xmj!~^? z#$ROCI)V-~t%L%GS#wo@U27ddR`4`3)WoB{R-4snfNrfee|kI8^bu#yDgYqOwas9# zmcb`3!kRJ`Cr=_tq)8aMt{aGtUZsqwVlj6DgCGre>AEt&x8H_in!x@uwgExIh|-mA zjdaC(29~CTVSaaF7HPbql&*9Uo8P@f)>LqCXclr}peS7_1BQ28u9PO8Eq1@`l3q9o zkfKCaO2?T?ZyA6loW<#9_c^O=m<&h}CA!ineAD@=(gbq`vyT|tiJ6#^B1$P;;qax` z55k&Q?wEh#87niLo*+n4L@65J(Nz~=Ya%7^(miLb(E>A3B@|Jjl;FU&D>o|9#7PJH z?|ago!o;WC^h=|T7PVBg(DAB}72cyUS zb(f>Bwbr!F1eTCO5fpj<{PqhY5>143p?~5ZA5H40);=@M#MYvrB6gqHbU_!GSY??i z%s=>-ciA4*zOOZHds0a(kWewZ4h(k8h(ua7HX)Au&mY~H8KY6(_cb$_&fA@QjIW-*heP3%$d!m5^AdnT}`12qA^c@!g3DOwZ5WwE2?)-yU z!)Vx#Mtxt?FzFTwK!77sy7)sMzUd->w4^bxtpM2j!b1pjgyk zGKwWGeb4)^zjy{9Es&PU1}gwg?|J#L$KJB7ett9@4M%-nGtIQr0>Fl@8-yh`-+1ed zS6r}(MeSvgSoFmH*_WPu@i?}!AB~2?;i&IxrkNg~cQ9Som98tcq)k^|eeER|Zl77t za-TVUc;DNvzVXJ%w52+#weN?+;i#{f#!Oc&z?81*N>^e~ltRS%ZI@lR{rs()HmqG! zx*}ZrI-EZ}ckJMiy>A^oofwDfC~IH)z8{VHKGT@#E5I(Ll&+MnMCl>~AV7+>Gi%mF zkU1QlKASdR0B80!YhP<$Ywi0?W2Ux45oPfxv9QolWzJPD^weBfvo4SONxP35106sAmh(e+vAs0GboFD@PvNs)jNPvarhW}0YliZEg{Gazv z+JDIpoojRVPr<*C|BTq<`6ga{5q^8^!|0cxe=rZ!zxH3%f5ZO0cQ*Z<^$Yt2{|Ek0 zyT|*F+CO@K;(owBKtGg!S^xj-Z~rga2m6nxKl9J=fBSuNKW_dLKWhJKeg^-Xe`^1? z`TyJj)8E!#>_3Y?uKrwqq3LJ#SGU>AzUO|6`nR^u&3FNN_jGOc zw)Nw`wr3yIKhgcee6IaN=ws>M{6677%)hPwx&HzC(f&u~&)6@b2kNRzBDQAP0*H73 zq%McOmRk{B3i47qRe=DA*$&odrbEJZ*pV9XXa&p@wlW~@Yfs>V{yiTtplMhgM*-Bz zsSnlq&pG;z0OUN%$~$3=g1UF+G*>+17eRbBf3=y79J}KR8owon@$1Z7MIrvvWWH)34nK2SD)GsrJ{l z1Cl#oVo3A8qY3e=aF)qzms~FG#2$LzT=gs&aVMOj>(%{y<&O0cG!nCiESl~x=^dF{ zKvj8F1K8Ng171wwM5Fh4KoQw`_c6#y$(5cAm7e}~nJ#A*fx+c9;y#&W!#VukR)ugk zKp3=+;Ut+IYn%m+r4d*<`L2h%aDnX5}^!5R|H;(34AoVWjRx(msBZvk;rCI*|~ zdOijqI@9Z{Vu!~jvHW{lBa$rnl4+!s_5sfK3bCGk-B%iDe&@-}+%fOKU|(9?V1 zHE8&@4z)Kx!RAvAs z!Wic9=o#(bg?kc-G68-m(jZ`^=XGUXb)}t(%&~sjFnV^sEX%hSy6UKC4iOhgV=BHV z2w`4g7Y=s#Vu2B_?#VQ|hP39@eArgfX>-0S+dd&^mx0*wp}>)x;c4RUgxz%;oNe?& z-7-lJ@Y^2^C;=qJsxx5|xF)*pTGhch2B&kxtn;f!7=gznk}I3}Dh}(CoMXgA5-p&kS202!l?!fT3t|HG*rIP~mS* z$Wjo}jq3}z$Qq!9yrtd3fM0N629ZM?LU$nv@Tv9b7I;D|;0H2dsA~g7Z7zp1| zB)XmrkMgF6OQr|R)HHD^TE{Y#j!~SR?b`Xt3Qs`B+x<hxexYeAjMUWdZ-*n9%(1)Wb(n2U<><7&9dwGJmrob)4%H? zlQ%z+L-^$dFhhH|@u$%97Qz?*Ynh2VG@q|?8vY&L74&fs&_b&3$x&Oyjl~LQDRRap zJU4U*R+(2Dd!G+lh8!V{pT_UJn+^1Qg6$` zqkNm(a#hWyc6SP+p5=C4HL8-m`pO`5o~`-LI?_h5CsH?F_%?nDodmz&pWR20WTpJE z?N|wSzLjMUK8E)a2tI}Lf;+;*M|h3Y(U#>)g1>zk9|Hd}oZAa2 zLYBWBoSW!Ts!RwXr^8h+U*@{9{zqS^iH)Op<;r`Uw~nc}<^$V~_i%$GFjaG?X1@E|M`h)nekvFKt`Dh-f>@|0-`Xoq)o` zx;JmzDfOV9qCx|EVpogEe0LK~tGS?5$$L_i6P$P6wIsCQaP_;d{{N=iV@+8LI}o#( zvo*Ejy=IIn{rdIQh1&q-{EuohpVOjJ^Q3lD*YTp37$^RRgn8ihpdu5{Ct%5-KO!VL zcNB6dUajXI9jkm-P|i3~GB-A(X`P1Oqqb$tcku)UJw0w3GeUijb__#QT4j%64z%EeB7S?jlWwx_7&+EEvB|6N=kV}DwnyAlX=?j`) zmU#!$*^@NIu#n_d7;WoJV@*Fbv9|yJO4;n|BNF2xy(54RyB>t~8lUOUW$&2%Nwi1y zx6JxW88>U2$#qhl^6KUbtmg9}D0o5vYDT7kWJthLGkpGnN4T>{St^_EU>4;DmLF9o zr|LqsA8_MoNLQ=}w?8u!ziSZ@PC#Y<#9uJFo-ozVo6D;<8j^1$c|qAE3ZTE5i~zmE z$BU5lw6l=EWsg^y^;8>r9qH{xfL|~PZYK#md$zZ0?o11gV<*WSW~cgy2GYGQir%wf zt4iW8D+;s*;RGrmd(-T<@2&j(Cb9xhV*l-x`TpK`xq|7p?5R%5*s!69?2c!cC*VY* z2DE^9pvOPLU!1e}wA8S8opcTJ3`NB>hY=JQnL~QFXR4K8A$BqJnoEB$wn-%u@E6Mh zCfMF4kusv3N!(aHC}4)Xs^xoOwXd%e^6pi5|DZo=Q25j+6HlJ^7FodH6y1bMROR^q zGu6)fopS`h%Sw<;ZH%TEPf+#81-#_v+@8nlR0jLcIDKQtLleOC)6yLZgC!D9X3GgS zohwU{v$jl=quD#Go^hB{`@Qw*a%`(^jyT~=q^bWgGzRj;|12J55HWdCWV}EB|K=%N z3Nq-qxJJ`>^|1MNN+q}zTB&ooE3j==AgK@^UW<^oSbeALa2peF)Th6{@sj0KyMNHZ zksk1+MXN2tv+22A%cQOGpS9)77(uP9mh+!5T5ERLvF@b}$+WvXM45Z?-kCa)fb~f1 znVbTD$Gx-0Zxc`0D@YgHakge6SL0H`-vN_x?AP0>iGH0_EE&=v83hMJgaKAI0jJXm zVxVz;X<$v6WW7}fxROO7vr#YLP;;lij5VrX{;>7kK6TtOH&6|Ar^xo>00%+u$C4@# z>!jOt6*3><171+WxoZnKDTzJtDRw+T030;yI}~uV@9fCnei^I*j>Bp&mzP2d=FPb_ zCM*l_+$LDR3B*a!A$g#>xsrZvw0lckxmMg>0aQd7tPyN=t{dgXb;Ie+T8{fZH=gdu zM7Rg9c(kg(Jg0?ARRRl=AONFKrvFj)lTY$KfT%6^6s`mk*ABGhsce*LsoD>K{z_M2 ziPpnu+lw22PfF!CoId^6n*G4H(Ix+#+N{C(da7t1BYMGEaE#PdpOLxsVD5riQXHp@OX;`S`8VnpM~)I920w~<3|mo0 zf8~Az`*?2?H&gZ&*K&bRkV@qzvMlRHXys8*Ze2+1c?5o!^+$&MHxB@4Ee5cke52R! zmn7AZtY6ST%ixgU5)%$%QcwHj7Es-Qu^kLAPwy%7pGBw_4Q9#da^W2$}axNHr03)_nw z5?yuNmXrI5HgS46)c5&}B)Tts49oU92>3xBLLy}FMUW=84DQbVq^;7_e7|(Sdz|&J z73N+M`rc2rt*oSWu#7S{*s~nH6HRHJS1SmzeXk|;CA)FI4bat3<%}nkB%;;?=F>B7ms9QSxv#@+69;@>QaR?REYX4&)=itG>rM{<{A79Rmk)`5ON#GL`*KX%}Ihk3w(RtM-WLt z?f&FLF}4N^yE!(pZ&Yj&Bc`~K0@4_}*0Om?wN|}4WJ>WL;G^H2*QpgEkGA~OET-Km zkwz|5{6dnz1U<2Pe9DNL>3g5FEIvp1jzP&2K#z~j%g6!7B;^zF+o95?fV{3mnB8*RMhCDNp>Am-3e@jNfMj?jHV$MWjk!DDKP zkAz$Y?Sr)!GUOX}qTQ5aMh|wq1uq}~joWyKl=b_LboM#wi{CMuz5x6BKlA-qy++cM01D3b7`uD z#l6M4pI;JCypO8JZ6?U&wNxR!{4oB_ zlV!x9+-&Qy6{%MQ{~yoZGkKiTSC`YS_j22~G;xUV855g2&C(zm^V!(wpcm@zn{%!g z4}JGo(sGZ1O~to-}le

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/values/colors.xml b/microapps/OfflineMapAreasApp/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..1e8023b93 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/values/colors.xml @@ -0,0 +1,28 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml b/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..d9483d2a7 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + OfflineMapAreasApp + diff --git a/microapps/OfflineMapAreasApp/app/src/main/res/values/themes.xml b/microapps/OfflineMapAreasApp/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..ce515aad1 --- /dev/null +++ b/microapps/OfflineMapAreasApp/app/src/main/res/values/themes.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file From 650ba13b7e0ce469fcab94b5d740e7e0ff537821 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 18 Jun 2025 11:46:04 -0700 Subject: [PATCH 083/132] Support map areas various fail reasons and errors (#885) --- toolkit/offline/build.gradle.kts | 3 +- .../toolkit/offline/OfflineMapAreas.kt | 91 ++++--- .../toolkit/offline/OfflineMapState.kt | 215 +++++++++++++--- .../offline/preplanned/PreplannedMapAreas.kt | 14 +- .../preplanned/PreplannedMapAreasState.kt | 79 +++--- .../offline/ui/MapAreaDetailsScreen.kt | 1 + .../offline/ui/OfflineMapAreasStatusScreen.kt | 239 ++++++++++++++++++ .../offline/src/main/res/values/strings.xml | 12 + 8 files changed, 546 insertions(+), 108 deletions(-) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt diff --git a/toolkit/offline/build.gradle.kts b/toolkit/offline/build.gradle.kts index d03fc64f6..19f294c50 100644 --- a/toolkit/offline/build.gradle.kts +++ b/toolkit/offline/build.gradle.kts @@ -85,7 +85,8 @@ apiValidation { val composableSingletons = listOf( "com.arcgismaps.toolkit.offline.preplanned.ComposableSingletons\$PreplannedMapAreasKt", "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$MapAreaDetailsScreenKt", - "com.arcgismaps.toolkit.offline.ui.material3.ComposableSingletons\$ModalBottomSheetKt" + "com.arcgismaps.toolkit.offline.ui.material3.ComposableSingletons\$ModalBottomSheetKt", + "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$OfflineMapAreasStatusScreenKt" ) ignoredClasses.addAll(composableSingletons) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 456184ba1..0cfa933e4 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -18,26 +18,26 @@ package com.arcgismaps.toolkit.offline -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreas +import com.arcgismaps.toolkit.offline.ui.EmptyOnDemandOfflineAreas +import com.arcgismaps.toolkit.offline.ui.EmptyPreplannedOfflineAreas +import com.arcgismaps.toolkit.offline.ui.NoInternetNoAreas +import com.arcgismaps.toolkit.offline.ui.OfflineDisabled +import com.arcgismaps.toolkit.offline.ui.OfflineMapAreasError /** * Take a web map offline by downloading map areas. @@ -51,14 +51,17 @@ public fun OfflineMapAreas( ) { val context = LocalContext.current val initializationStatus by offlineMapState.initializationStatus + var isRefreshEnabled by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(offlineMapState) { + LaunchedEffect(offlineMapState, isRefreshEnabled) { + if (isRefreshEnabled) { + offlineMapState.resetInitialize() + } offlineMapState.initialize(context) + isRefreshEnabled = false } - Surface( - modifier = modifier - ) { + Surface(modifier = modifier) { when (initializationStatus) { is InitializationStatus.NotInitialized, InitializationStatus.Initializing -> { Box( @@ -70,36 +73,46 @@ public fun OfflineMapAreas( } is InitializationStatus.FailedToInitialize -> { - (initializationStatus as InitializationStatus.FailedToInitialize).error.message?.let { - NonRecoveredErrorIndicator( - it - ) - } + OfflineMapAreasError( + onRefresh = { isRefreshEnabled = true }, + error = (initializationStatus as InitializationStatus.FailedToInitialize).error + ) } - else -> { - if (offlineMapState.mode == OfflineMapMode.Preplanned) { - PreplannedMapAreas( - preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, - modifier = modifier - ) + is InitializationStatus.Initialized -> { + // Check if the map has offline mode disabled + if (offlineMapState.mapIsOfflineDisabled) { + OfflineDisabled(onRefresh = { isRefreshEnabled = true }) + } else { + // If offline mode is enabled, display the offline modes + when (offlineMapState.mode) { + // For preplanned, display online & offline map areas. + OfflineMapMode.Preplanned -> { + Column { + PreplannedMapAreas( + preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, + modifier = modifier + ) + if (offlineMapState.isShowingOnlyOfflineModels) { + NoInternetNoAreas( + onlyFooterVisible = offlineMapState.preplannedMapAreaStates.isNotEmpty(), + onRefresh = { isRefreshEnabled = true } + ) + } else if (offlineMapState.preplannedMapAreaStates.isEmpty()){ + EmptyPreplannedOfflineAreas(onRefresh = { isRefreshEnabled = true }) + } + } + } + // If not preplanned state & map has offline mode enabled, display the on demand areas + OfflineMapMode.OnDemand, OfflineMapMode.Unknown -> { + // TODO: Init OnDemand screen... + EmptyOnDemandOfflineAreas(onAdd = { + // TODO: Add new on demand map area + }) + } + } } } } } } - -@Composable -private fun NonRecoveredErrorIndicator(errorMessage: String) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.Default.Info, - contentDescription = stringResource(id = R.string.error), - tint = MaterialTheme.colorScheme.error - ) - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error - ) - } -} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 2153cbfb0..cad0e95d5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -22,15 +22,22 @@ import android.content.Context import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask +import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.Status +import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException +import java.io.File /** * Represents the state of the offline map. @@ -78,6 +85,22 @@ public class OfflineMapState( */ public val initializationStatus: State = _initializationStatus + /** + * A Boolean value indicating if only offline models are being shown. + * + * @since 200.8.0 + */ + internal var isShowingOnlyOfflineModels by mutableStateOf(false) + private set + + /** + * A Boolean value indicating whether the web map is offline disabled. + * + * @since 200.8.0 + */ + internal var mapIsOfflineDisabled by mutableStateOf(false) + private set + /** * Initializes the state object by loading the map, creating and loading the offline map task. * @@ -89,49 +112,166 @@ public class OfflineMapState( return Result.success(Unit) } _initializationStatus.value = InitializationStatus.Initializing - arcGISMap.load().getOrElse { - _initializationStatus.value = InitializationStatus.FailedToInitialize(it) - throw it - } - + // initialize the offline repository OfflineRepository.refreshOfflineMapInfos(context) + // reset to check if map has offline enabled + isShowingOnlyOfflineModels = false + // load the map, and ignore network error if device is offline + arcGISMap.retryLoad().getOrElse { error -> + // check if the error is due to network connection + if (error.message?.contains("Unable to resolve host") == true) { + // enable offline only mode + isShowingOnlyOfflineModels = true + } else { + // unexpected error, report failed status + _initializationStatus.value = InitializationStatus.FailedToInitialize(error) + throw error + } + } offlineMapTask = OfflineMapTask(arcGISMap) portalItem = (arcGISMap.item as? PortalItem) ?: throw IllegalStateException("Item not found") - offlineMapTask.load().getOrElse { - _initializationStatus.value = InitializationStatus.FailedToInitialize(it) - throw it + // load the task, and ignore network error if device is offline + offlineMapTask.retryLoad().getOrElse { error -> + // check if the error is not due to network connection + if (error.message?.contains("Unable to resolve host") == false) { + // unexpected error, report failed status + _initializationStatus.value = InitializationStatus.FailedToInitialize(error) + throw error + } } - val preplannedMapAreas = offlineMapTask.getPreplannedMapAreas().getOrNull() - preplannedMapAreas?.let { preplannedMapArea -> + + // determine if offline is disabled for the map + mapIsOfflineDisabled = + (arcGISMap.loadStatus.value == LoadStatus.Loaded) && (arcGISMap.offlineSettings == null) + + // load the preplanned map area states + loadPreplannedMapAreas(context) + + // check if preplanned for loaded + if (_mode != OfflineMapMode.Preplanned || _mode == OfflineMapMode.Unknown) { + // TODO: Load OnDemandMapAresState + if (_mode == OfflineMapMode.Unknown) + _mode = OfflineMapMode.OnDemand + } + _initializationStatus.value = InitializationStatus.Initialized + } + + /** + * Loads preplanned map areas from the [portalItem], initializes their [preplannedMapAreaStates], + * and updates download status. If no online areas are available or “offline-only” mode is enabled, + * falls back [loadOfflinePreplannedMapAreas]. + * + * @since 200.8.0 + */ + private suspend fun loadPreplannedMapAreas(context: Context) { + _preplannedMapAreaStates.clear() + val preplannedMapAreas = mutableListOf() + try { + preplannedMapAreas.addAll( + elements = offlineMapTask.getPreplannedMapAreas().getOrNull() ?: emptyList() + ) + } catch (e: Exception) { + preplannedMapAreas.clear() + } + if (isShowingOnlyOfflineModels || preplannedMapAreas.isEmpty()) { + loadOfflinePreplannedMapAreas(context = context) + } else { _mode = OfflineMapMode.Preplanned - preplannedMapArea - .sortedBy { it.portalItem.title } - .forEach { mapArea -> - val preplannedMapAreaState = PreplannedMapAreaState( - context = context, - preplannedMapArea = mapArea, - offlineMapTask = offlineMapTask, - portalItem = portalItem, - onSelectionChanged = onSelectionChanged - ) - preplannedMapAreaState.initialize() - val preplannedPath = OfflineRepository.isPrePlannedAreaDownloaded( - context = context, - portalItemID = portalItem.itemId, - preplannedMapAreaID = mapArea.portalItem.itemId - ) - if (preplannedPath != null) { - preplannedMapAreaState.updateStatus(Status.Downloaded) - preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = preplannedPath + preplannedMapAreas.let { preplannedMapArea -> + preplannedMapArea + .sortedBy { it.portalItem.title } + .forEach { mapArea -> + val preplannedMapAreaState = PreplannedMapAreaState( + context = context, + preplannedMapArea = mapArea, + offlineMapTask = offlineMapTask, + item = portalItem, + onSelectionChanged = onSelectionChanged + ) + preplannedMapAreaState.initialize() + val preplannedPath = OfflineRepository.isPrePlannedAreaDownloaded( + context = context, + portalItemID = portalItem.itemId, + preplannedMapAreaID = mapArea.portalItem.itemId ) + if (preplannedPath != null) { + preplannedMapAreaState.updateStatus(Status.Downloaded) + preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = preplannedPath + ) + } + _preplannedMapAreaStates.add(preplannedMapAreaState) } - _preplannedMapAreaStates.add(preplannedMapAreaState) - } + } } - _initializationStatus.value = InitializationStatus.Initialized + } + + /** + * Scans the local preplanned directory for downloaded maps and creates [PreplannedMapAreaState]s. + * Sets the [OfflineMapMode.Preplanned] when any local areas are found. + * + * @since 200.8.0 + */ + private suspend fun loadOfflinePreplannedMapAreas(context: Context) { + val preplannedDirectory = File( + OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId) + ) + val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } + ?: emptyList() + + if (preplannedMapAreaItemIds.isNotEmpty()) + _mode = OfflineMapMode.Preplanned + + preplannedMapAreaItemIds.forEach { itemId -> + makeOfflinePreplannedMapAreaState(context, itemId) + ?.let { _preplannedMapAreaStates.add(it) } + } + } + + /** + * Attempts to create a [PreplannedMapAreaState] for a given area ID by loading + * its [MobileMapPackage] from disk. Returns null if the directory is missing + * or the package fails to load; otherwise initializes status and map. + * + * @since 200.8.0 + */ + private suspend fun makeOfflinePreplannedMapAreaState( + context: Context, + areaItemId: String + ): PreplannedMapAreaState? { + val areaDir = File( + OfflineURLs.prePlannedDirectoryPath( + context = context, + portalItemID = portalItem.itemId, + preplannedMapAreaID = areaItemId + ) + ) + if (!areaDir.exists() || !areaDir.isDirectory) return null + val mmpk = MobileMapPackage(areaDir.absolutePath).apply { + load().getOrElse { return null } + } + val item = mmpk.item ?: return null + + val preplannedMapAreaState = PreplannedMapAreaState( + context = context, + item = item, + onSelectionChanged = onSelectionChanged + ) + val preplannedPath = OfflineRepository.isPrePlannedAreaDownloaded( + context = context, + portalItemID = portalItem.itemId, + preplannedMapAreaID = areaItemId + ) + if (preplannedPath != null) { + preplannedMapAreaState.updateStatus(Status.Downloaded) + preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = preplannedPath + ) + return preplannedMapAreaState + } else + return null } /** @@ -142,6 +282,15 @@ public class OfflineMapState( public fun resetSelectedMapArea() { _preplannedMapAreaStates.forEach { it.setSelectedToOpen(false) } } + + /** + * Support to refresh & re-initialize the offline map area state. + * + * @since 200.8.0 + */ + internal fun resetInitialize() { + _initializationStatus.value = InitializationStatus.NotInitialized + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 75c0c3e82..8da4ae8db 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -98,9 +98,9 @@ internal fun PreplannedMapAreas( sheetState = sheetState, scope = scope, onDismiss = { showSheet = false }, - thumbnail = selectedPreplannedMapAreaState.preplannedMapArea.portalItem.thumbnail?.image?.bitmap?.asImageBitmap(), /* your default image */ - title = selectedPreplannedMapAreaState.preplannedMapArea.portalItem.title, - description = selectedPreplannedMapAreaState.preplannedMapArea.portalItem.description, + thumbnail = selectedPreplannedMapAreaState.thumbnail?.asImageBitmap(), + title = selectedPreplannedMapAreaState.title, + description = selectedPreplannedMapAreaState.description, size = selectedPreplannedMapAreaState.directorySize, isAvailableToDownload = selectedPreplannedMapAreaState.status.allowsDownload, onStartDownload = { @@ -141,7 +141,7 @@ internal fun PreplannedMapAreas( verticalAlignment = Alignment.CenterVertically ) { // Display the thumbnail image if available, otherwise show a placeholder icon - val thumbnail = state.preplannedMapArea.portalItem.thumbnail?.image?.bitmap?.asImageBitmap() + val thumbnail = state.thumbnail?.asImageBitmap() Box( modifier = Modifier .padding(vertical = 8.dp) @@ -169,7 +169,7 @@ internal fun PreplannedMapAreas( Column(modifier = Modifier.weight(1f)) { // Display the title with a maximum of one line Text( - text = state.preplannedMapArea.portalItem.title, + text = state.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 6.dp), maxLines = 1, // Restrict to one line @@ -177,7 +177,7 @@ internal fun PreplannedMapAreas( ) // Display the description with a maximum of two lines Text( - text = state.preplannedMapArea.portalItem.description, + text = state.description, style = MaterialTheme.typography.bodySmall, maxLines = 2, // Restrict to two lines overflow = TextOverflow.Ellipsis // Add ellipses if the text overflows @@ -209,11 +209,13 @@ internal fun PreplannedMapAreas( } } } + state.status == Status.Downloading -> { CancelDownloadButtonWithProgressIndicator(state.downloadProgress.value) { state.cancelDownload() } } + state.status.isDownloaded -> { OpenButton(!state.isSelectedToOpen) { // Unselect all, then select this one diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index c0cc17cbf..de05172b5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -19,6 +19,7 @@ package com.arcgismaps.toolkit.offline.preplanned import android.content.Context +import android.graphics.Bitmap import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.State @@ -27,6 +28,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.Item import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.DownloadPreplannedOfflineMapJob @@ -34,10 +36,10 @@ import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea import com.arcgismaps.tasks.offlinemaptask.PreplannedPackagingStatus import com.arcgismaps.tasks.offlinemaptask.PreplannedUpdateMode +import com.arcgismaps.toolkit.offline.OfflineRepository import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize import com.arcgismaps.toolkit.offline.runCatchingCancellable import com.arcgismaps.toolkit.offline.workmanager.LOG_TAG -import com.arcgismaps.toolkit.offline.OfflineRepository import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -53,11 +55,12 @@ import java.util.UUID */ internal class PreplannedMapAreaState( private val context: Context, - internal val preplannedMapArea: PreplannedMapArea, - private val offlineMapTask: OfflineMapTask, - private val portalItem: PortalItem, + private val item: Item, + internal val preplannedMapArea: PreplannedMapArea? = null, + private val offlineMapTask: OfflineMapTask? = null, private val onSelectionChanged: (ArcGISMap) -> Unit ) { + private lateinit var workerUUID: UUID private lateinit var mobileMapPackage: MobileMapPackage @@ -83,6 +86,12 @@ internal class PreplannedMapAreaState( private lateinit var scope: CoroutineScope + internal val title = item.title + internal val description = item.description + + private var _thumbnail by mutableStateOf(null) + internal val thumbnail: Bitmap? get() = _thumbnail ?: item.thumbnail?.image?.bitmap + /** * Loads and initializes the associated preplanned map area. * @@ -96,8 +105,8 @@ internal class PreplannedMapAreaState( * @since 200.8.0 */ internal suspend fun initialize() = runCatchingCancellable { - preplannedMapArea.load() - .onSuccess { + preplannedMapArea?.retryLoad() + ?.onSuccess { _status = try { Status.fromPackagingStatus(preplannedMapArea.packagingStatus) } catch (illegalStateException: IllegalStateException) { @@ -108,8 +117,13 @@ internal class PreplannedMapAreaState( Status.Packaged } // Load the thumbnail - preplannedMapArea.portalItem.thumbnail?.load() - } + _thumbnail = preplannedMapArea.portalItem.thumbnail?.let { loadableImage -> + runCatching { loadableImage.load() } + loadableImage.image?.bitmap + } + } ?: { + // preplannedMapArea is null. + } } /** @@ -117,24 +131,30 @@ internal class PreplannedMapAreaState( * * @since 200.8.0 */ - internal fun downloadPreplannedMapArea() = runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) - scope.launch { - _status = Status.Downloading - val offlineWorkerUUID = startOfflineMapJob( - downloadPreplannedOfflineMapJob = createOfflineMapJob( - preplannedMapArea = preplannedMapArea + internal fun downloadPreplannedMapArea() = + runCatchingCancellable { + scope = CoroutineScope(Dispatchers.IO) + val area = preplannedMapArea ?: return@runCatchingCancellable + val task = offlineMapTask ?: return@runCatchingCancellable + val portalItem = item as? PortalItem ?: return@runCatchingCancellable + + scope.launch { + _status = Status.Downloading + val offlineWorkerUUID = startOfflineMapJob( + downloadPreplannedOfflineMapJob = createOfflineMapJob( + preplannedMapArea = area, + offlineMapTask = task + ) + ) + OfflineRepository.observeStatusForPreplannedWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + preplannedMapAreaState = this@PreplannedMapAreaState, + portalItem = portalItem, + offlineWorkerUUID = offlineWorkerUUID ) - ) - OfflineRepository.observeStatusForPreplannedWork( - context = context, - onWorkInfoStateChanged = ::logWorkInfo, - preplannedMapAreaState = this@PreplannedMapAreaState, - portalItem = portalItem, - offlineWorkerUUID = offlineWorkerUUID - ) + } } - } /** * Cancels the current coroutine scope. @@ -155,13 +175,14 @@ internal class PreplannedMapAreaState( * Defines a directory path where map data will be stored and creates a download job using these configurations. * * @param preplannedMapArea The target [PreplannedMapArea] to be downloaded offline. - * + * @param offlineMapTask The target [OfflineMapTask] to create the params & the job. * @return An instance of [DownloadPreplannedOfflineMapJob] configured with download parameters. * * @since 200.8.0 */ private suspend fun createOfflineMapJob( - preplannedMapArea: PreplannedMapArea + preplannedMapArea: PreplannedMapArea, + offlineMapTask: OfflineMapTask ): DownloadPreplannedOfflineMapJob { // Create default download parameters from the offline map task @@ -176,7 +197,7 @@ internal class PreplannedMapAreaState( // Define the path where the map will be saved val preplannedMapAreaDownloadDirectory = OfflineRepository.createPendingPreplannedJobPath( context = context, - portalItemID = portalItem.itemId, + portalItemID = item.itemId, preplannedMapAreaID = preplannedMapArea.portalItem.itemId ) @@ -210,7 +231,7 @@ internal class PreplannedMapAreaState( workerUUID = OfflineRepository.createPreplannedMapAreaRequestAndQueueDownload( context = context, jsonJobPath = jsonJobFile.path, - preplannedMapAreaTitle = preplannedMapArea.portalItem.title + preplannedMapAreaTitle = item.title ) return workerUUID @@ -235,7 +256,7 @@ internal class PreplannedMapAreaState( if (shouldRemoveOfflineMapInfo()) { OfflineRepository.removeOfflineMapInfo( context = context, - portalItemID = portalItem.itemId + portalItemID = item.itemId ) } val localScope = CoroutineScope(Dispatchers.IO) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index 84f63ecbc..bd2ef6461 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -15,6 +15,7 @@ * limitations under the License. * */ + package com.arcgismaps.toolkit.offline.ui import androidx.compose.foundation.Image diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt new file mode 100644 index 000000000..8611069d2 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt @@ -0,0 +1,239 @@ +/* + * + * Copyright 2025 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.offline.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.arcgismaps.toolkit.offline.R + +@Composable +internal fun OfflineMapAreasStatusContent( + title: String, + message: String, + icon: ImageVector, + onlyFooterVisible: Boolean, + actions: @Composable RowScope.() -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (onlyFooterVisible) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) + Text( + modifier = Modifier.wrapContentSize(), + text = message, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { actions() } + } else { + Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Text(message, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { actions() } + } + } +} + +@Composable +internal fun RefreshButton(onRefresh: () -> Unit) { + Button(onClick = onRefresh) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.Refresh, + contentDescription = "Icon to refresh map area content." + ) + Spacer(Modifier.width(4.dp)) + Text(text = stringResource(R.string.refresh), style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +internal fun NoInternetNoAreas(onlyFooterVisible: Boolean = false, onRefresh: () -> Unit) { + OfflineMapAreasStatusContent( + title = stringResource(R.string.no_internet_connection_error_message), + message = stringResource(R.string.no_internet_error_message), + icon = Icons.Default.WifiOff, + actions = { RefreshButton(onRefresh) }, + onlyFooterVisible = onlyFooterVisible + ) +} + +@Composable +internal fun EmptyPreplannedOfflineAreas( + onlyFooterVisible: Boolean = false, + onRefresh: () -> Unit +) { + OfflineMapAreasStatusContent( + title = stringResource(R.string.no_map_areas), + message = stringResource(R.string.no_offline_map_areas_error_message), + icon = Icons.Default.ArrowDownward, + actions = { RefreshButton(onRefresh) }, + onlyFooterVisible = onlyFooterVisible + ) +} + +@Composable +internal fun OfflineMapAreasError( + error: Throwable, + onlyFooterVisible: Boolean = false, + onRefresh: () -> Unit +) { + OfflineMapAreasStatusContent( + title = stringResource(R.string.error_fetching_areas), + message = stringResource(R.string.error_fetching_areas_message) + "\n\n" + error.message, + icon = Icons.Default.Error, + actions = { RefreshButton(onRefresh) }, + onlyFooterVisible = onlyFooterVisible + ) +} + +@Composable +internal fun OfflineDisabled(onlyFooterVisible: Boolean = false, onRefresh: () -> Unit) { + OfflineMapAreasStatusContent( + title = stringResource(R.string.offline_disabled), + message = stringResource(R.string.offline_disabled_message), + icon = Icons.Default.Block, + actions = { RefreshButton(onRefresh) }, + onlyFooterVisible = onlyFooterVisible + ) +} + +@Composable +internal fun EmptyOnDemandOfflineAreas(onlyFooterVisible: Boolean = false, onAdd: () -> Unit) { + OfflineMapAreasStatusContent( + title = stringResource(R.string.no_map_areas), + message = stringResource(R.string.empty_on_demand_message), + icon = Icons.Default.ArrowDownward, + actions = { + Button(onClick = onAdd) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.Add, + contentDescription = "Icon to add map area" + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.add_map_area), + style = MaterialTheme.typography.labelSmall + ) + } + }, + onlyFooterVisible = onlyFooterVisible + ) +} + +@Preview(showBackground = true) +@Composable +private fun NoInternetNoAreasPreview() { + MaterialTheme { + Surface { + NoInternetNoAreas { } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NoInternetNoAreasFooterPreview() { + MaterialTheme { + Surface { + NoInternetNoAreas(onlyFooterVisible = true) { } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyPreplannedOfflineAreasPreview() { + MaterialTheme { + Surface { + EmptyPreplannedOfflineAreas { } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun OfflineMapAreasErrorPreview() { + MaterialTheme { + Surface { + OfflineMapAreasError(error = Throwable("Failed to initialize map areas")) { } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun OfflineDisabledPreview() { + MaterialTheme { + Surface { + OfflineDisabled { } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyOnDemandOfflineAreasPreview() { + MaterialTheme { + Surface { + EmptyOnDemandOfflineAreas { } + } + } +} diff --git a/toolkit/offline/src/main/res/values/strings.xml b/toolkit/offline/src/main/res/values/strings.xml index b0a1f0c3c..782439f44 100644 --- a/toolkit/offline/src/main/res/values/strings.xml +++ b/toolkit/offline/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Map Areas + Add Map Area PortalItem Thumbnail Download Open @@ -33,4 +34,15 @@ Download Map Area Remove Download No Image Available + Refresh + No Internet Connection + No internet connection. Showing downloaded areas only. + Could not retrieve map areas for this map. + There are no map areas for this map. + Error Fetching Map Areas + An error occurred while fetching map areas. + No Map Areas + There are no map areas for this map. Tap the button below to get started. + Offline Disabled + The map is not enabled for offline use. From e28a49350ed5addd04309ada618c6d702439db71 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 18 Jun 2025 14:12:52 -0700 Subject: [PATCH 084/132] Enhance OfflineMapAreas microapp (#893) --- .../offlinemapareasapp/screens/MainScreen.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt index b56f8c53f..31f60cd83 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt @@ -18,6 +18,9 @@ package com.arcgismaps.toolkit.offlinemapareasapp.screens +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring.StiffnessMediumLow +import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -28,6 +31,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -67,12 +71,16 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { sheetContent = { OfflineMapAreas( viewModel.offlineMapState, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .animateContentSize() ) }, + sheetContainerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize(), scaffoldState = scaffoldState, - sheetPeekHeight = 100.dp, + sheetPeekHeight = 120.dp, sheetSwipeEnabled = true, topBar = { TopAppBar( @@ -116,6 +124,7 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { modifier = Modifier .padding(padding) .fillMaxSize(), + onDown = { coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() } } ) } } From e831ca49ae560a5f5097dce6d348274d10cd199a Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Wed, 18 Jun 2025 16:11:38 -0700 Subject: [PATCH 085/132] Add sheetGesturesEnabled (#894) Add Modifications to copyright --- .../offline/ui/material3/ModalBottomSheet.kt | 96 ++++++++++++------- .../offline/ui/material3/SheetDefaults.kt | 2 + 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/ModalBottomSheet.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/ModalBottomSheet.kt index 76ebf27a0..9630aa0a7 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/ModalBottomSheet.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/ModalBottomSheet.kt @@ -12,6 +12,10 @@ * 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. + * + * Modifications 2025: Esri Inc. Removed experimental annotations + * Modifications 2025: Esri Inc. Made functions internal and explicitly specified return types + * Modifications 2025: Esri Inc. Add sheetGesturesEnabled */ package com.arcgismaps.toolkit.offline.ui.material3 @@ -40,6 +44,7 @@ import androidx.compose.animation.core.Easing import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.draggable @@ -146,6 +151,7 @@ import java.util.UUID * @param sheetState The state of the bottom sheet. * @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take. Pass in * [Dp.Unspecified] for a sheet that spans the entire screen width. + * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures. * @param shape The shape of the bottom sheet. * @param containerColor The color used for the background of this bottom sheet * @param contentColor The preferred color for content inside this bottom sheet. Defaults to either @@ -168,6 +174,7 @@ internal fun ModalBottomSheet( modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState(), sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + sheetGesturesEnabled: Boolean = true, shape: Shape = BottomSheetDefaults.ExpandedShape, containerColor: Color = BottomSheetDefaults.ContainerColor, contentColor: Color = contentColorFor(containerColor), @@ -227,6 +234,7 @@ internal fun ModalBottomSheet( modifier, sheetState, sheetMaxWidth, + sheetGesturesEnabled, shape, containerColor, contentColor, @@ -251,6 +259,7 @@ internal fun BoxScope.ModalBottomSheetContent( modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState(), sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + sheetGesturesEnabled: Boolean = true, shape: Shape = BottomSheetDefaults.ExpandedShape, containerColor: Color = BottomSheetDefaults.ContainerColor, contentColor: Color = contentColorFor(containerColor), @@ -267,14 +276,18 @@ internal fun BoxScope.ModalBottomSheetContent( .align(Alignment.TopCenter) .widthIn(max = sheetMaxWidth) .fillMaxWidth() - .nestedScroll( - remember(sheetState) { - ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - sheetState = sheetState, - orientation = Orientation.Vertical, - onFling = settleToDismiss + .then( + if (sheetGesturesEnabled) + Modifier.nestedScroll( + remember(sheetState) { + ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState = sheetState, + orientation = Orientation.Vertical, + onFling = settleToDismiss + ) + } ) - } + else Modifier ) .draggableAnchors(sheetState.anchoredDraggableState, Orientation.Vertical) { sheetSize, @@ -309,7 +322,7 @@ internal fun BoxScope.ModalBottomSheetContent( .draggable( state = sheetState.anchoredDraggableState.draggableState, orientation = Orientation.Vertical, - enabled = sheetState.isVisible, + enabled = sheetGesturesEnabled && sheetState.isVisible, startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning, onDragStopped = { settleToDismiss(it) } ) @@ -351,35 +364,50 @@ internal fun BoxScope.ModalBottomSheetContent( val dismissActionLabel = "Dismiss bottom sheet" val expandActionLabel = "Expand bottom sheet" Box( - Modifier.align(Alignment.CenterHorizontally).semantics( - mergeDescendants = true - ) { - // Provides semantics to interact with the bottomsheet based on its - // current value. - with(sheetState) { - dismiss(dismissActionLabel) { - animateToDismiss() - true - } - if (currentValue == PartiallyExpanded) { - expand(expandActionLabel) { - if (anchoredDraggableState.confirmValueChange(Expanded)) { - scope.launch { sheetState.expand() } - } - true + modifier = + Modifier.align(Alignment.CenterHorizontally) + .clickable { + when (sheetState.currentValue) { + Expanded -> animateToDismiss() + PartiallyExpanded -> scope.launch { sheetState.expand() } + else -> scope.launch { sheetState.show() } } - } else if (hasPartiallyExpandedState) { - collapse(collapseActionLabel) { - if ( - anchoredDraggableState.confirmValueChange(PartiallyExpanded) - ) { - scope.launch { partialExpand() } + } + .semantics(mergeDescendants = true) { + // Provides semantics to interact with the bottomsheet based on its + // current value. + if (sheetGesturesEnabled) { + with(sheetState) { + dismiss(dismissActionLabel) { + animateToDismiss() + true + } + if (currentValue == PartiallyExpanded) { + expand(expandActionLabel) { + if ( + anchoredDraggableState.confirmValueChange( + Expanded + ) + ) { + scope.launch { sheetState.expand() } + } + true + } + } else if (hasPartiallyExpandedState) { + collapse(collapseActionLabel) { + if ( + anchoredDraggableState.confirmValueChange( + PartiallyExpanded + ) + ) { + scope.launch { partialExpand() } + } + true + } + } } - true } - } - } - } + }, ) { dragHandle() } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/SheetDefaults.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/SheetDefaults.kt index 2ef943cf2..c1de4a9a5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/SheetDefaults.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/material3/SheetDefaults.kt @@ -13,6 +13,8 @@ * 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. + * + * Modifications 2025: Esri Inc. Made functions internal and explicitly specified return types */ package com.arcgismaps.toolkit.offline.ui.material3 From db2b8f09fb5c303f2cfa5c913e50280f94e86de4 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Fri, 20 Jun 2025 14:56:20 -0700 Subject: [PATCH 086/132] Init on demand map areas selector screen (#895) --- .../screens/OfflineViewModel.kt | 7 +- toolkit/offline/build.gradle.kts | 4 +- .../toolkit/offline/OfflineMapAreas.kt | 41 +- .../toolkit/offline/OfflineMapState.kt | 31 ++ .../toolkit/offline/OfflineRepository.kt | 199 ++++++++- .../ondemand/OnDemandMapAreaSelector.kt | 419 ++++++++++++++++++ .../offline/ondemand/OnDemandMapAreas.kt | 40 ++ .../offline/ondemand/OnDemandMapAreasState.kt | 393 ++++++++++++++++ .../workmanager/OnDemandMapAreaJobWorker.kt | 200 +++++++++ 9 files changed, 1323 insertions(+), 11 deletions(-) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt index 1aba8b9ec..f20185aab 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt @@ -29,11 +29,14 @@ import kotlinx.coroutines.launch class OfflineViewModel : ViewModel() { - private val napervilleWaterNetwork = "acc027394bc84c2fb04d1ed317aac674" + // TODO: Update to use napervilleWaterNetwork and remove USBreweriesforOfflineTesting + // private val napervilleWaterNetwork = "acc027394bc84c2fb04d1ed317aac674" + private val USBreweriesforOfflineTesting = "3da658f2492f4cfd8494970ef489d2c5" + private val onlineMap = ArcGISMap( PortalItem( Portal.arcGISOnline(connection = Portal.Connection.Anonymous), - napervilleWaterNetwork + USBreweriesforOfflineTesting ) ) val selectedMap = mutableStateOf(null) diff --git a/toolkit/offline/build.gradle.kts b/toolkit/offline/build.gradle.kts index 19f294c50..65c53aed2 100644 --- a/toolkit/offline/build.gradle.kts +++ b/toolkit/offline/build.gradle.kts @@ -86,13 +86,15 @@ apiValidation { "com.arcgismaps.toolkit.offline.preplanned.ComposableSingletons\$PreplannedMapAreasKt", "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$MapAreaDetailsScreenKt", "com.arcgismaps.toolkit.offline.ui.material3.ComposableSingletons\$ModalBottomSheetKt", - "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$OfflineMapAreasStatusScreenKt" + "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$OfflineMapAreasStatusScreenKt", + "com.arcgismaps.toolkit.offline.ondemand.ComposableSingletons\$OnDemandMapAreaSelectorKt" ) ignoredClasses.addAll(composableSingletons) } dependencies { api(arcgis.mapsSdk) + implementation(project(":geoview-compose")) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.composeCore) implementation(libs.bundles.core) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 0cfa933e4..e72908b0b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -27,17 +27,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaSelector import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreas import com.arcgismaps.toolkit.offline.ui.EmptyOnDemandOfflineAreas import com.arcgismaps.toolkit.offline.ui.EmptyPreplannedOfflineAreas import com.arcgismaps.toolkit.offline.ui.NoInternetNoAreas import com.arcgismaps.toolkit.offline.ui.OfflineDisabled import com.arcgismaps.toolkit.offline.ui.OfflineMapAreasError +import kotlinx.coroutines.launch /** * Take a web map offline by downloading map areas. @@ -50,8 +53,10 @@ public fun OfflineMapAreas( modifier: Modifier = Modifier ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val initializationStatus by offlineMapState.initializationStatus var isRefreshEnabled by rememberSaveable { mutableStateOf(false) } + var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } LaunchedEffect(offlineMapState, isRefreshEnabled) { if (isRefreshEnabled) { @@ -98,17 +103,41 @@ public fun OfflineMapAreas( onlyFooterVisible = offlineMapState.preplannedMapAreaStates.isNotEmpty(), onRefresh = { isRefreshEnabled = true } ) - } else if (offlineMapState.preplannedMapAreaStates.isEmpty()){ - EmptyPreplannedOfflineAreas(onRefresh = { isRefreshEnabled = true }) + } else if (offlineMapState.preplannedMapAreaStates.isEmpty()) { + EmptyPreplannedOfflineAreas(onRefresh = { + isRefreshEnabled = true + }) } } } // If not preplanned state & map has offline mode enabled, display the on demand areas OfflineMapMode.OnDemand, OfflineMapMode.Unknown -> { - // TODO: Init OnDemand screen... - EmptyOnDemandOfflineAreas(onAdd = { - // TODO: Add new on demand map area - }) + EmptyOnDemandOfflineAreas( + onAdd = { + isOnDemandMapAreaSelectorVisible = true + } + ) + OnDemandMapAreaSelector( + localMap = offlineMapState.localMap, + showBottomSheet = isOnDemandMapAreaSelectorVisible, + uniqueMapAreaTitle = "Area 1", // TODO: Ensure this is a unique area title + onDismiss = { + isOnDemandMapAreaSelectorVisible = false + }, + onDownloadMapAreaSelected = { envelope, mapAreaTitle -> + scope.launch { + // TODO: This should be triggered from the area state + val onDemandMapAreaState = offlineMapState.createOnDemandMapAreasState( + context = context, + envelope = envelope, + mapAreaTitle = mapAreaTitle + ) + // Start the on-demand download + onDemandMapAreaState.downloadOnDemandMapArea() + + } + } + ) } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index cad0e95d5..2f9dfc52b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -28,16 +28,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.Status import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException import java.io.File +import java.util.UUID /** * Represents the state of the offline map. @@ -66,6 +69,8 @@ public class OfflineMapState( internal val mode: OfflineMapMode get() = _mode + internal lateinit var localMap: ArcGISMap + private lateinit var offlineMapTask: OfflineMapTask private lateinit var portalItem: PortalItem @@ -75,6 +80,12 @@ public class OfflineMapState( internal val preplannedMapAreaStates: List get() = _preplannedMapAreaStates + + private var _onDemandMapAreaStates: SnapshotStateList = + mutableStateListOf() + internal val onDemandMapAreaStates: List + get() = _onDemandMapAreaStates + private val _initializationStatus: MutableState = mutableStateOf(InitializationStatus.NotInitialized) @@ -128,6 +139,7 @@ public class OfflineMapState( throw error } } + localMap = arcGISMap.clone() offlineMapTask = OfflineMapTask(arcGISMap) portalItem = (arcGISMap.item as? PortalItem) ?: throw IllegalStateException("Item not found") @@ -230,6 +242,25 @@ public class OfflineMapState( } } + // TODO: Should this be wired to call OnDemandMapAreasState.initialize? + internal fun createOnDemandMapAreasState( + context: Context, + envelope: Envelope, + mapAreaTitle: String + ): OnDemandMapAreasState { + val onDemandMapAreasState = OnDemandMapAreasState( + context = context, + item = portalItem, + onDemandAreaID = UUID.randomUUID().toString(), + title = mapAreaTitle, + mapAreaEnvelope = envelope, + offlineMapTask = offlineMapTask, + onSelectionChanged = onSelectionChanged + ) + _onDemandMapAreaStates.add(onDemandMapAreasState) + return onDemandMapAreasState + } + /** * Attempts to create a [PreplannedMapAreaState] for a given area ID by loading * its [MobileMapPackage] from disk. Returns null if the directory is missing diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index de7a9309b..65efe01e1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -27,9 +27,11 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.arcgismaps.mapping.PortalItem +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.Status import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs +import com.arcgismaps.toolkit.offline.workmanager.OnDemandMapAreaJobWorker import com.arcgismaps.toolkit.offline.workmanager.PreplannedMapAreaJobWorker import com.arcgismaps.toolkit.offline.workmanager.downloadJobJsonFile import com.arcgismaps.toolkit.offline.workmanager.jobAreaTitleKey @@ -133,6 +135,25 @@ public object OfflineRepository { ).also { it.mkdirs() } } + /** + * Creates and returns the directory file for a pending on-demand job. + * + * @param portalItemID The ID of the portal item for which to create a pending job folder. + * @param onDemandMapAreaID The ID of the specific on-demand map area. + * @return A [File] instance pointing to the created `/` directory. + * @since 200.8.0 + */ + internal fun createPendingOnDemandJobPath( + context: Context, + portalItemID: String, + onDemandMapAreaID: String + ): File { + return File( + OfflineURLs.pendingJobInfoDirectoryPath(context, portalItemID), + onDemandMapAreaID + ).also { it.mkdirs() } + } + /** * Saves a serialized offline map job as a JSON file on disk. Ensures directories are created * and writes the provided JSON content to the specified path. @@ -204,7 +225,38 @@ public object OfflineRepository { val target = File(destDir, child.name) child.copyRecursively(target, overwrite = true) } - movePreplannedOfflineMapInfoToDestination(context, portalItemID) + moveOfflineMapInfoToDestination(context, portalItemID) + cacheAreaDir.deleteRecursively() + return destDir + } + + /** + * Returns the path to the final “OnDemand/” folder. + * + * - Moves all contents from: `/PendingJobs//` + * - into: `/com.esri.toolkit.offline//OnDemand/` + * + * @since 200.8.0 + */ + private fun moveOnDemandJobResultToDestination( + context: Context, + offlineMapCacheDownloadPath: String + ): File { + val cacheAreaDir = File(offlineMapCacheDownloadPath) + val areaItemID = cacheAreaDir.name + val portalDir = cacheAreaDir.parentFile + val portalItemID = portalDir?.name.toString() + val destDirPath = OfflineURLs.onDemandDirectoryPath( + context = context, + portalItemID = portalItemID, + onDemandMapAreaID = areaItemID + ) + val destDir = File(destDirPath) + cacheAreaDir.listFiles()?.forEach { child -> + val target = File(destDir, child.name) + child.copyRecursively(target, overwrite = true) + } + moveOfflineMapInfoToDestination(context, portalItemID) cacheAreaDir.deleteRecursively() return destDir } @@ -244,7 +296,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun movePreplannedOfflineMapInfoToDestination(context: Context, portalItemID: String) { + private fun moveOfflineMapInfoToDestination(context: Context, portalItemID: String) { val pendingDir = File(OfflineURLs.pendingMapInfoDirectoryPath(context, portalItemID)) val destDir = File(OfflineURLs.portalItemDirectoryPath(context, portalItemID)) // use pending map info file only if it exists @@ -326,6 +378,47 @@ public object OfflineRepository { return workRequest.id } + /** + * Creates and enqueues a one-time WorkManager request for downloading an offline map area + * using [OnDemandMapAreaJobWorker]. Sets up expedited work with input data containing + * notification and job details. Ensures only one worker instance runs at any time by + * replacing active workers with the same unique name as per the defined policy. + * + * @param jsonJobPath The file path to the serialized JSON representation of the job. + * @param onDemandMapAreaTitle The title of the on-demand map area being downloaded. + * @return A [UUID] representing the identifier of the enqueued WorkManager request. + * @since 200.8.0 + */ + internal fun createOnDemandMapAreaRequestAndQueueDownload( + context: Context, + jsonJobPath: String, + onDemandMapAreaTitle: String + ): UUID { + // create a one-time work request with an instance of OfflineJobWorker + val workRequest = OneTimeWorkRequestBuilder() + // run it as an expedited work + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + // add the input data + .setInputData( + // add the notificationId and the json file path as a key/value pair + workDataOf( + jsonJobPathKey to jsonJobPath, + jobAreaTitleKey to onDemandMapAreaTitle + ) + ).build() + + // enqueue the work request to run as a unique work with the uniqueWorkName, so that + // only one instance of OfflineJobWorker is running at any time + // if any new work request with the uniqueWorkName is enqueued, it replaces any existing + // ones that are active + WorkManager.getInstance(context).enqueueUniqueWork( + uniqueWorkName = jobWorkerUuidKey + workRequest.id, + existingWorkPolicy = ExistingWorkPolicy.KEEP, + request = workRequest + ) + return workRequest.id + } + /** * Observes updates on a specific WorkManager task identified by its UUID and * handles status changes for preplanned map areas. Monitors task states to update @@ -420,6 +513,108 @@ public object OfflineRepository { } } + /** + * Observes updates on a specific WorkManager task identified by its UUID and + * handles status changes for on-demand map areas. Monitors task states to update + * corresponding statuses in [OnDemandMapAreasState]. Finally, prunes completed tasks + * from WorkManager's database when necessary. + * + * + * @param offlineWorkerUUID The unique identifier associated with the specific task being observed in WorkManager. + * @param onDemandMapAreasState The [onDemandMapAreasState] instance to update based on task progress or completion. + * @param onWorkInfoStateChanged A callback function triggered when work state changes occur. + * @since 200.8.0 + */ + internal suspend fun observeStatusForOnDemandWork( + context: Context, + offlineWorkerUUID: UUID, + onDemandMapAreasState: OnDemandMapAreasState, + portalItem: PortalItem, + onWorkInfoStateChanged: (WorkInfo) -> Unit, + ) { + val workManager = WorkManager.getInstance(context) + savePendingMapInfo(context, portalItem) + // collect the flow to get the latest work info list + workManager.getWorkInfoByIdFlow(offlineWorkerUUID) + .collect { workInfo -> + if (workInfo != null) { + // Report progress + val progress = workInfo.progress.getInt("Progress", 0) + onDemandMapAreasState.updateDownloadProgress(progress) + + // emit changes in the work info state + onWorkInfoStateChanged(workInfo) + // check the current state of the work request + when (workInfo.state) { + // if work completed successfully + WorkInfo.State.SUCCEEDED -> { + // TODO: Wire in status handler + // onDemandMapAreasState.updateStatus(Status.Downloaded) + workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> + // using the pending path, move the result to final destination path + val destDir = moveOnDemandJobResultToDestination(context, path) + // create & load the downloaded map + onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = destDir.absolutePath + ) + // skip adding map info if it already exists in the list + if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { + // create offline map information from local directory + OfflineMapInfo.createFromDirectory( + directory = File( + OfflineURLs.portalItemDirectoryPath( + context = context, + portalItemID = portalItem.itemId + ) + ) + )?.let { + // if non-null info was created, add it to the list + _offlineMapInfos.add(it) + } + } + } ?: run { + // TODO: Wire in status handler + /* + onDemandMapAreasState.updateStatus( + Status.MmpkLoadFailure( + Exception("Mobile Map Package path is null") + ) + ) + */ + } + onDemandMapAreasState.disposeScope() + } + // if the work failed or was cancelled + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + // this removes the completed WorkInfo from the WorkManager's database + // otherwise, the observer will emit the WorkInfo on every launch + // until WorkManager auto-prunes + workManager.pruneWork() + // TODO: Wire in status handler + /* + preplannedMapAreaState.updateStatus( + Status.DownloadFailure( + Exception( + "${workInfo.tags}: FAILED. Reason: " + + "${workInfo.outputData.getString("Error")}" + ) + ) + ) + */ + onDemandMapAreasState.disposeScope() + } + // if the work is currently in progress + WorkInfo.State.RUNNING -> { + // TODO: Wire in status handler + // preplannedMapAreaState.updateStatus(Status.Downloading) + } + // don't have to handle other states + else -> {} + } + } + } + } + /** * Cancels a WorkManager request by its unique identifier (UUID). * diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt new file mode 100644 index 000000000..741d18265 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -0,0 +1,419 @@ +/* + * + * Copyright 2025 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.offline.ondemand + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Create +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.view.ScreenCoordinate +import com.arcgismaps.toolkit.geoviewcompose.MapView +import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheet +import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheetProperties +import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState +import kotlinx.coroutines.launch + + +// TODO: Migrate these to the area state + +private val mapViewProxy = MapViewProxy() + +private fun calculateEnvelope(fullSize: IntSize): Envelope? { + val inh = fullSize.width * 0.2 / 2 + val inv = fullSize.height * 0.2 / 2 + val minScreen = ScreenCoordinate(x = inh, y = inv) + val maxScreen = ScreenCoordinate(x = fullSize.width - inh, y = fullSize.height - inv) + val minResult = mapViewProxy.screenToLocationOrNull(minScreen) + val maxResult = mapViewProxy.screenToLocationOrNull(maxScreen) + return if (minResult != null && maxResult != null) { + Envelope(min = minResult, max = maxResult) + } else null +} + + +/** + * Take a web map offline by downloading map areas. + * + * @since 200.8.0 + */ +@Composable +internal fun OnDemandMapAreaSelector( + localMap: ArcGISMap? = null, + uniqueMapAreaTitle: String, + showBottomSheet: Boolean, + onDismiss: () -> Unit, + onDownloadMapAreaSelected: (Envelope, String) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + if (showBottomSheet) { + ModalBottomSheet( + modifier = Modifier.systemBarsPadding(), + onDismissRequest = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + onDismiss.invoke() + } + }, + sheetState = sheetState, + sheetGesturesEnabled = false, + properties = ModalBottomSheetProperties(), + dragHandle = {}) { + OnDemandMapAreaSelectorOptions( + localMap = localMap, onDismiss = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + onDismiss.invoke() + } + }, + currentAreaName = uniqueMapAreaTitle, + onDownloadMapAreaSelected = { mapViewSize, mapAreaName -> + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + onDismiss.invoke() + calculateEnvelope(mapViewSize)?.let { downloadEnvelope -> + onDownloadMapAreaSelected.invoke(downloadEnvelope, mapAreaName) + } + } + } + ) + } + } +} + +@Composable +private fun OnDemandMapAreaSelectorOptions( + currentAreaName: String, + localMap: ArcGISMap? = null, + onDismiss: () -> Unit, + onDownloadMapAreaSelected: (IntSize, String) -> Unit +) { + var isShowingAreaNameDialog by rememberSaveable { mutableStateOf(false) } + var mapAreaName by rememberSaveable { mutableStateOf(currentAreaName) } + var mapViewSize = IntSize(0, 0) + if (isShowingAreaNameDialog) { + AreaNameDialog( + currentAreaName = currentAreaName, + onDismiss = { isShowingAreaNameDialog = false }, + onConfirm = { newAreaName -> + mapAreaName = newAreaName + isShowingAreaNameDialog = false + } + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Text( + "Select area", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.align(Alignment.Center) + ) + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.CenterEnd)) { + Icon(imageVector = Icons.Default.Close, contentDescription = "Close icon") + } + } + HorizontalDivider() + Text(text = "Pan and zoom to define the area", style = MaterialTheme.typography.labelSmall) + MapViewWithAreaSelector( + modifier = Modifier.weight(1f), + localMap = localMap, + onMapViewSizeChanged = { newSize -> mapViewSize = newSize } + ) + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(mapAreaName, style = MaterialTheme.typography.titleLarge) + OutlinedButton(onClick = { isShowingAreaNameDialog = true }) { + Icon( + imageVector = Icons.Default.Create, + contentDescription = "Rename map area button", + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.size(4.dp)) + Text("Rename", style = MaterialTheme.typography.labelSmall) + } + } + HorizontalDivider() + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Level of Detail", style = MaterialTheme.typography.titleMedium) + // TODO: Wire level of detail to drop down: + val levelOfDetails = listOf("Streets", "City", "Country") + var selectedIndex by rememberSaveable { mutableIntStateOf(0) } + DropDownMenuBox( + modifier = Modifier, + textFieldValue = levelOfDetails[selectedIndex], + dropDownItemList = levelOfDetails, + onIndexSelected = { selectedIndex = it } + ) + } + Button( + modifier = Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth(), + onClick = { onDownloadMapAreaSelected.invoke(mapViewSize, mapAreaName) } + ) { Text("Download") } + } +} + +@Composable +private fun MapViewWithAreaSelector( + localMap: ArcGISMap? = null, + onMapViewSizeChanged: (IntSize) -> Unit, + modifier: Modifier +) { + Box( + modifier + .fillMaxWidth() + .onSizeChanged { onMapViewSizeChanged.invoke(it) }) { + localMap?.let { arcGISMap -> + MapView( + modifier = Modifier.matchParentSize(), + arcGISMap = arcGISMap, + mapViewProxy = mapViewProxy + ) + } + MapAreaSelectorOverlay( + modifier = Modifier.matchParentSize() + ) + } + +} + +@Composable +private fun MapAreaSelectorOverlay( + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier) { + val w = size.width + val h = size.height + val insetX = w * 0.1f + val insetY = h * 0.1f + val cornerRadius = 32.dp.toPx() + val outerPath = Path().apply { addRect(Rect(Offset.Zero, size)) } + val innerRect = Rect( + offset = Offset(insetX, insetY), + size = Size(width = w - 2 * insetX, height = h - 2 * insetY) + ) + val innerPath = Path().apply { + addRoundRect( + RoundRect( + rect = innerRect, + cornerRadius = CornerRadius(x = cornerRadius, y = cornerRadius) + ) + ) + } + val maskPath = Path().apply { + op(outerPath, innerPath, PathOperation.Difference) + } + drawPath( + path = maskPath, + color = Color.Black.copy(alpha = 0.5f) + ) + } +} + +@Composable +private fun AreaNameDialog( + currentAreaName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var areaName by rememberSaveable { mutableStateOf(currentAreaName) } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = true) + ) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.background) + .padding(12.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Enter a name", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text("The name for the map area must be unique") }, + value = areaName, + singleLine = true, + onValueChange = { newValue -> areaName = newValue }, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = { onConfirm.invoke(areaName) }) { Text("Ok") } + } + + } + } +} + +@Composable +private fun DropDownMenuBox( + modifier: Modifier = Modifier, + textFieldValue: String, + textFieldLabel: String = "Choose level of detail", + dropDownItemList: List, + onIndexSelected: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val onDropDownTapped by interactionSource.collectIsPressedAsState() + LaunchedEffect(onDropDownTapped) { if (onDropDownTapped) expanded = true } + + Box(modifier = modifier) { + OutlinedTextField( + value = textFieldValue, + onValueChange = {}, + readOnly = true, + label = { Text(textFieldLabel) }, + interactionSource = interactionSource, + trailingIcon = { + val icon = if (expanded) + Icons.Default.ArrowDropUp + else + Icons.Default.ArrowDropDown + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.clickable { expanded = !expanded }) + }, + modifier = Modifier.width(175.dp) + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.width(175.dp) + ) { + dropDownItemList.forEachIndexed { index, text -> + DropdownMenuItem( + onClick = { + onIndexSelected(index) + expanded = false + }, + text = { Text(text) } + ) + if (index < dropDownItemList.lastIndex) { + HorizontalDivider() + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AreaNameDialogPreview() { + AreaNameDialog( + currentAreaName = "Area 1", + onDismiss = { }, + onConfirm = { } + ) +} + +@Preview(showBackground = true) +@Composable +private fun OnDemandMapAreaSelectorPreview() { + Box(Modifier.fillMaxSize()) { + OnDemandMapAreaSelectorOptions( + onDismiss = { }, + currentAreaName = "Area 1", + onDownloadMapAreaSelected = { _, _ -> } + ) + } +} + + diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt new file mode 100644 index 000000000..957324e37 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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.offline.ondemand + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Displays a list of on-demand map areas. + * + * @since 200.8.0 + */ +@Composable +internal fun OnDemandMapAreas( + onDemandMapAreasStates: List, + modifier: Modifier +) { + Column { + onDemandMapAreasStates.forEach { state -> + Text(state.title) + } + } +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt new file mode 100644 index 000000000..bc55539e7 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -0,0 +1,393 @@ +/* + * Copyright 2025 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.offline.ondemand + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.Item +import com.arcgismaps.mapping.MobileMapPackage +import com.arcgismaps.mapping.PortalItem +import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob +import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapUpdateMode +import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask +import com.arcgismaps.toolkit.offline.OfflineRepository +import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize +import com.arcgismaps.toolkit.offline.runCatchingCancellable +import com.arcgismaps.toolkit.offline.workmanager.LOG_TAG +import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.io.File +import java.util.UUID + +private data class OnDemandMapAreaConfiguration( + private val areaID: String, + private val title: String, + private val minScale: Double, + private val maxScale: Double, + private val areaOfInterest: Envelope, + private val thumbnail: Bitmap? +) + +/** + * Represents the state of a on-demand map area. + * + * @since 200.8.0 + */ +internal class OnDemandMapAreasState( + private val context: Context, + private val item: Item, + internal val onDemandAreaID: String, + internal val title: String, + internal val mapAreaEnvelope: Envelope? = null, + private val offlineMapTask: OfflineMapTask? = null, + private val onSelectionChanged: (ArcGISMap) -> Unit +) { + + private lateinit var workerUUID: UUID + + private lateinit var mobileMapPackage: MobileMapPackage + private lateinit var map: ArcGISMap + + // Enabled when a downloaded map is chosen to be displayed by pressing the "Open" button. + private var _isSelectedToOpen by mutableStateOf(false) + internal val isSelectedToOpen: Boolean + get() = _isSelectedToOpen + + // The status of the on-demand map area. + private var _status by mutableStateOf(Status.NotLoaded) + internal val status: Status + get() = _status + + // The download progress of the on-demand map area. + private var _downloadProgress: MutableState = mutableIntStateOf(0) + internal val downloadProgress: State = _downloadProgress + + private var _directorySize by mutableIntStateOf(0) + internal val directorySize: Int + get() = _directorySize + + private lateinit var scope: CoroutineScope + + private var _thumbnail by mutableStateOf(null) + internal val thumbnail: Bitmap? get() = _thumbnail ?: item.thumbnail?.image?.bitmap + + private var configuration: OnDemandMapAreaConfiguration? = null + + /** + * Loads and initializes the associated on demand map area. + * + * @since 200.8.0 + */ + internal suspend fun initialize() = runCatchingCancellable { + // TODO + } + + /** + * Initiates downloading of the associated on-demand map area for offline use. + * + * @since 200.8.0 + */ + internal fun downloadOnDemandMapArea() = + runCatchingCancellable { + scope = CoroutineScope(Dispatchers.IO) + val area = mapAreaEnvelope ?: return@runCatchingCancellable + val task = offlineMapTask ?: return@runCatchingCancellable + val portalItem = item as? PortalItem ?: return@runCatchingCancellable + + scope.launch { + _status = Status.Downloading + val offlineWorkerUUID = startOfflineMapJob( + downloadOnDemandOfflineMapJob = createOfflineMapJob( + downloadMapArea = area, + offlineMapTask = task + ) + ) + OfflineRepository.observeStatusForOnDemandWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + onDemandMapAreasState = this@OnDemandMapAreasState, + portalItem = portalItem, + offlineWorkerUUID = offlineWorkerUUID + ) + } + } + + /** + * Cancels the current coroutine scope. + * + * This function is used to clean up resources and stop any ongoing operations + * associated with this instance of [OnDemandMapAreasState]. + * + * @since 200.8.0 + */ + internal fun disposeScope() { + scope.cancel() + } + + /** + * Creates a download job for fetching the on-demand map area offline. + * + * Generates default parameters for downloading, including no updates mode and error handling settings. + * Defines a directory path where map data will be stored and creates a download job using these configurations. + * + * @param downloadMapArea The target selected map area to be downloaded offline. + * @param offlineMapTask The target [OfflineMapTask] to create the params & the job. + * @return An instance of [GenerateOfflineMapJob] configured with download parameters. + * + * @since 200.8.0 + */ + private suspend fun createOfflineMapJob( + downloadMapArea: Envelope, + offlineMapTask: OfflineMapTask + ): GenerateOfflineMapJob { + + // Create default download parameters from the offline map task + val params = offlineMapTask.createDefaultGenerateOfflineMapParameters( + areaOfInterest = downloadMapArea, + minScale = 35000.0, + maxScale = 32000.0 + ).getOrThrow().apply { + // Set the update mode to receive no updates + updateMode = GenerateOfflineMapUpdateMode.NoUpdates + continueOnErrors = false + itemInfo?.apply { + title = this@OnDemandMapAreasState.title + description = "" + } + } + + // Define the path where the map will be saved + val onDemandMapAreaDownloadDirectory = OfflineRepository.createPendingOnDemandJobPath( + context = context, + portalItemID = item.itemId, + onDemandMapAreaID = onDemandAreaID + ) + + // Create a job to download the on-demand offline map + val downloadOnDemandOfflineMapJob = offlineMapTask.createGenerateOfflineMapJob( + parameters = params, + downloadDirectoryPath = onDemandMapAreaDownloadDirectory.path + ) + + return downloadOnDemandOfflineMapJob + } + + /** + * Starts an offline map job using WorkManager for managing background tasks. + * + * Serializes the download job into JSON format, saves it to disk, and queues it as a WorkRequest + * in WorkManager. + * + * @param downloadOnDemandOfflineMapJob The on-demand offline map job to execute using WorkManager. + * + * @return A unique identifier ([UUID]) associated with this task within WorkManager's queue system. + * + * @since 200.8.0 + */ + private fun startOfflineMapJob(downloadOnDemandOfflineMapJob: GenerateOfflineMapJob): UUID { + val jsonJobFile = OfflineRepository.saveJobToDisk( + jobPath = downloadOnDemandOfflineMapJob.downloadDirectoryPath, + jobJson = downloadOnDemandOfflineMapJob.toJson() + ) + + workerUUID = OfflineRepository.createOnDemandMapAreaRequestAndQueueDownload( + context = context, + jsonJobPath = jsonJobFile.path, + onDemandMapAreaTitle = item.title + ) + + return workerUUID + } + + /** + * Removes the downloaded on-demand map area from the device. + * + * This function deletes the contents of the directory associated with the on-demand map area + * and updates the status to reflect that the area is no longer loaded. If specified, it also + * removes the offline map information from the repository. + * + * @param shouldRemoveOfflineMapInfo A lambda function that determines whether to remove offline map info. + * + * @since 200.8.0 + */ + internal fun removeDownloadedMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { + if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { + Log.d(TAG, "Deleted on-demand map area: ${mobileMapPackage.path}") + // Reset the status to reflect the deletion + _status = Status.NotLoaded + if (shouldRemoveOfflineMapInfo()) { + OfflineRepository.removeOfflineMapInfo( + context = context, + portalItemID = item.itemId + ) + } + val localScope = CoroutineScope(Dispatchers.IO) + localScope.launch { + initialize() + localScope.cancel() + } + } else { + Log.e(TAG, "Failed to delete on-demand map area: ${mobileMapPackage.path}") + } + } + + /** + * Updates the current state of this on-demand map area instance. + * + * @param newStatus The updated [Status] value representing this area's current state. + * + * @since 200.8.0 + */ + internal fun updateStatus(newStatus: Status) { + _status = newStatus + } + + internal fun updateDownloadProgress(progress: Int) { + _downloadProgress.value = progress + } + + internal fun cancelDownload() { + OfflineRepository.cancelWorkRequest(context, workerUUID) + } + + internal suspend fun createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath: String + ) { + runCatchingCancellable { + mobileMapPackage = MobileMapPackage(mobileMapPackagePath) + mobileMapPackage.load() + .onSuccess { + _directorySize = getDirectorySize(mobileMapPackagePath) + Log.d(TAG, "Mobile map package loaded successfully") + }.onFailure { exception -> + Log.e(TAG, "Error loading mobile map package", exception) + _status = Status.MmpkLoadFailure(exception) + } + map = mobileMapPackage.maps.firstOrNull() + ?: throw IllegalStateException("No maps found in the mobile map package") + }.onFailure { exception -> + Log.e(TAG, "Error loading mobile map package", exception) + _status = Status.MmpkLoadFailure(exception) + } + } + + internal fun setSelectedToOpen(selected: Boolean) { + _isSelectedToOpen = selected + if (selected) { + onSelectionChanged(map) + } + } +} + +/** + * Represents various states of a on-demand map area during its lifecycle. + * + * @since 200.8.0 + */ +// TODO: Refine status as not all the status values are being used in onDemand +// compared to Preplanned in Swift implementation. +internal sealed class Status { + + /** + * On-Demand map area not loaded. + */ + data object NotLoaded : Status() + + /** + * On-Demand map area is loading. + */ + data object Loading : Status() + + /** + * On-Demand map area failed to load. + */ + data class LoadFailure(val error: Throwable) : Status() + + /** + * On-Demand map area is packaging. + */ + data object Packaging : Status() + + /** + * On-Demand map area is packaged and ready for download. + */ + data object Packaged : Status() + + /** + * On-Demand map area packaging failed. + */ + data object PackageFailure : Status() + + /** + * On-Demand map area is being downloaded. + */ + data object Downloading : Status() + + /** + * On-Demand map area is downloaded. + */ + data object Downloaded : Status() + + /** + * On-Demand map area failed to download. + */ + data class DownloadFailure(val error: Throwable) : Status() + + /** + * Downloaded mobile map package failed to load. + */ + data class MmpkLoadFailure(val error: Throwable) : Status() + + /** + * Indicates whether the model can load the on-demand map area. + */ + val canLoadOnDemandMapArea: Boolean + get() = when (this) { + is NotLoaded, is LoadFailure, is PackageFailure -> true + is Loading, is Packaging, is Packaged, is Downloading, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false + } + + /** + * Indicates if download is allowed for this status. + */ + val allowsDownload: Boolean + get() = when (this) { + is Packaged, is DownloadFailure -> true + is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is Downloaded, is MmpkLoadFailure -> false + } + + /** + * Indicates whether the on-demand map area is downloaded. + */ + val isDownloaded: Boolean + get() = this is Downloaded +} + +private val TAG = LOG_TAG + File.separator + "OnDemandMapAreasState" diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt new file mode 100644 index 000000000..6b6d97299 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2025 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.offline.workmanager + +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.arcgismaps.tasks.JobStatus +import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + + +/** + * Executes a [GenerateOfflineMapJob] as a CoroutineWorker using WorkManager. + * Manages job execution, progress tracking, foreground notifications, and cleanup operations + * for a on-demand map download. + * + * @since 200.8.0 + */ + +internal class OnDemandMapAreaJobWorker( + private val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + private lateinit var generateOfflineMapJob: GenerateOfflineMapJob + + /** + * Retrieves the title of the map area being processed from input data. + */ + private val jobAreaTitle by lazy { + inputData.getString(key = jobAreaTitleKey) ?: "Unknown area title" + } + + /** + * Initializes a [WorkerNotification] instance for managing updates during job execution + * via notifications such as progress and final status messages. + */ + private val workerNotification by lazy { + WorkerNotification( + applicationContext = context, + jobAreaTitle = jobAreaTitle, + workerUuid = id + ) + } + + /** + * Provides the [ForegroundInfo] required to run this worker as a foreground service. Sets up + * an override for API levels below 31 to ensure backward compatibility. + * + * @return A [ForegroundInfo] instance configured with progress details. + * @since 200.8.0 + */ + override suspend fun getForegroundInfo(): ForegroundInfo { + return createForegroundInfo(progress = 0) + } + + /** + * Creates a [ForegroundInfo] object with a [progress] notification based on the given value. + * Ensures proper setup of the notification ID, type, and visibility depending on the API level. + * + * @param progress The current download progress percentage (0-100). + * @return A [ForegroundInfo] instance configured with ongoing progress details. + * @since 200.8.0 + */ + private fun createForegroundInfo(progress: Int): ForegroundInfo { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + /* notificationId = */ id.hashCode(), + /* notification = */ workerNotification.createProgressNotification(progress), + /* foregroundServiceType = */ FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo( + /* notificationId = */ id.hashCode(), + /* notification = */ workerNotification.createProgressNotification(progress) + ) + } + } + + /** + * Performs the main work operation to execute the offline map job. Handles preparation, + * execution, progress tracking, error management, and resource cleanup during job execution. + * Implements notifications for user updates. + * + * @return A [Result] indicating success or failure of the worker's operation. + * @since 200.8.0 + */ + override suspend fun doWork(): Result { + // Retrieve the JSON file path from input data + val offlineJobJsonPath = inputData.getString(jsonJobPathKey) ?: return Result.failure() + // Load the JSON file, return failure if it doesn't exist. + val offlineJobJsonFile = File(offlineJobJsonPath) + if (!offlineJobJsonFile.exists()) { + return Result.failure() + } + // Deserialize the job from the JSON file; return failure if deserialization fails + generateOfflineMapJob = GenerateOfflineMapJob.fromJsonOrNull( + json = offlineJobJsonFile.readText() + ) ?: return Result.failure() + + return try { + // Set up this worker to run as a foreground service with initial progress notification. + setForeground(createForegroundInfo(0)) + // Delete existing map package directory to ensure clean download process. + File(generateOfflineMapJob.downloadDirectoryPath).deleteRecursively() + // Start downloading the on-demand offline map job. + generateOfflineMapJob.start() + // Collect and handle job progress until completion or failure and get the result + val jobResult = withContext(Dispatchers.IO) { + coroutineScope { + val progressCollectorJob = launch { + // Collect progress until the job has completed in a success/failure + generateOfflineMapJob.progress.takeWhile { + generateOfflineMapJob.status.value != JobStatus.Failed + && generateOfflineMapJob.status.value != JobStatus.Succeeded + }.collect { progress -> + // Update the worker progress & ongoing progress notification + setProgress(workDataOf("Progress" to progress)) + setForeground(createForegroundInfo(progress)) + } + } + // Suspends until the job has completed + val result = generateOfflineMapJob.result() + // Cancel the progress collection coroutine if it is still running + progressCollectorJob.cancelAndJoin() + // Return the job result + result + } + } + // Handle success or failure of the job based on its result status. + if (jobResult.isSuccess) { + workerNotification.showStatusNotification("The download for $jobAreaTitle has completed successfully.") + val downloadOnDemandOfflineMapResult = jobResult.getOrNull() + val outputData = workDataOf( + mobileMapPackagePathKey to (downloadOnDemandOfflineMapResult?.mobileMapPackage?.path + ?: ""), + ) + Result.success(outputData) + } else { + val errorMessage = jobResult.exceptionOrNull()?.message + ?: "Unknown error during job execution" + Log.e(TAG, "Job failed internally: $errorMessage", jobResult.exceptionOrNull()) + workerNotification.showStatusNotification("The download for $jobAreaTitle failed: $errorMessage") + Result.failure(workDataOf("Error" to errorMessage)) + } + } catch (cancellationException: CancellationException) { + // Handle user/system-triggered cancellation by logging and notifying cancellation status. + Log.w(TAG, "Job cancelled.", cancellationException) + workerNotification.showStatusNotification("The download for $jobAreaTitle was cancelled") + Result.failure(workDataOf("Error" to "Job cancelled by user or system")) + } catch (exception: Exception) { + // Log unexpected exceptions and notify failure status with error details. + Log.e(TAG, "Job failed with exception: ${exception.message}", exception) + workerNotification.showStatusNotification("The download for $jobAreaTitle failed: ${exception.message}") + Result.failure(workDataOf("Error" to exception.message)) + + } finally { + withContext(NonCancellable) { + try { + // Cancel ongoing resources and delete temporary files on completion/failure. + generateOfflineMapJob.cancel().getOrThrow() + offlineJobJsonFile.delete() + } catch (e: Exception) { + Log.e(TAG, "Error during job cleanup: ${e.message}", e) + } + } + } + } +} + +private val TAG = LOG_TAG + File.separator + "OnDemandAreaJobWorker" From 0f35c3adb9f40932f486bbf8a125b63578b64e0d Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Mon, 23 Jun 2025 17:13:55 -0700 Subject: [PATCH 087/132] Init OnDemand map areas screen (#900) * refactor common code * WIP * add newline * update build.gradle * address code review comments --- toolkit/offline/build.gradle.kts | 4 +- .../toolkit/offline/OfflineMapAreas.kt | 22 +- .../toolkit/offline/OfflineMapState.kt | 7 +- .../toolkit/offline/OfflineRepository.kt | 29 +-- .../toolkit/offline/internal/utils/Buttons.kt | 132 +++++++++++ .../toolkit/offline/internal/utils/Utils.kt | 2 +- .../offline/ondemand/OnDemandMapAreas.kt | 221 +++++++++++++++++- .../offline/ondemand/OnDemandMapAreasState.kt | 38 +-- .../offline/preplanned/PreplannedMapAreas.kt | 96 ++------ .../preplanned/PreplannedMapAreasState.kt | 50 ++-- .../offline/ui/MapAreaDetailsScreen.kt | 51 ++-- 11 files changed, 475 insertions(+), 177 deletions(-) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt diff --git a/toolkit/offline/build.gradle.kts b/toolkit/offline/build.gradle.kts index 65c53aed2..81422405e 100644 --- a/toolkit/offline/build.gradle.kts +++ b/toolkit/offline/build.gradle.kts @@ -87,7 +87,9 @@ apiValidation { "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$MapAreaDetailsScreenKt", "com.arcgismaps.toolkit.offline.ui.material3.ComposableSingletons\$ModalBottomSheetKt", "com.arcgismaps.toolkit.offline.ui.ComposableSingletons\$OfflineMapAreasStatusScreenKt", - "com.arcgismaps.toolkit.offline.ondemand.ComposableSingletons\$OnDemandMapAreaSelectorKt" + "com.arcgismaps.toolkit.offline.ondemand.ComposableSingletons\$OnDemandMapAreaSelectorKt", + "com.arcgismaps.toolkit.offline.internal.utils.ComposableSingletons\$ButtonsKt", + "com.arcgismaps.toolkit.offline.ondemand.ComposableSingletons\$OnDemandMapAreasKt" ) ignoredClasses.addAll(composableSingletons) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index e72908b0b..e09a002df 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaSelector +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreas import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreas import com.arcgismaps.toolkit.offline.ui.EmptyOnDemandOfflineAreas import com.arcgismaps.toolkit.offline.ui.EmptyPreplannedOfflineAreas @@ -112,11 +113,22 @@ public fun OfflineMapAreas( } // If not preplanned state & map has offline mode enabled, display the on demand areas OfflineMapMode.OnDemand, OfflineMapMode.Unknown -> { - EmptyOnDemandOfflineAreas( - onAdd = { - isOnDemandMapAreaSelectorVisible = true - } - ) + if (!isOnDemandMapAreaSelectorVisible && offlineMapState.onDemandMapAreaStates.isNotEmpty()) { + OnDemandMapAreas( + onDemandMapAreasStates = offlineMapState.onDemandMapAreaStates, + modifier = modifier, + onDownloadNewMapArea = { + isOnDemandMapAreaSelectorVisible = true + } + ) + } + if (offlineMapState.onDemandMapAreaStates.isEmpty()) { + EmptyOnDemandOfflineAreas( + onAdd = { + isOnDemandMapAreaSelectorVisible = true + } + ) + } OnDemandMapAreaSelector( localMap = offlineMapState.localMap, showBottomSheet = isOnDemandMapAreaSelectorVisible, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 2f9dfc52b..3fea0ae76 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -36,7 +36,7 @@ import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState -import com.arcgismaps.toolkit.offline.preplanned.Status +import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException import java.io.File @@ -209,7 +209,7 @@ public class OfflineMapState( preplannedMapAreaID = mapArea.portalItem.itemId ) if (preplannedPath != null) { - preplannedMapAreaState.updateStatus(Status.Downloaded) + preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( mobileMapPackagePath = preplannedPath ) @@ -296,7 +296,7 @@ public class OfflineMapState( preplannedMapAreaID = areaItemId ) if (preplannedPath != null) { - preplannedMapAreaState.updateStatus(Status.Downloaded) + preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( mobileMapPackagePath = preplannedPath ) @@ -312,6 +312,7 @@ public class OfflineMapState( */ public fun resetSelectedMapArea() { _preplannedMapAreaStates.forEach { it.setSelectedToOpen(false) } + _onDemandMapAreaStates.forEach { it.setSelectedToOpen(false) } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index 65efe01e1..a2abbb0b5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -28,8 +28,9 @@ import androidx.work.WorkManager import androidx.work.workDataOf import com.arcgismaps.mapping.PortalItem import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState +import com.arcgismaps.toolkit.offline.ondemand.OnDemandStatus import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState -import com.arcgismaps.toolkit.offline.preplanned.Status +import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import com.arcgismaps.toolkit.offline.workmanager.OnDemandMapAreaJobWorker import com.arcgismaps.toolkit.offline.workmanager.PreplannedMapAreaJobWorker @@ -454,7 +455,7 @@ public object OfflineRepository { when (workInfo.state) { // if work completed successfully WorkInfo.State.SUCCEEDED -> { - preplannedMapAreaState.updateStatus(Status.Downloaded) + preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> // using the pending path, move the result to final destination path val destDir = movePreplannedJobResultToDestination(context, path) @@ -479,7 +480,7 @@ public object OfflineRepository { } } ?: run { preplannedMapAreaState.updateStatus( - Status.MmpkLoadFailure( + PreplannedStatus.MmpkLoadFailure( Exception("Mobile Map Package path is null") ) ) @@ -493,7 +494,7 @@ public object OfflineRepository { // until WorkManager auto-prunes workManager.pruneWork() preplannedMapAreaState.updateStatus( - Status.DownloadFailure( + PreplannedStatus.DownloadFailure( Exception( "${workInfo.tags}: FAILED. Reason: " + "${workInfo.outputData.getString("Error")}" @@ -504,7 +505,7 @@ public object OfflineRepository { } // if the work is currently in progress WorkInfo.State.RUNNING -> { - preplannedMapAreaState.updateStatus(Status.Downloading) + preplannedMapAreaState.updateStatus(PreplannedStatus.Downloading) } // don't have to handle other states else -> {} @@ -548,8 +549,7 @@ public object OfflineRepository { when (workInfo.state) { // if work completed successfully WorkInfo.State.SUCCEEDED -> { - // TODO: Wire in status handler - // onDemandMapAreasState.updateStatus(Status.Downloaded) + onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> // using the pending path, move the result to final destination path val destDir = moveOnDemandJobResultToDestination(context, path) @@ -573,14 +573,11 @@ public object OfflineRepository { } } } ?: run { - // TODO: Wire in status handler - /* onDemandMapAreasState.updateStatus( - Status.MmpkLoadFailure( + OnDemandStatus.MmpkLoadFailure( Exception("Mobile Map Package path is null") ) ) - */ } onDemandMapAreasState.disposeScope() } @@ -590,23 +587,19 @@ public object OfflineRepository { // otherwise, the observer will emit the WorkInfo on every launch // until WorkManager auto-prunes workManager.pruneWork() - // TODO: Wire in status handler - /* - preplannedMapAreaState.updateStatus( - Status.DownloadFailure( + onDemandMapAreasState.updateStatus( + OnDemandStatus.DownloadFailure( Exception( "${workInfo.tags}: FAILED. Reason: " + "${workInfo.outputData.getString("Error")}" ) ) ) - */ onDemandMapAreasState.disposeScope() } // if the work is currently in progress WorkInfo.State.RUNNING -> { - // TODO: Wire in status handler - // preplannedMapAreaState.updateStatus(Status.Downloading) + onDemandMapAreasState.updateStatus(OnDemandStatus.Downloading) } // don't have to handle other states else -> {} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt new file mode 100644 index 000000000..43fe4bf1c --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -0,0 +1,132 @@ +/* + * + * Copyright 2025 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.offline.internal.utils + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.arcgismaps.toolkit.offline.R + +@Composable +internal fun DownloadButton(onClick: () -> Unit) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = onClick + ) { + Icon( + imageVector = Icons.Filled.Download, + contentDescription = stringResource(R.string.download), + tint = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: () -> Unit) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(30.dp) + .clickable { onClick.invoke() } + ) { + // Circular Progress Indicator + CircularProgressIndicator( + progress = { progress / 100f }, + ) + // Square Button to cancel the download + Box( + modifier = Modifier + .size(10.dp) + .clip(RectangleShape) + .background(ButtonDefaults.buttonColors().containerColor), + ) + } +} + +@Composable +internal fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { + Button( + modifier = Modifier.widthIn(max = 80.dp), // restricts max width + contentPadding = PaddingValues(horizontal = 10.dp), + enabled = isEnabled, + onClick = onClick + ) { + Text( + text = stringResource(R.string.open), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ButtonsPreview() { + MaterialTheme { + Surface { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + DownloadButton( + onClick = { } + ) + CancelDownloadButtonWithProgressIndicator( + progress = 55, + onClick = { } + ) + OpenButton( + isEnabled = true, + onClick = { } + ) + OpenButton( + isEnabled = false, + onClick = { } + ) + } + } + } +} + diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index 9b9257f63..b23cc9adf 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -18,8 +18,8 @@ package com.arcgismaps.toolkit.offline.internal.utils -import java.io.File import android.text.Html +import java.io.File internal fun getDirectorySize(path: String): Int { val file = File(path) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 957324e37..a6e9df2b3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -17,10 +17,55 @@ package com.arcgismaps.toolkit.offline.ondemand +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ImageNotSupported +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat.getString +import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator +import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton +import com.arcgismaps.toolkit.offline.internal.utils.OpenButton +import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet +import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState +import kotlinx.coroutines.launch /** * Displays a list of on-demand map areas. @@ -30,11 +75,179 @@ import androidx.compose.ui.Modifier @Composable internal fun OnDemandMapAreas( onDemandMapAreasStates: List, - modifier: Modifier + modifier: Modifier, + onDownloadNewMapArea: () -> Unit ) { - Column { - onDemandMapAreasStates.forEach { state -> - Text(state.title) + var showSheet by rememberSaveable { mutableStateOf(false) } + var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } + val selectedOnDemandMapAreaState = selectedIndex.takeIf { it in onDemandMapAreasStates.indices } + ?.let { onDemandMapAreasStates[it] } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val scope = rememberCoroutineScope() + + // Show the modal bottom sheet if needed + if (showSheet && selectedOnDemandMapAreaState != null) { + MapAreaDetailsBottomSheet( + showSheet = true, + sheetState = sheetState, + scope = scope, + onDismiss = { showSheet = false }, + thumbnail = selectedOnDemandMapAreaState.thumbnail?.asImageBitmap(), + title = selectedOnDemandMapAreaState.title, + description = null, + size = selectedOnDemandMapAreaState.directorySize, + isAvailableToDownload = selectedOnDemandMapAreaState.status.allowsDownload, + onStartDownload = { + selectedOnDemandMapAreaState.downloadOnDemandMapArea() + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + showSheet = false + } + } + }, + isDeletable = selectedOnDemandMapAreaState.status.isDownloaded && !selectedOnDemandMapAreaState.isSelectedToOpen, + onDeleteDownload = { + selectedOnDemandMapAreaState.removeDownloadedMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } + } + ) + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.map_areas), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp) + ) + LazyColumn(modifier = Modifier) { + itemsIndexed(onDemandMapAreasStates) { index, state -> + Row( + modifier = Modifier + .clickable { + selectedIndex = index + showSheet = true + }, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Display the thumbnail image if available, otherwise show a placeholder icon + val thumbnail = state.thumbnail?.asImageBitmap() + Box( + modifier = Modifier + .padding(vertical = 8.dp) + .size(64.dp) // Ensures the image is square + .clip(RoundedCornerShape(10.dp)), // Applies rounded corners + ) { + if (thumbnail != null) { + Image( + modifier = Modifier.fillMaxSize(), + bitmap = thumbnail, + contentDescription = stringResource(R.string.thumbnail_description), + contentScale = ContentScale.Crop + ) + } else { + Icon( + modifier = Modifier + .size(32.dp) // half the size of the image Box + .align(Alignment.Center), + imageVector = Icons.Default.ImageNotSupported, + contentDescription = stringResource(id = R.string.no_image_available), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Column(modifier = Modifier.weight(1f)) { + // Display the title with a maximum of one line + Text( + text = state.title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 6.dp), + maxLines = 1, // Restrict to one line + overflow = TextOverflow.Ellipsis // Add ellipses if the text overflows + ) + // Display the status string + val statusString = if (state.isSelectedToOpen) { + stringResource(R.string.currently_open) + } else { + getOnDemandMapAreaStatusString( + context = LocalContext.current, + status = state.status + ) + } + Text( + text = statusString, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Normal + ), + maxLines = 1, // Restrict to one lines + ) + } + // Display the action button based on the status + when { + state.status.allowsDownload -> { + DownloadButton { + if (state.status.allowsDownload) { + state.downloadOnDemandMapArea() + } + } + } + + state.status == OnDemandStatus.Downloading -> { + CancelDownloadButtonWithProgressIndicator(state.downloadProgress.value) { + state.cancelDownload() + } + } + + state.status.isDownloaded -> { + OpenButton(!state.isSelectedToOpen) { + // Unselect all, then select this one + onDemandMapAreasStates.forEach { it.setSelectedToOpen(false) } + state.setSelectedToOpen(true) + } + } + } + } + if (state.onDemandAreaID != onDemandMapAreasStates.last().onDemandAreaID) { + HorizontalDivider(modifier = Modifier.padding(start = 80.dp)) + } + } + } + Spacer(Modifier.height(12.dp)) + // add a button to add a new on demand map area + Button(onClick = onDownloadNewMapArea) { + Text( + text = stringResource(R.string.add_map_area), + style = MaterialTheme.typography.labelSmall + ) } } } + +/** + * Retrieves a user-friendly status string for a on demand map area based on its current status. + * + * @param context The `Context` used to access application-specific resources like strings. + * @param status The current state of the on demand map area, represented by an instance of `Status`. + * @return A localized string corresponding to the given status. + * @since 200.8.0 + */ +private fun getOnDemandMapAreaStatusString(context: Context, status: OnDemandStatus): String { + return when (status) { + OnDemandStatus.NotLoaded, OnDemandStatus.Loading -> getString(context, R.string.loading) + is OnDemandStatus.LoadFailure, is OnDemandStatus.MmpkLoadFailure -> getString(context, R.string.loading_failed) + is OnDemandStatus.DownloadFailure -> getString(context, R.string.download_failed) + OnDemandStatus.Downloaded -> getString(context, R.string.downloaded) + OnDemandStatus.Downloading -> getString(context, R.string.downloading) + OnDemandStatus.PackageFailure -> getString(context, R.string.packaging_failed) + OnDemandStatus.Packaged -> getString(context, R.string.ready_to_download) + OnDemandStatus.Packaging -> getString(context, R.string.packaging) + } +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index bc55539e7..61e13a1e3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -81,8 +81,8 @@ internal class OnDemandMapAreasState( get() = _isSelectedToOpen // The status of the on-demand map area. - private var _status by mutableStateOf(Status.NotLoaded) - internal val status: Status + private var _status by mutableStateOf(OnDemandStatus.NotLoaded) + internal val status: OnDemandStatus get() = _status // The download progress of the on-demand map area. @@ -122,7 +122,7 @@ internal class OnDemandMapAreasState( val portalItem = item as? PortalItem ?: return@runCatchingCancellable scope.launch { - _status = Status.Downloading + _status = OnDemandStatus.Downloading val offlineWorkerUUID = startOfflineMapJob( downloadOnDemandOfflineMapJob = createOfflineMapJob( downloadMapArea = area, @@ -241,7 +241,7 @@ internal class OnDemandMapAreasState( if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { Log.d(TAG, "Deleted on-demand map area: ${mobileMapPackage.path}") // Reset the status to reflect the deletion - _status = Status.NotLoaded + _status = OnDemandStatus.NotLoaded if (shouldRemoveOfflineMapInfo()) { OfflineRepository.removeOfflineMapInfo( context = context, @@ -261,11 +261,11 @@ internal class OnDemandMapAreasState( /** * Updates the current state of this on-demand map area instance. * - * @param newStatus The updated [Status] value representing this area's current state. + * @param newStatus The updated [OnDemandStatus] value representing this area's current state. * * @since 200.8.0 */ - internal fun updateStatus(newStatus: Status) { + internal fun updateStatus(newStatus: OnDemandStatus) { _status = newStatus } @@ -288,13 +288,13 @@ internal class OnDemandMapAreasState( Log.d(TAG, "Mobile map package loaded successfully") }.onFailure { exception -> Log.e(TAG, "Error loading mobile map package", exception) - _status = Status.MmpkLoadFailure(exception) + _status = OnDemandStatus.MmpkLoadFailure(exception) } map = mobileMapPackage.maps.firstOrNull() ?: throw IllegalStateException("No maps found in the mobile map package") }.onFailure { exception -> Log.e(TAG, "Error loading mobile map package", exception) - _status = Status.MmpkLoadFailure(exception) + _status = OnDemandStatus.MmpkLoadFailure(exception) } } @@ -313,57 +313,57 @@ internal class OnDemandMapAreasState( */ // TODO: Refine status as not all the status values are being used in onDemand // compared to Preplanned in Swift implementation. -internal sealed class Status { +internal sealed class OnDemandStatus { /** * On-Demand map area not loaded. */ - data object NotLoaded : Status() + data object NotLoaded : OnDemandStatus() /** * On-Demand map area is loading. */ - data object Loading : Status() + data object Loading : OnDemandStatus() /** * On-Demand map area failed to load. */ - data class LoadFailure(val error: Throwable) : Status() + data class LoadFailure(val error: Throwable) : OnDemandStatus() /** * On-Demand map area is packaging. */ - data object Packaging : Status() + data object Packaging : OnDemandStatus() /** * On-Demand map area is packaged and ready for download. */ - data object Packaged : Status() + data object Packaged : OnDemandStatus() /** * On-Demand map area packaging failed. */ - data object PackageFailure : Status() + data object PackageFailure : OnDemandStatus() /** * On-Demand map area is being downloaded. */ - data object Downloading : Status() + data object Downloading : OnDemandStatus() /** * On-Demand map area is downloaded. */ - data object Downloaded : Status() + data object Downloaded : OnDemandStatus() /** * On-Demand map area failed to download. */ - data class DownloadFailure(val error: Throwable) : Status() + data class DownloadFailure(val error: Throwable) : OnDemandStatus() /** * Downloaded mobile map package failed to load. */ - data class MmpkLoadFailure(val error: Throwable) : Status() + data class MmpkLoadFailure(val error: Throwable) : OnDemandStatus() /** * Indicates whether the model can load the on-demand map area. diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 8da4ae8db..e558a68a1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -20,35 +20,31 @@ package com.arcgismaps.toolkit.offline.preplanned import android.content.Context import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.ImageNotSupported -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -62,13 +58,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getString import com.arcgismaps.toolkit.offline.R -import androidx.compose.ui.graphics.RectangleShape +import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator +import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton +import com.arcgismaps.toolkit.offline.internal.utils.OpenButton import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState import kotlinx.coroutines.launch /** @@ -210,7 +204,7 @@ internal fun PreplannedMapAreas( } } - state.status == Status.Downloading -> { + state.status == PreplannedStatus.Downloading -> { CancelDownloadButtonWithProgressIndicator(state.downloadProgress.value) { state.cancelDownload() } @@ -241,67 +235,15 @@ internal fun PreplannedMapAreas( * @return A localized string corresponding to the given status. * @since 200.8.0 */ -private fun getPreplannedMapAreaStatusString(context: Context, status: Status): String { +private fun getPreplannedMapAreaStatusString(context: Context, status: PreplannedStatus): String { return when (status) { - Status.NotLoaded, Status.Loading -> getString(context, R.string.loading) - is Status.LoadFailure, is Status.MmpkLoadFailure -> getString(context, R.string.loading_failed) - is Status.DownloadFailure -> getString(context, R.string.download_failed) - Status.Downloaded -> getString(context, R.string.downloaded) - Status.Downloading -> getString(context, R.string.downloading) - Status.PackageFailure -> getString(context, R.string.packaging_failed) - Status.Packaged -> getString(context, R.string.ready_to_download) - Status.Packaging -> getString(context, R.string.packaging) - } -} - -@Composable -private fun DownloadButton(onClick: () -> Unit) { - IconButton( - modifier = Modifier.size(30.dp), - onClick = onClick - ) { - Icon( - imageVector = Icons.Filled.Download, - contentDescription = stringResource(R.string.download), - tint = MaterialTheme.colorScheme.primary, - ) - } -} - -@Composable -internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: () -> Unit) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(30.dp) - .clickable { onClick.invoke() } - ) { - // Circular Progress Indicator - CircularProgressIndicator( - progress = { progress / 100f }, - ) - // Square Button to cancel the download - Box( - modifier = Modifier - .size(10.dp) - .clip(RectangleShape) - .background(ButtonDefaults.buttonColors().containerColor), - ) - } -} - -@Composable -private fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { - Button( - modifier = Modifier.widthIn(max = 80.dp), // restricts max width - contentPadding = PaddingValues(horizontal = 10.dp), - enabled = isEnabled, - onClick = onClick - ) { - Text( - text = stringResource(R.string.open), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + PreplannedStatus.NotLoaded, PreplannedStatus.Loading -> getString(context, R.string.loading) + is PreplannedStatus.LoadFailure, is PreplannedStatus.MmpkLoadFailure -> getString(context, R.string.loading_failed) + is PreplannedStatus.DownloadFailure -> getString(context, R.string.download_failed) + PreplannedStatus.Downloaded -> getString(context, R.string.downloaded) + PreplannedStatus.Downloading -> getString(context, R.string.downloading) + PreplannedStatus.PackageFailure -> getString(context, R.string.packaging_failed) + PreplannedStatus.Packaged -> getString(context, R.string.ready_to_download) + PreplannedStatus.Packaging -> getString(context, R.string.packaging) } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index de05172b5..5acd9a7c5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -72,8 +72,8 @@ internal class PreplannedMapAreaState( get() = _isSelectedToOpen // The status of the preplanned map area. - private var _status by mutableStateOf(Status.NotLoaded) - internal val status: Status + private var _status by mutableStateOf(PreplannedStatus.NotLoaded) + internal val status: PreplannedStatus get() = _status // The download progress of the preplanned map area. @@ -108,13 +108,13 @@ internal class PreplannedMapAreaState( preplannedMapArea?.retryLoad() ?.onSuccess { _status = try { - Status.fromPackagingStatus(preplannedMapArea.packagingStatus) + PreplannedStatus.fromPackagingStatus(preplannedMapArea.packagingStatus) } catch (illegalStateException: IllegalStateException) { // Note: Packaging status is `Unknown` for compatibility with legacy webmaps // that have incomplete metadata. We throw an illegalStateException when Package // Status is unknown. We can safely assume that the preplanned map area is packaged. // If the area loads, then we know for certain the status is complete. - Status.Packaged + PreplannedStatus.Packaged } // Load the thumbnail _thumbnail = preplannedMapArea.portalItem.thumbnail?.let { loadableImage -> @@ -139,7 +139,7 @@ internal class PreplannedMapAreaState( val portalItem = item as? PortalItem ?: return@runCatchingCancellable scope.launch { - _status = Status.Downloading + _status = PreplannedStatus.Downloading val offlineWorkerUUID = startOfflineMapJob( downloadPreplannedOfflineMapJob = createOfflineMapJob( preplannedMapArea = area, @@ -252,7 +252,7 @@ internal class PreplannedMapAreaState( if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { Log.d(TAG, "Deleted preplanned map area: ${mobileMapPackage.path}") // Reset the status to reflect the deletion - _status = Status.NotLoaded + _status = PreplannedStatus.NotLoaded if (shouldRemoveOfflineMapInfo()) { OfflineRepository.removeOfflineMapInfo( context = context, @@ -272,11 +272,11 @@ internal class PreplannedMapAreaState( /** * Updates the current state of this preplanned map area instance. * - * @param newStatus The updated [Status] value representing this area's current state. + * @param newStatus The updated [PreplannedStatus] value representing this area's current state. * * @since 200.8.0 */ - internal fun updateStatus(newStatus: Status) { + internal fun updateStatus(newStatus: PreplannedStatus) { _status = newStatus } @@ -299,13 +299,13 @@ internal class PreplannedMapAreaState( Log.d(TAG, "Mobile map package loaded successfully") }.onFailure { exception -> Log.e(TAG, "Error loading mobile map package", exception) - _status = Status.MmpkLoadFailure(exception) + _status = PreplannedStatus.MmpkLoadFailure(exception) } map = mobileMapPackage.maps.firstOrNull() ?: throw IllegalStateException("No maps found in the mobile map package") }.onFailure { exception -> Log.e(TAG, "Error loading mobile map package", exception) - _status = Status.MmpkLoadFailure(exception) + _status = PreplannedStatus.MmpkLoadFailure(exception) } } @@ -322,69 +322,69 @@ internal class PreplannedMapAreaState( * * @since 200.8.0 */ -internal sealed class Status { +internal sealed class PreplannedStatus { /** * Preplanned map area not loaded. */ - data object NotLoaded : Status() + data object NotLoaded : PreplannedStatus() /** * Preplanned map area is loading. */ - data object Loading : Status() + data object Loading : PreplannedStatus() /** * Preplanned map area failed to load. */ - data class LoadFailure(val error: Throwable) : Status() + data class LoadFailure(val error: Throwable) : PreplannedStatus() /** * Preplanned map area is packaging. */ - data object Packaging : Status() + data object Packaging : PreplannedStatus() /** * Preplanned map area is packaged and ready for download. */ - data object Packaged : Status() + data object Packaged : PreplannedStatus() /** * Preplanned map area packaging failed. */ - data object PackageFailure : Status() + data object PackageFailure : PreplannedStatus() /** * Preplanned map area is being downloaded. */ - data object Downloading : Status() + data object Downloading : PreplannedStatus() /** * Preplanned map area is downloaded. */ - data object Downloaded : Status() + data object Downloaded : PreplannedStatus() /** * Preplanned map area failed to download. */ - data class DownloadFailure(val error: Throwable) : Status() + data class DownloadFailure(val error: Throwable) : PreplannedStatus() /** * Downloaded mobile map package failed to load. */ - data class MmpkLoadFailure(val error: Throwable) : Status() + data class MmpkLoadFailure(val error: Throwable) : PreplannedStatus() companion object { /** - * Maps a given packaging status to the corresponding [Status] type. + * Maps a given packaging status to the corresponding [PreplannedStatus] type. * - * @param packagingStatus The packaging status to translate into a [Status]. - * @return A [Status] object representing the translated state of the preplanned map area. + * @param packagingStatus The packaging status to translate into a [PreplannedStatus]. + * @return A [PreplannedStatus] object representing the translated state of the preplanned map area. * @throws IllegalStateException if the status is unknown or unrecognized. * * @since 200.8.0 */ - fun fromPackagingStatus(packagingStatus: PreplannedPackagingStatus): Status { + fun fromPackagingStatus(packagingStatus: PreplannedPackagingStatus): PreplannedStatus { return when (packagingStatus) { PreplannedPackagingStatus.Processing -> Packaging PreplannedPackagingStatus.Failed -> PackageFailure diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index bd2ef6461..45fd3cf9f 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -69,7 +69,7 @@ internal fun MapAreaDetailsBottomSheet( onDismiss: () -> Unit, thumbnail: ImageBitmap?, title: String, - description: String, + description: String?, size: Int, isAvailableToDownload: Boolean, isDeletable: Boolean, @@ -103,7 +103,7 @@ internal fun MapAreaDetailsBottomSheet( internal fun MapAreaDetailsScreen( thumbnail: ImageBitmap?, title: String, - description: String, + description: String?, size: Int, isAvailableToDownload: Boolean, isDeletable: Boolean, @@ -114,6 +114,7 @@ internal fun MapAreaDetailsScreen( Column( modifier = Modifier .padding(16.dp) + .fillMaxWidth() .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -152,30 +153,32 @@ internal fun MapAreaDetailsScreen( if (size != 0) { Text(text = "Size: ${formatSize(size)}", style = MaterialTheme.typography.bodyMedium) } - // Description label - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(id = R.string.description), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier - .align(Alignment.Start) - .padding(start = 14.dp) - ) - // Description - Spacer(modifier = Modifier.height(4.dp)) - Box( - modifier = Modifier - .background( - MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(10.dp) - ) - .padding(12.dp) - .fillMaxWidth() - ) { + + if (description != null) { + // Description label + Spacer(modifier = Modifier.height(24.dp)) Text( - text = htmlToPlainText(description), - style = MaterialTheme.typography.bodyMedium + text = stringResource(id = R.string.description), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .align(Alignment.Start) + .padding(start = 14.dp) ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(10.dp) + ) + .padding(12.dp) + .fillMaxWidth() + ) { + Text( + text = htmlToPlainText(description), + style = MaterialTheme.typography.bodyMedium + ) + } } Spacer(modifier = Modifier.height(20.dp)) From 3a3b41b2b6611f4a241a80c04e35ef93fc420f02 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 23 Jun 2025 18:30:01 -0700 Subject: [PATCH 088/132] Level of detail (#902) --- .../toolkit/offline/OfflineMapAreas.kt | 5 +- .../toolkit/offline/OfflineMapState.kt | 5 +- .../offline/internal/utils/ZoomLevel.kt | 139 ++++++++++++++++++ .../ondemand/OnDemandMapAreaSelector.kt | 86 +++++------ .../offline/ondemand/OnDemandMapAreasState.kt | 6 +- .../offline/src/main/res/values/strings.xml | 25 ++++ 6 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/ZoomLevel.kt diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index e09a002df..77fa21441 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -136,13 +136,14 @@ public fun OfflineMapAreas( onDismiss = { isOnDemandMapAreaSelectorVisible = false }, - onDownloadMapAreaSelected = { envelope, mapAreaTitle -> + onDownloadMapAreaSelected = { envelope, mapAreaTitle, zoomLevel -> scope.launch { // TODO: This should be triggered from the area state val onDemandMapAreaState = offlineMapState.createOnDemandMapAreasState( context = context, envelope = envelope, - mapAreaTitle = mapAreaTitle + mapAreaTitle = mapAreaTitle, + zoomLevel = zoomLevel ) // Start the on-demand download onDemandMapAreaState.downloadOnDemandMapArea() diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 3fea0ae76..a970a8866 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -34,6 +34,7 @@ import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea +import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus @@ -246,7 +247,8 @@ public class OfflineMapState( internal fun createOnDemandMapAreasState( context: Context, envelope: Envelope, - mapAreaTitle: String + mapAreaTitle: String, + zoomLevel: ZoomLevel ): OnDemandMapAreasState { val onDemandMapAreasState = OnDemandMapAreasState( context = context, @@ -254,6 +256,7 @@ public class OfflineMapState( onDemandAreaID = UUID.randomUUID().toString(), title = mapAreaTitle, mapAreaEnvelope = envelope, + zoomLevel = zoomLevel, offlineMapTask = offlineMapTask, onSelectionChanged = onSelectionChanged ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/ZoomLevel.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/ZoomLevel.kt new file mode 100644 index 000000000..aacf9e092 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/ZoomLevel.kt @@ -0,0 +1,139 @@ +/* + * + * Copyright 2025 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.offline.internal.utils + +import androidx.annotation.StringRes +import com.arcgismaps.arcgisservices.LevelOfDetail +import com.arcgismaps.toolkit.offline.R + +/** + * An enumeration of predefined cache scales, each corresponding to a specific + * level of detail. + * + * @param descriptionResId The string resource ID for the description of the cache scale. + * @param levelOfDetail The [LevelOfDetail] object containing the level, resolution, and scale. + * @since 200.8.0 + */ +internal enum class ZoomLevel( + @StringRes val descriptionResId: Int, + private val levelOfDetail: LevelOfDetail? = null +) { + ROOM( + R.string.room, + LevelOfDetail(level = 23, resolution = 0.01866138385297604, scale = 70.5310735) + ), + ROOMS( + R.string.rooms, + LevelOfDetail(level = 22, resolution = 0.03732276770595208, scale = 141.062147) + ), + HOUSE_PROPERTY( + R.string.house_property, + LevelOfDetail(level = 21, resolution = 0.07464553541190416, scale = 282.124294) + ), + HOUSES( + R.string.houses, + LevelOfDetail(level = 20, resolution = 0.14929107082380833, scale = 564.248588) + ), + SMALL_BUILDING( + R.string.small_building, + LevelOfDetail(level = 19, resolution = 0.2985821417799086, scale = 1128.497175) + ), + BUILDING( + R.string.building, + LevelOfDetail(level = 18, resolution = 0.5971642835598172, scale = 2256.994353) + ), + BUILDINGS( + R.string.buildings, + LevelOfDetail(level = 17, resolution = 1.1943285668550503, scale = 4513.988705) + ), + STREET( + R.string.street, + LevelOfDetail(level = 16, resolution = 2.388657133974685, scale = 9027.977411) + ), + STREETS( + R.string.streets, + LevelOfDetail(level = 15, resolution = 4.77731426794937, scale = 18055.954822) + ), + NEIGHBORHOOD( + R.string.neighborhood, + LevelOfDetail(level = 14, resolution = 9.554628535634155, scale = 36111.909643) + ), + TOWN( + R.string.town, + LevelOfDetail(level = 13, resolution = 19.10925707126831, scale = 72223.819286) + ), + CITY( + R.string.city, + LevelOfDetail(level = 12, resolution = 38.21851414253662, scale = 144447.638572) + ), + CITIES( + R.string.cities, + LevelOfDetail(level = 11, resolution = 76.43702828507324, scale = 288895.277144) + ), + METROPOLITAN_AREA( + R.string.metropolitan_area, + LevelOfDetail(level = 10, resolution = 152.87405657041106, scale = 577790.554289) + ), + COUNTY( + R.string.county, + LevelOfDetail(level = 9, resolution = 305.74811314055756, scale = 1155581.108577) + ), + COUNTIES( + R.string.counties, + LevelOfDetail(level = 8, resolution = 611.4962262813797, scale = 2311162.217155) + ), + STATE_PROVINCE( + R.string.state_province, + LevelOfDetail(level = 7, resolution = 1222.992452562495, scale = 4622324.434309) + ), + STATES_PROVINCES( + R.string.states_provinces, + LevelOfDetail(level = 6, resolution = 2445.98490512499, scale = 9244648.868618) + ), + COUNTRIES_SMALL( + R.string.countries_small, + LevelOfDetail(level = 5, resolution = 4891.96981024998, scale = 18489297.737236) + ), + COUNTRIES_BIG( + R.string.countries_big, + LevelOfDetail(level = 4, resolution = 9783.93962049996, scale = 36978595.474472) + ), + CONTINENT( + R.string.continent, + LevelOfDetail(level = 3, resolution = 19567.87924099992, scale = 73957190.948944) + ), + WORLD_SMALL( + R.string.world_small, + LevelOfDetail(level = 2, resolution = 39135.75848200009, scale = 147914381.897889) + ), + WORLD_BIG( + R.string.world_big, + LevelOfDetail(level = 1, resolution = 78271.51696399994, scale = 295828763.795777) + ), + WORLD( + R.string.world, + LevelOfDetail(level = 0, resolution = 156543.03392800014, scale = 591657527.591555) + ); + + /** + * The scale value of the level of detail. + */ + val scale: Double + get() = this.levelOfDetail?.scale ?: 0.0 +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 741d18265..20a7ca253 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -28,16 +28,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Create import androidx.compose.material3.Button @@ -54,7 +53,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -72,6 +70,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathOperation import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -82,6 +83,8 @@ import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.view.ScreenCoordinate import com.arcgismaps.toolkit.geoviewcompose.MapView import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheetProperties import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState @@ -116,7 +119,7 @@ internal fun OnDemandMapAreaSelector( uniqueMapAreaTitle: String, showBottomSheet: Boolean, onDismiss: () -> Unit, - onDownloadMapAreaSelected: (Envelope, String) -> Unit + onDownloadMapAreaSelected: (Envelope, String, ZoomLevel) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -144,13 +147,11 @@ internal fun OnDemandMapAreaSelector( } }, currentAreaName = uniqueMapAreaTitle, - onDownloadMapAreaSelected = { mapViewSize, mapAreaName -> - scope.launch { - sheetState.hide() - }.invokeOnCompletion { + onDownloadMapAreaSelected = { mapViewSize, mapAreaName, zoomLevel -> + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss.invoke() - calculateEnvelope(mapViewSize)?.let { downloadEnvelope -> - onDownloadMapAreaSelected.invoke(downloadEnvelope, mapAreaName) + calculateEnvelope(mapViewSize)?.let { downloadArea -> + onDownloadMapAreaSelected.invoke(downloadArea, mapAreaName, zoomLevel) } } } @@ -164,14 +165,15 @@ private fun OnDemandMapAreaSelectorOptions( currentAreaName: String, localMap: ArcGISMap? = null, onDismiss: () -> Unit, - onDownloadMapAreaSelected: (IntSize, String) -> Unit + onDownloadMapAreaSelected: (IntSize, String, ZoomLevel) -> Unit ) { var isShowingAreaNameDialog by rememberSaveable { mutableStateOf(false) } var mapAreaName by rememberSaveable { mutableStateOf(currentAreaName) } var mapViewSize = IntSize(0, 0) + var zoomLevel by rememberSaveable { mutableStateOf(ZoomLevel.STREET) } if (isShowingAreaNameDialog) { AreaNameDialog( - currentAreaName = currentAreaName, + currentAreaName = mapAreaName, onDismiss = { isShowingAreaNameDialog = false }, onConfirm = { newAreaName -> mapAreaName = newAreaName @@ -208,7 +210,15 @@ private fun OnDemandMapAreaSelectorOptions( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Text(mapAreaName, style = MaterialTheme.typography.titleLarge) + Text( + text = mapAreaName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) OutlinedButton(onClick = { isShowingAreaNameDialog = true }) { Icon( imageVector = Icons.Default.Create, @@ -227,22 +237,18 @@ private fun OnDemandMapAreaSelectorOptions( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Text("Level of Detail", style = MaterialTheme.typography.titleMedium) - // TODO: Wire level of detail to drop down: - val levelOfDetails = listOf("Streets", "City", "Country") - var selectedIndex by rememberSaveable { mutableIntStateOf(0) } DropDownMenuBox( - modifier = Modifier, - textFieldValue = levelOfDetails[selectedIndex], - dropDownItemList = levelOfDetails, - onIndexSelected = { selectedIndex = it } + textFieldValue = stringResource(zoomLevel.descriptionResId), + textFieldLabel = stringResource(R.string.level_of_detail), + dropDownItemList = ZoomLevel.entries.map { stringResource(it.descriptionResId) }, + onIndexSelected = { zoomLevel = ZoomLevel.entries[it] } ) } Button( modifier = Modifier .padding(horizontal = 12.dp) .fillMaxWidth(), - onClick = { onDownloadMapAreaSelected.invoke(mapViewSize, mapAreaName) } + onClick = { onDownloadMapAreaSelected.invoke(mapViewSize, mapAreaName, zoomLevel) } ) { Text("Download") } } } @@ -345,7 +351,7 @@ private fun AreaNameDialog( private fun DropDownMenuBox( modifier: Modifier = Modifier, textFieldValue: String, - textFieldLabel: String = "Choose level of detail", + textFieldLabel: String, dropDownItemList: List, onIndexSelected: (Int) -> Unit ) { @@ -354,7 +360,7 @@ private fun DropDownMenuBox( val onDropDownTapped by interactionSource.collectIsPressedAsState() LaunchedEffect(onDropDownTapped) { if (onDropDownTapped) expanded = true } - Box(modifier = modifier) { + Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) { OutlinedTextField( value = textFieldValue, onValueChange = {}, @@ -371,12 +377,11 @@ private fun DropDownMenuBox( contentDescription = null, modifier = Modifier.clickable { expanded = !expanded }) }, - modifier = Modifier.width(175.dp) + modifier = Modifier.align(Alignment.BottomEnd).fillMaxWidth() ) DropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.width(175.dp) + onDismissRequest = { expanded = false } ) { dropDownItemList.forEachIndexed { index, text -> DropdownMenuItem( @@ -384,7 +389,18 @@ private fun DropDownMenuBox( onIndexSelected(index) expanded = false }, - text = { Text(text) } + text = { + if (text == textFieldValue) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(Icons.Default.Check, null, Modifier.size(16.dp)) + Text(text, fontWeight = FontWeight.ExtraBold) + } + else + Text(text) + } ) if (index < dropDownItemList.lastIndex) { HorizontalDivider() @@ -403,17 +419,3 @@ private fun AreaNameDialogPreview() { onConfirm = { } ) } - -@Preview(showBackground = true) -@Composable -private fun OnDemandMapAreaSelectorPreview() { - Box(Modifier.fillMaxSize()) { - OnDemandMapAreaSelectorOptions( - onDismiss = { }, - currentAreaName = "Area 1", - onDownloadMapAreaSelected = { _, _ -> } - ) - } -} - - diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 61e13a1e3..d9c8f2fd9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -35,6 +35,7 @@ import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapUpdateMode import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.toolkit.offline.OfflineRepository +import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize import com.arcgismaps.toolkit.offline.runCatchingCancellable import com.arcgismaps.toolkit.offline.workmanager.LOG_TAG @@ -65,6 +66,7 @@ internal class OnDemandMapAreasState( private val item: Item, internal val onDemandAreaID: String, internal val title: String, + private val zoomLevel: ZoomLevel = ZoomLevel.STREET, internal val mapAreaEnvelope: Envelope? = null, private val offlineMapTask: OfflineMapTask? = null, private val onSelectionChanged: (ArcGISMap) -> Unit @@ -171,8 +173,8 @@ internal class OnDemandMapAreasState( // Create default download parameters from the offline map task val params = offlineMapTask.createDefaultGenerateOfflineMapParameters( areaOfInterest = downloadMapArea, - minScale = 35000.0, - maxScale = 32000.0 + minScale = 0.0, + maxScale = zoomLevel.scale ).getOrThrow().apply { // Set the update mode to receive no updates updateMode = GenerateOfflineMapUpdateMode.NoUpdates diff --git a/toolkit/offline/src/main/res/values/strings.xml b/toolkit/offline/src/main/res/values/strings.xml index 782439f44..19e59e7d1 100644 --- a/toolkit/offline/src/main/res/values/strings.xml +++ b/toolkit/offline/src/main/res/values/strings.xml @@ -45,4 +45,29 @@ There are no map areas for this map. Tap the button below to get started. Offline Disabled The map is not enabled for offline use. + Level of detail + Room + Rooms + House Property + Houses + Small Building + Building + Buildings + Street + Streets + Neighborhood + Town + City + Cities + Metropolitan Area + County + Counties + State/Province + States/Provinces + Countries (Small) + Countries (Big) + Continent + World (Small) + World (Big) + World From fef98ee58797965629e8b334f8d61d55707e30f9 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 08:37:16 -0700 Subject: [PATCH 089/132] wired functionality --- .../toolkit/offline/OfflineMapAreas.kt | 148 +++++++++++------- .../toolkit/offline/OfflineMapState.kt | 104 +++++++++--- .../toolkit/offline/OfflineRepository.kt | 23 +++ .../toolkit/offline/internal/utils/Utils.kt | 38 +++++ .../ondemand/OnDemandMapAreaSelector.kt | 111 +++++++------ .../offline/ondemand/OnDemandMapAreas.kt | 21 +-- .../offline/ondemand/OnDemandMapAreasState.kt | 95 +++++------ 7 files changed, 358 insertions(+), 182 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 77fa21441..647389d26 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -33,15 +33,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.toolkit.offline.internal.utils.getDefaultMapAreaTitle +import com.arcgismaps.toolkit.offline.internal.utils.isValidMapAreaTitle +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaSelector import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreas +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState +import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreas import com.arcgismaps.toolkit.offline.ui.EmptyOnDemandOfflineAreas import com.arcgismaps.toolkit.offline.ui.EmptyPreplannedOfflineAreas import com.arcgismaps.toolkit.offline.ui.NoInternetNoAreas import com.arcgismaps.toolkit.offline.ui.OfflineDisabled import com.arcgismaps.toolkit.offline.ui.OfflineMapAreasError -import kotlinx.coroutines.launch /** * Take a web map offline by downloading map areas. @@ -57,7 +62,6 @@ public fun OfflineMapAreas( val scope = rememberCoroutineScope() val initializationStatus by offlineMapState.initializationStatus var isRefreshEnabled by rememberSaveable { mutableStateOf(false) } - var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } LaunchedEffect(offlineMapState, isRefreshEnabled) { if (isRefreshEnabled) { @@ -94,61 +98,27 @@ public fun OfflineMapAreas( when (offlineMapState.mode) { // For preplanned, display online & offline map areas. OfflineMapMode.Preplanned -> { - Column { - PreplannedMapAreas( - preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, - modifier = modifier - ) - if (offlineMapState.isShowingOnlyOfflineModels) { - NoInternetNoAreas( - onlyFooterVisible = offlineMapState.preplannedMapAreaStates.isNotEmpty(), - onRefresh = { isRefreshEnabled = true } - ) - } else if (offlineMapState.preplannedMapAreaStates.isEmpty()) { - EmptyPreplannedOfflineAreas(onRefresh = { - isRefreshEnabled = true - }) - } - } + PreplannedLayoutContainer( + modifier = modifier, + preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, + isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, + onRefresh = { isRefreshEnabled = true } + ) } // If not preplanned state & map has offline mode enabled, display the on demand areas OfflineMapMode.OnDemand, OfflineMapMode.Unknown -> { - if (!isOnDemandMapAreaSelectorVisible && offlineMapState.onDemandMapAreaStates.isNotEmpty()) { - OnDemandMapAreas( - onDemandMapAreasStates = offlineMapState.onDemandMapAreaStates, - modifier = modifier, - onDownloadNewMapArea = { - isOnDemandMapAreaSelectorVisible = true - } - ) - } - if (offlineMapState.onDemandMapAreaStates.isEmpty()) { - EmptyOnDemandOfflineAreas( - onAdd = { - isOnDemandMapAreaSelectorVisible = true - } - ) - } - OnDemandMapAreaSelector( + OnDemandLayoutContainer( + modifier = modifier, + onDemandMapAreaStates = offlineMapState.onDemandMapAreaStates, + isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, localMap = offlineMapState.localMap, - showBottomSheet = isOnDemandMapAreaSelectorVisible, - uniqueMapAreaTitle = "Area 1", // TODO: Ensure this is a unique area title - onDismiss = { - isOnDemandMapAreaSelectorVisible = false - }, - onDownloadMapAreaSelected = { envelope, mapAreaTitle, zoomLevel -> - scope.launch { - // TODO: This should be triggered from the area state - val onDemandMapAreaState = offlineMapState.createOnDemandMapAreasState( - context = context, - envelope = envelope, - mapAreaTitle = mapAreaTitle, - zoomLevel = zoomLevel - ) - // Start the on-demand download - onDemandMapAreaState.downloadOnDemandMapArea() - - } + onRefresh = { isRefreshEnabled = true }, + onDownloadMapAreaSelected = { onDemandConfig -> + // Create the on-demand state and start the download + offlineMapState.makeOnDemandMapAreaState( + context = context, + configuration = onDemandConfig + ).downloadOnDemandMapArea() } ) } @@ -158,3 +128,75 @@ public fun OfflineMapAreas( } } } + +@Composable +internal fun PreplannedLayoutContainer( + modifier: Modifier, + preplannedMapAreaStates: List, + isShowingOnlyOfflineModels: Boolean, + onRefresh: () -> Unit +) { + Column { + PreplannedMapAreas( + preplannedMapAreaStates = preplannedMapAreaStates, + modifier = modifier + ) + if (isShowingOnlyOfflineModels) { + NoInternetNoAreas( + onlyFooterVisible = preplannedMapAreaStates.isNotEmpty(), + onRefresh = onRefresh + ) + } else if (preplannedMapAreaStates.isEmpty()) { + EmptyPreplannedOfflineAreas(onRefresh = onRefresh) + } + } +} + +@Composable +internal fun OnDemandLayoutContainer( + modifier: Modifier, + onDemandMapAreaStates: List, + isShowingOnlyOfflineModels: Boolean, + localMap: ArcGISMap, + onRefresh: () -> Unit, + onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit +) { + var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } + var isProposedTitleChangeUnique by rememberSaveable { mutableStateOf(false) } + Column { + if (onDemandMapAreaStates.isNotEmpty()) { + OnDemandMapAreas( + onDemandMapAreasStates = onDemandMapAreaStates, + modifier = modifier, + isNetworkConnectionAvailable = !isShowingOnlyOfflineModels, + onConfigureNewOnDemandMapArea = { + isOnDemandMapAreaSelectorVisible = true + } + ) + } + if (isShowingOnlyOfflineModels) { + NoInternetNoAreas( + onlyFooterVisible = false, + onRefresh = onRefresh + ) + } else if (onDemandMapAreaStates.isEmpty()) { + EmptyOnDemandOfflineAreas( + onAdd = { isOnDemandMapAreaSelectorVisible = true } + ) + } + } + OnDemandMapAreaSelector( + localMap = localMap, + showBottomSheet = isOnDemandMapAreaSelectorVisible, + uniqueMapAreaTitle = getDefaultMapAreaTitle(onDemandMapAreaStates), + isProposedTitleChangeUnique = isProposedTitleChangeUnique, + onDismiss = { isOnDemandMapAreaSelectorVisible = false }, + onProposedTitleChange = { mapAreaTitle -> + isProposedTitleChangeUnique = isValidMapAreaTitle( + mapAreaTitle = mapAreaTitle, + onDemandMapAreaStates = onDemandMapAreaStates + ) + }, + onDownloadMapAreaSelected = onDownloadMapAreaSelected + ) +} \ No newline at end of file diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index a970a8866..d1cd2d558 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -28,20 +28,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import com.arcgismaps.LoadStatus -import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea -import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState +import com.arcgismaps.toolkit.offline.ondemand.OnDemandStatus import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException import java.io.File -import java.util.UUID /** * Represents the state of the offline map. @@ -164,7 +163,7 @@ public class OfflineMapState( // check if preplanned for loaded if (_mode != OfflineMapMode.Preplanned || _mode == OfflineMapMode.Unknown) { - // TODO: Load OnDemandMapAresState + loadOfflineOnDemandMapAreas(context) if (_mode == OfflineMapMode.Unknown) _mode = OfflineMapMode.OnDemand } @@ -243,25 +242,27 @@ public class OfflineMapState( } } - // TODO: Should this be wired to call OnDemandMapAreasState.initialize? - internal fun createOnDemandMapAreasState( - context: Context, - envelope: Envelope, - mapAreaTitle: String, - zoomLevel: ZoomLevel - ): OnDemandMapAreasState { - val onDemandMapAreasState = OnDemandMapAreasState( - context = context, - item = portalItem, - onDemandAreaID = UUID.randomUUID().toString(), - title = mapAreaTitle, - mapAreaEnvelope = envelope, - zoomLevel = zoomLevel, - offlineMapTask = offlineMapTask, - onSelectionChanged = onSelectionChanged + /** + * Scans the local on-demand directory for downloaded maps and creates [OnDemandMapAreasState]s. + * Sets the [OfflineMapMode.OnDemand] when any local areas are found. + * + * @since 200.8.0 + */ + private suspend fun loadOfflineOnDemandMapAreas(context: Context) { + _onDemandMapAreaStates.clear() + val onDemandDirectory = File( + OfflineURLs.onDemandDirectoryPath(context, portalItem.itemId) ) - _onDemandMapAreaStates.add(onDemandMapAreasState) - return onDemandMapAreasState + val onDemandMapAreaItemIds = onDemandDirectory.listFiles()?.map { it.name.toString() } + ?: emptyList() + if (onDemandMapAreaItemIds.isNotEmpty()) + _mode = OfflineMapMode.OnDemand + + onDemandMapAreaItemIds.forEach { itemId -> + makeOfflineOnDemandMapAreaState(context, itemId)?.let { + _onDemandMapAreaStates.add(it) + } + } } /** @@ -308,6 +309,65 @@ public class OfflineMapState( return null } + /** + * Attempts to create a [OnDemandMapAreasState] for a given area ID by loading + * its [MobileMapPackage] from disk. Returns null if the directory is missing + * or the package fails to load; otherwise initializes status and map. + * + * @since 200.8.0 + */ + private suspend fun makeOfflineOnDemandMapAreaState( + context: Context, + areaItemId: String + ): OnDemandMapAreasState? { + val areaDir = File( + OfflineURLs.onDemandDirectoryPath( + context = context, + portalItemID = portalItem.itemId, + onDemandMapAreaID = areaItemId + ) + ) + if (!areaDir.exists() || !areaDir.isDirectory) return null + val mmpk = MobileMapPackage(areaDir.absolutePath).apply { + load().getOrElse { return null } + } + val item = mmpk.item ?: return null + + val onDemandMapAreasState = OnDemandMapAreasState( + context = context, + item = item, + onSelectionChanged = onSelectionChanged + ) + val onDemandPath = OfflineRepository.isOnDemandAreaDownloaded( + context = context, + portalItemID = portalItem.itemId, + onDemandMapAreaID = areaItemId + ) + if (onDemandPath != null) { + onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) + onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = onDemandPath + ) + return onDemandMapAreasState + } else + return null + } + + internal fun makeOnDemandMapAreaState( + context: Context, + configuration: OnDemandMapAreaConfiguration + ): OnDemandMapAreasState { + val onDemandMapAreasState = OnDemandMapAreasState( + context = context, + item = portalItem, + configuration = configuration, + offlineMapTask = offlineMapTask, + onSelectionChanged = onSelectionChanged + ) + _onDemandMapAreaStates.add(onDemandMapAreasState) + return onDemandMapAreasState + } + /** * Resets the current selection of preplanned map areas. * diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index a2abbb0b5..dfb6e9704 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -41,6 +41,7 @@ import com.arcgismaps.toolkit.offline.workmanager.jsonJobPathKey import com.arcgismaps.toolkit.offline.workmanager.mobileMapPackagePathKey import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile +import com.arcgismaps.toolkit.offline.workmanager.onDemandAreas import com.arcgismaps.toolkit.offline.workmanager.preplannedMapAreas import java.io.File import java.util.UUID @@ -338,6 +339,28 @@ public object OfflineRepository { else null } + /** + * Checks whether a given [onDemandMapAreaID] associated with a [portalItemID] + * has already been downloaded locally. + * + * @return The path to the on-demand area’s local folder if it exists, + * otherwise `null`. + * @since 200.8.0 + */ + internal fun isOnDemandAreaDownloaded( + context: Context, + portalItemID: String, + onDemandMapAreaID: String + ): String? { + val destDir = File( + File(OfflineURLs.portalItemDirectoryPath(context, portalItemID), onDemandAreas), + onDemandMapAreaID + ) + return if (destDir.exists()) + destDir.path + else null + } + /** * Creates and enqueues a one-time WorkManager request for downloading an offline map area * using [PreplannedMapAreaJobWorker]. Sets up expedited work with input data containing diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index b23cc9adf..d4a4c42ab 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -19,6 +19,11 @@ package com.arcgismaps.toolkit.offline.internal.utils import android.text.Html +import androidx.compose.ui.unit.IntSize +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.mapping.view.ScreenCoordinate +import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import java.io.File internal fun getDirectorySize(path: String): Int { @@ -40,3 +45,36 @@ internal fun formatSize(size: Int): String { internal fun htmlToPlainText(html: String): String { return Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY).toString() } + +internal fun getDefaultMapAreaTitle(onDemandMapAreaStates: List): String { + for (i in 1..Int.MAX_VALUE) { + val title = "Area $i" + if (onDemandMapAreaStates.none { it.title == title }) { + return title + } + } + return "" +} + +internal fun isValidMapAreaTitle( + mapAreaTitle: String, + onDemandMapAreaStates: List +): Boolean { + if (mapAreaTitle.isBlank()) + return false + if (onDemandMapAreaStates.any { it.title == mapAreaTitle }) + return false + return true +} + +internal fun calculateEnvelope(fullSize: IntSize, mapViewProxy: MapViewProxy): Envelope? { + val inh = fullSize.width * 0.2 / 2 + val inv = fullSize.height * 0.2 / 2 + val minScreen = ScreenCoordinate(x = inh, y = inv) + val maxScreen = ScreenCoordinate(x = fullSize.width - inh, y = fullSize.height - inv) + val minResult = mapViewProxy.screenToLocationOrNull(minScreen) + val maxResult = mapViewProxy.screenToLocationOrNull(maxScreen) + return if (minResult != null && maxResult != null) { + Envelope(min = minResult, max = maxResult) + } else null +} \ No newline at end of file diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 20a7ca253..54a80bb8f 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -78,36 +78,20 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap -import com.arcgismaps.mapping.view.ScreenCoordinate import com.arcgismaps.toolkit.geoviewcompose.MapView import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel +import com.arcgismaps.toolkit.offline.internal.utils.calculateEnvelope import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheetProperties import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState import kotlinx.coroutines.launch - - -// TODO: Migrate these to the area state +import java.util.UUID private val mapViewProxy = MapViewProxy() -private fun calculateEnvelope(fullSize: IntSize): Envelope? { - val inh = fullSize.width * 0.2 / 2 - val inv = fullSize.height * 0.2 / 2 - val minScreen = ScreenCoordinate(x = inh, y = inv) - val maxScreen = ScreenCoordinate(x = fullSize.width - inh, y = fullSize.height - inv) - val minResult = mapViewProxy.screenToLocationOrNull(minScreen) - val maxResult = mapViewProxy.screenToLocationOrNull(maxScreen) - return if (minResult != null && maxResult != null) { - Envelope(min = minResult, max = maxResult) - } else null -} - - /** * Take a web map offline by downloading map areas. * @@ -115,11 +99,13 @@ private fun calculateEnvelope(fullSize: IntSize): Envelope? { */ @Composable internal fun OnDemandMapAreaSelector( - localMap: ArcGISMap? = null, + localMap: ArcGISMap, uniqueMapAreaTitle: String, showBottomSheet: Boolean, onDismiss: () -> Unit, - onDownloadMapAreaSelected: (Envelope, String, ZoomLevel) -> Unit + isProposedTitleChangeUnique: Boolean, + onProposedTitleChange: (String) -> Unit, + onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -137,22 +123,24 @@ internal fun OnDemandMapAreaSelector( sheetState = sheetState, sheetGesturesEnabled = false, properties = ModalBottomSheetProperties(), - dragHandle = {}) { + dragHandle = {} + ) { OnDemandMapAreaSelectorOptions( - localMap = localMap, onDismiss = { + localMap = localMap, + currentAreaName = uniqueMapAreaTitle, + isProposedTitleChangeUnique = isProposedTitleChangeUnique, + onProposedTitleChange = onProposedTitleChange, + onDismiss = { scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss.invoke() } }, - currentAreaName = uniqueMapAreaTitle, - onDownloadMapAreaSelected = { mapViewSize, mapAreaName, zoomLevel -> + onDownloadMapAreaSelected = { onDemandConfig -> scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss.invoke() - calculateEnvelope(mapViewSize)?.let { downloadArea -> - onDownloadMapAreaSelected.invoke(downloadArea, mapAreaName, zoomLevel) - } + onDownloadMapAreaSelected.invoke(onDemandConfig) } } ) @@ -163,17 +151,22 @@ internal fun OnDemandMapAreaSelector( @Composable private fun OnDemandMapAreaSelectorOptions( currentAreaName: String, - localMap: ArcGISMap? = null, - onDismiss: () -> Unit, - onDownloadMapAreaSelected: (IntSize, String, ZoomLevel) -> Unit + localMap: ArcGISMap, + isProposedTitleChangeUnique: Boolean, + onProposedTitleChange: (String) -> Unit, + onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit, + onDismiss: () -> Unit ) { var isShowingAreaNameDialog by rememberSaveable { mutableStateOf(false) } var mapAreaName by rememberSaveable { mutableStateOf(currentAreaName) } var mapViewSize = IntSize(0, 0) var zoomLevel by rememberSaveable { mutableStateOf(ZoomLevel.STREET) } + val scope = rememberCoroutineScope() if (isShowingAreaNameDialog) { AreaNameDialog( currentAreaName = mapAreaName, + isProposedTitleChangeUnique = isProposedTitleChangeUnique, + onProposedTitleChange = onProposedTitleChange, onDismiss = { isShowingAreaNameDialog = false }, onConfirm = { newAreaName -> mapAreaName = newAreaName @@ -200,12 +193,12 @@ private fun OnDemandMapAreaSelectorOptions( Text(text = "Pan and zoom to define the area", style = MaterialTheme.typography.labelSmall) MapViewWithAreaSelector( modifier = Modifier.weight(1f), - localMap = localMap, + arcGISMap = localMap, onMapViewSizeChanged = { newSize -> mapViewSize = newSize } ) Row( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 24.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -232,7 +225,7 @@ private fun OnDemandMapAreaSelectorOptions( HorizontalDivider() Row( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 24.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -246,16 +239,32 @@ private fun OnDemandMapAreaSelectorOptions( } Button( modifier = Modifier - .padding(horizontal = 12.dp) + .padding(horizontal = 24.dp) .fillMaxWidth(), - onClick = { onDownloadMapAreaSelected.invoke(mapViewSize, mapAreaName, zoomLevel) } + onClick = { + scope.launch { + val thumbnail = mapViewProxy.exportImage().getOrNull()?.bitmap + calculateEnvelope(mapViewSize, mapViewProxy)?.let { downloadArea -> + onDownloadMapAreaSelected.invoke( + OnDemandMapAreaConfiguration( + areaID = UUID.randomUUID().toString(), + title = mapAreaName, + minScale = 0.0, + maxScale = zoomLevel.scale, + areaOfInterest = downloadArea, + thumbnail = thumbnail + ) + ) + } + } + } ) { Text("Download") } } } @Composable private fun MapViewWithAreaSelector( - localMap: ArcGISMap? = null, + arcGISMap: ArcGISMap, onMapViewSizeChanged: (IntSize) -> Unit, modifier: Modifier ) { @@ -263,13 +272,11 @@ private fun MapViewWithAreaSelector( modifier .fillMaxWidth() .onSizeChanged { onMapViewSizeChanged.invoke(it) }) { - localMap?.let { arcGISMap -> - MapView( - modifier = Modifier.matchParentSize(), - arcGISMap = arcGISMap, - mapViewProxy = mapViewProxy - ) - } + MapView( + modifier = Modifier.matchParentSize(), + arcGISMap = arcGISMap, + mapViewProxy = mapViewProxy + ) MapAreaSelectorOverlay( modifier = Modifier.matchParentSize() ) @@ -313,6 +320,8 @@ private fun MapAreaSelectorOverlay( @Composable private fun AreaNameDialog( currentAreaName: String, + isProposedTitleChangeUnique: Boolean, + onProposedTitleChange: (String) -> Unit, onDismiss: () -> Unit, onConfirm: (String) -> Unit ) { @@ -336,11 +345,17 @@ private fun AreaNameDialog( label = { Text("The name for the map area must be unique") }, value = areaName, singleLine = true, - onValueChange = { newValue -> areaName = newValue }, + onValueChange = { newValue -> + areaName = newValue + onProposedTitleChange(areaName) + }, ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = onDismiss) { Text("Cancel") } - TextButton(onClick = { onConfirm.invoke(areaName) }) { Text("Ok") } + TextButton( + onClick = { onConfirm.invoke(areaName) }, + enabled = isProposedTitleChangeUnique + ) { Text("Ok") } } } @@ -377,7 +392,9 @@ private fun DropDownMenuBox( contentDescription = null, modifier = Modifier.clickable { expanded = !expanded }) }, - modifier = Modifier.align(Alignment.BottomEnd).fillMaxWidth() + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxWidth() ) DropdownMenu( expanded = expanded, @@ -415,6 +432,8 @@ private fun DropDownMenuBox( private fun AreaNameDialogPreview() { AreaNameDialog( currentAreaName = "Area 1", + isProposedTitleChangeUnique = true, + onProposedTitleChange = { }, onDismiss = { }, onConfirm = { } ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index a6e9df2b3..72c01c2b8 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -75,8 +75,9 @@ import kotlinx.coroutines.launch @Composable internal fun OnDemandMapAreas( onDemandMapAreasStates: List, + isNetworkConnectionAvailable: Boolean, modifier: Modifier, - onDownloadNewMapArea: () -> Unit + onConfigureNewOnDemandMapArea: () -> Unit ) { var showSheet by rememberSaveable { mutableStateOf(false) } var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } @@ -215,18 +216,20 @@ internal fun OnDemandMapAreas( } } } - if (state.onDemandAreaID != onDemandMapAreasStates.last().onDemandAreaID) { + if (state != onDemandMapAreasStates.last()) { HorizontalDivider(modifier = Modifier.padding(start = 80.dp)) } } } - Spacer(Modifier.height(12.dp)) - // add a button to add a new on demand map area - Button(onClick = onDownloadNewMapArea) { - Text( - text = stringResource(R.string.add_map_area), - style = MaterialTheme.typography.labelSmall - ) + if (isNetworkConnectionAvailable) { + Spacer(Modifier.height(12.dp)) + // add a button to add a new on demand map area + Button(onClick = onConfigureNewOnDemandMapArea) { + Text( + text = stringResource(R.string.add_map_area), + style = MaterialTheme.typography.labelSmall + ) + } } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index d9c8f2fd9..6cbfb8d77 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -47,13 +47,18 @@ import kotlinx.coroutines.launch import java.io.File import java.util.UUID -private data class OnDemandMapAreaConfiguration( - private val areaID: String, - private val title: String, - private val minScale: Double, - private val maxScale: Double, - private val areaOfInterest: Envelope, - private val thumbnail: Bitmap? +/** + * A data class to hold configuration for an on-demand map area. + * + * @since 200.8.0 + */ +internal data class OnDemandMapAreaConfiguration( + internal val areaID: String, + internal val title: String, + internal val minScale: Double, + internal val maxScale: Double, + internal val areaOfInterest: Envelope, + internal val thumbnail: Bitmap? ) /** @@ -64,10 +69,7 @@ private data class OnDemandMapAreaConfiguration( internal class OnDemandMapAreasState( private val context: Context, private val item: Item, - internal val onDemandAreaID: String, - internal val title: String, - private val zoomLevel: ZoomLevel = ZoomLevel.STREET, - internal val mapAreaEnvelope: Envelope? = null, + private val configuration: OnDemandMapAreaConfiguration? = null, private val offlineMapTask: OfflineMapTask? = null, private val onSelectionChanged: (ArcGISMap) -> Unit ) { @@ -97,49 +99,41 @@ internal class OnDemandMapAreasState( private lateinit var scope: CoroutineScope - private var _thumbnail by mutableStateOf(null) - internal val thumbnail: Bitmap? get() = _thumbnail ?: item.thumbnail?.image?.bitmap - - private var configuration: OnDemandMapAreaConfiguration? = null + internal val title = configuration?.title ?: item.title - /** - * Loads and initializes the associated on demand map area. - * - * @since 200.8.0 - */ - internal suspend fun initialize() = runCatchingCancellable { - // TODO - } + internal val thumbnail: Bitmap? + get() = configuration?.thumbnail ?: item.thumbnail?.image?.bitmap /** * Initiates downloading of the associated on-demand map area for offline use. * * @since 200.8.0 */ - internal fun downloadOnDemandMapArea() = - runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) - val area = mapAreaEnvelope ?: return@runCatchingCancellable - val task = offlineMapTask ?: return@runCatchingCancellable - val portalItem = item as? PortalItem ?: return@runCatchingCancellable - - scope.launch { - _status = OnDemandStatus.Downloading - val offlineWorkerUUID = startOfflineMapJob( - downloadOnDemandOfflineMapJob = createOfflineMapJob( - downloadMapArea = area, - offlineMapTask = task - ) + internal fun downloadOnDemandMapArea() = runCatchingCancellable { + scope = CoroutineScope(Dispatchers.IO) + val task = offlineMapTask ?: return@runCatchingCancellable + val portalItem = item as? PortalItem ?: return@runCatchingCancellable + val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable + val downloadMapArea = configuration.areaOfInterest + + scope.launch { + _status = OnDemandStatus.Downloading + val offlineWorkerUUID = startOfflineMapJob( + downloadOnDemandOfflineMapJob = createOfflineMapJob( + onDemandMapAreaID = onDemandMapAreaID, + downloadMapArea = downloadMapArea, + offlineMapTask = task ) - OfflineRepository.observeStatusForOnDemandWork( - context = context, - onWorkInfoStateChanged = ::logWorkInfo, - onDemandMapAreasState = this@OnDemandMapAreasState, - portalItem = portalItem, - offlineWorkerUUID = offlineWorkerUUID - ) - } + ) + OfflineRepository.observeStatusForOnDemandWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + onDemandMapAreasState = this@OnDemandMapAreasState, + portalItem = portalItem, + offlineWorkerUUID = offlineWorkerUUID + ) } + } /** * Cancels the current coroutine scope. @@ -159,6 +153,7 @@ internal class OnDemandMapAreasState( * Generates default parameters for downloading, including no updates mode and error handling settings. * Defines a directory path where map data will be stored and creates a download job using these configurations. * + * @param onDemandMapAreaID The String ID of the map area to download on demand. * @param downloadMapArea The target selected map area to be downloaded offline. * @param offlineMapTask The target [OfflineMapTask] to create the params & the job. * @return An instance of [GenerateOfflineMapJob] configured with download parameters. @@ -166,6 +161,7 @@ internal class OnDemandMapAreasState( * @since 200.8.0 */ private suspend fun createOfflineMapJob( + onDemandMapAreaID: String, downloadMapArea: Envelope, offlineMapTask: OfflineMapTask ): GenerateOfflineMapJob { @@ -174,7 +170,7 @@ internal class OnDemandMapAreasState( val params = offlineMapTask.createDefaultGenerateOfflineMapParameters( areaOfInterest = downloadMapArea, minScale = 0.0, - maxScale = zoomLevel.scale + maxScale = configuration?.maxScale ?: ZoomLevel.STREET.scale ).getOrThrow().apply { // Set the update mode to receive no updates updateMode = GenerateOfflineMapUpdateMode.NoUpdates @@ -189,7 +185,7 @@ internal class OnDemandMapAreasState( val onDemandMapAreaDownloadDirectory = OfflineRepository.createPendingOnDemandJobPath( context = context, portalItemID = item.itemId, - onDemandMapAreaID = onDemandAreaID + onDemandMapAreaID = onDemandMapAreaID ) // Create a job to download the on-demand offline map @@ -250,11 +246,6 @@ internal class OnDemandMapAreasState( portalItemID = item.itemId ) } - val localScope = CoroutineScope(Dispatchers.IO) - localScope.launch { - initialize() - localScope.cancel() - } } else { Log.e(TAG, "Failed to delete on-demand map area: ${mobileMapPackage.path}") } From 1466f5101500ffb8db7ed1d0c2190ab7b7ea0312 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 09:32:53 -0700 Subject: [PATCH 090/132] added minor UI cleanups --- .../toolkit/offline/OfflineMapAreas.kt | 23 +++--- .../toolkit/offline/internal/utils/Buttons.kt | 74 +++++++++++++------ .../ondemand/OnDemandMapAreaSelector.kt | 34 ++++----- .../offline/ondemand/OnDemandMapAreas.kt | 14 +--- .../offline/ui/OfflineMapAreasStatusScreen.kt | 19 +---- 5 files changed, 85 insertions(+), 79 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 647389d26..09a774286 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -27,13 +27,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.toolkit.offline.internal.utils.AddMapAreaButton import com.arcgismaps.toolkit.offline.internal.utils.getDefaultMapAreaTitle import com.arcgismaps.toolkit.offline.internal.utils.isValidMapAreaTitle import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration @@ -59,7 +59,6 @@ public fun OfflineMapAreas( modifier: Modifier = Modifier ) { val context = LocalContext.current - val scope = rememberCoroutineScope() val initializationStatus by offlineMapState.initializationStatus var isRefreshEnabled by rememberSaveable { mutableStateOf(false) } @@ -163,23 +162,27 @@ internal fun OnDemandLayoutContainer( ) { var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } var isProposedTitleChangeUnique by rememberSaveable { mutableStateOf(false) } - Column { + Column(horizontalAlignment = Alignment.CenterHorizontally) { if (onDemandMapAreaStates.isNotEmpty()) { OnDemandMapAreas( onDemandMapAreasStates = onDemandMapAreaStates, - modifier = modifier, - isNetworkConnectionAvailable = !isShowingOnlyOfflineModels, - onConfigureNewOnDemandMapArea = { - isOnDemandMapAreaSelectorVisible = true - } + modifier = modifier ) + if (!isShowingOnlyOfflineModels) { + AddMapAreaButton { isOnDemandMapAreaSelectorVisible = true } + } else { + NoInternetNoAreas( + onlyFooterVisible = true, + onRefresh = onRefresh + ) + } } - if (isShowingOnlyOfflineModels) { + if (isShowingOnlyOfflineModels && onDemandMapAreaStates.isEmpty()) { NoInternetNoAreas( onlyFooterVisible = false, onRefresh = onRefresh ) - } else if (onDemandMapAreaStates.isEmpty()) { + } else if (!isShowingOnlyOfflineModels && onDemandMapAreaStates.isEmpty()) { EmptyOnDemandOfflineAreas( onAdd = { isOnDemandMapAreaSelectorVisible = true } ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt index 43fe4bf1c..4e34f70c2 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -23,11 +23,16 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -35,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -72,6 +78,9 @@ internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: ( ) { // Circular Progress Indicator CircularProgressIndicator( + modifier = Modifier + .size(30.dp) + .align(Alignment.Center), progress = { progress / 100f }, ) // Square Button to cancel the download @@ -79,7 +88,8 @@ internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: ( modifier = Modifier .size(10.dp) .clip(RectangleShape) - .background(ButtonDefaults.buttonColors().containerColor), + .background(ButtonDefaults.buttonColors().containerColor) + .align(Alignment.Center), ) } } @@ -95,7 +105,24 @@ internal fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { Text( text = stringResource(R.string.open), maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall + ) + } +} + +@Composable +internal fun AddMapAreaButton(onAdd: () -> Unit) { + Button(onClick = onAdd) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.Add, + contentDescription = "Icon to add map area" + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.add_map_area), + style = MaterialTheme.typography.labelSmall ) } } @@ -105,26 +132,31 @@ internal fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { private fun ButtonsPreview() { MaterialTheme { Surface { - Row( + LazyVerticalGrid( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(12.dp), + columns = GridCells.Fixed(2), ) { - DownloadButton( - onClick = { } - ) - CancelDownloadButtonWithProgressIndicator( - progress = 55, - onClick = { } - ) - OpenButton( - isEnabled = true, - onClick = { } - ) - OpenButton( - isEnabled = false, - onClick = { } - ) + item { + DownloadButton(onClick = { }) + } + item { + CancelDownloadButtonWithProgressIndicator( + progress = 55, + onClick = { } + ) + } + item { + OpenButton( + isEnabled = true, + onClick = { } + ) + } + item { + AddMapAreaButton(onAdd = { }) + } } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 54a80bb8f..39bdecd64 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -108,18 +108,20 @@ internal fun OnDemandMapAreaSelector( onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - + var onHideSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(onHideSheet, sheetState.isVisible) { + if (onHideSheet) { + sheetState.hide() + onHideSheet = false + } + if (!sheetState.isVisible) { + onDismiss() + } + } if (showBottomSheet) { ModalBottomSheet( modifier = Modifier.systemBarsPadding(), - onDismissRequest = { - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - onDismiss.invoke() - } - }, + onDismissRequest = { onHideSheet = true }, sheetState = sheetState, sheetGesturesEnabled = false, properties = ModalBottomSheetProperties(), @@ -130,18 +132,10 @@ internal fun OnDemandMapAreaSelector( currentAreaName = uniqueMapAreaTitle, isProposedTitleChangeUnique = isProposedTitleChangeUnique, onProposedTitleChange = onProposedTitleChange, - onDismiss = { - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - onDismiss.invoke() - } - }, + onDismiss = { onHideSheet = true }, onDownloadMapAreaSelected = { onDemandConfig -> - scope.launch { sheetState.hide() }.invokeOnCompletion { - onDismiss.invoke() - onDownloadMapAreaSelected.invoke(onDemandConfig) - } + onHideSheet = true + onDownloadMapAreaSelected.invoke(onDemandConfig) } ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 72c01c2b8..d7aad5e2b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -75,9 +75,7 @@ import kotlinx.coroutines.launch @Composable internal fun OnDemandMapAreas( onDemandMapAreasStates: List, - isNetworkConnectionAvailable: Boolean, - modifier: Modifier, - onConfigureNewOnDemandMapArea: () -> Unit + modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } @@ -221,16 +219,6 @@ internal fun OnDemandMapAreas( } } } - if (isNetworkConnectionAvailable) { - Spacer(Modifier.height(12.dp)) - // add a button to add a new on demand map area - Button(onClick = onConfigureNewOnDemandMapArea) { - Text( - text = stringResource(R.string.add_map_area), - style = MaterialTheme.typography.labelSmall - ) - } - } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt index 8611069d2..7f1a33192 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.internal.utils.AddMapAreaButton @Composable internal fun OfflineMapAreasStatusContent( @@ -69,10 +70,11 @@ internal fun OfflineMapAreasStatusContent( Row( modifier = Modifier .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) Text( modifier = Modifier.wrapContentSize(), text = message, @@ -160,20 +162,7 @@ internal fun EmptyOnDemandOfflineAreas(onlyFooterVisible: Boolean = false, onAdd title = stringResource(R.string.no_map_areas), message = stringResource(R.string.empty_on_demand_message), icon = Icons.Default.ArrowDownward, - actions = { - Button(onClick = onAdd) { - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Default.Add, - contentDescription = "Icon to add map area" - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(R.string.add_map_area), - style = MaterialTheme.typography.labelSmall - ) - } - }, + actions = { AddMapAreaButton(onAdd) }, onlyFooterVisible = onlyFooterVisible ) } From 6733784bbc362d00054759353b7bbc85a3aafa23 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 12:59:40 -0700 Subject: [PATCH 091/132] wired in OnDemand cancellation, and update list --- .../toolkit/offline/OfflineMapAreas.kt | 32 +++++++++--------- .../toolkit/offline/OfflineMapState.kt | 14 ++++++++ .../toolkit/offline/OfflineRepository.kt | 13 ++++---- .../toolkit/offline/internal/utils/Buttons.kt | 33 +++++++++++++------ .../toolkit/offline/internal/utils/Utils.kt | 2 +- .../offline/ondemand/OnDemandMapAreas.kt | 16 +++++++-- .../offline/ondemand/OnDemandMapAreasState.kt | 30 +++++++++++++++-- .../offline/preplanned/PreplannedMapAreas.kt | 2 ++ .../offline/ui/MapAreaDetailsScreen.kt | 13 +++++++- .../offline/workmanager/WorkManagerUtils.kt | 12 +++---- .../offline/src/main/res/values/strings.xml | 2 ++ 11 files changed, 123 insertions(+), 46 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 09a774286..bc14e063c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -101,6 +101,7 @@ public fun OfflineMapAreas( modifier = modifier, preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, + onDownloadDeleted = offlineMapState::removePreplannedMapArea, onRefresh = { isRefreshEnabled = true } ) } @@ -112,6 +113,7 @@ public fun OfflineMapAreas( isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, localMap = offlineMapState.localMap, onRefresh = { isRefreshEnabled = true }, + onDownloadDeleted = offlineMapState::removeOnDemandMapArea, onDownloadMapAreaSelected = { onDemandConfig -> // Create the on-demand state and start the download offlineMapState.makeOnDemandMapAreaState( @@ -133,13 +135,16 @@ internal fun PreplannedLayoutContainer( modifier: Modifier, preplannedMapAreaStates: List, isShowingOnlyOfflineModels: Boolean, + onDownloadDeleted: (PreplannedMapAreaState) -> Unit, onRefresh: () -> Unit ) { Column { - PreplannedMapAreas( - preplannedMapAreaStates = preplannedMapAreaStates, - modifier = modifier - ) + if (preplannedMapAreaStates.isNotEmpty()) { + PreplannedMapAreas( + preplannedMapAreaStates = preplannedMapAreaStates, + modifier = modifier + ) + } if (isShowingOnlyOfflineModels) { NoInternetNoAreas( onlyFooterVisible = preplannedMapAreaStates.isNotEmpty(), @@ -158,6 +163,7 @@ internal fun OnDemandLayoutContainer( isShowingOnlyOfflineModels: Boolean, localMap: ArcGISMap, onRefresh: () -> Unit, + onDownloadDeleted: (OnDemandMapAreasState) -> Unit, onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit ) { var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } @@ -166,26 +172,20 @@ internal fun OnDemandLayoutContainer( if (onDemandMapAreaStates.isNotEmpty()) { OnDemandMapAreas( onDemandMapAreasStates = onDemandMapAreaStates, + onDownloadDeleted = onDownloadDeleted, modifier = modifier ) if (!isShowingOnlyOfflineModels) { AddMapAreaButton { isOnDemandMapAreaSelectorVisible = true } - } else { - NoInternetNoAreas( - onlyFooterVisible = true, - onRefresh = onRefresh - ) } } - if (isShowingOnlyOfflineModels && onDemandMapAreaStates.isEmpty()) { + if (isShowingOnlyOfflineModels) { NoInternetNoAreas( - onlyFooterVisible = false, + onlyFooterVisible = onDemandMapAreaStates.isNotEmpty(), onRefresh = onRefresh ) - } else if (!isShowingOnlyOfflineModels && onDemandMapAreaStates.isEmpty()) { - EmptyOnDemandOfflineAreas( - onAdd = { isOnDemandMapAreaSelectorVisible = true } - ) + } else if (onDemandMapAreaStates.isEmpty()) { + EmptyOnDemandOfflineAreas { isOnDemandMapAreaSelectorVisible = true } } } OnDemandMapAreaSelector( @@ -202,4 +202,4 @@ internal fun OnDemandLayoutContainer( }, onDownloadMapAreaSelected = onDownloadMapAreaSelected ) -} \ No newline at end of file +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index d1cd2d558..b209724a5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -386,6 +386,20 @@ public class OfflineMapState( internal fun resetInitialize() { _initializationStatus.value = InitializationStatus.NotInitialized } + + internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { + if (state.isSelectedToOpen){ + resetSelectedMapArea() + } + _onDemandMapAreaStates.remove(state) + } + + internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { + if (state.isSelectedToOpen){ + resetSelectedMapArea() + } + _preplannedMapAreaStates.remove(state) + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index dfb6e9704..0c613c242 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -604,12 +604,8 @@ public object OfflineRepository { } onDemandMapAreasState.disposeScope() } - // if the work failed or was cancelled - WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - // this removes the completed WorkInfo from the WorkManager's database - // otherwise, the observer will emit the WorkInfo on every launch - // until WorkManager auto-prunes - workManager.pruneWork() + // if the work failed + WorkInfo.State.FAILED -> { onDemandMapAreasState.updateStatus( OnDemandStatus.DownloadFailure( Exception( @@ -620,6 +616,11 @@ public object OfflineRepository { ) onDemandMapAreasState.disposeScope() } + // if the work was cancelled + WorkInfo.State.CANCELLED -> { + onDemandMapAreasState.updateStatus(OnDemandStatus.DownloadCancelled) + onDemandMapAreasState.disposeScope() + } // if the work is currently in progress WorkInfo.State.RUNNING -> { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloading) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt index 4e34f70c2..86dd0df7c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -33,6 +33,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -94,6 +96,20 @@ internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: ( } } +@Composable +internal fun CancelButton(onClick: () -> Unit) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = onClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cancelled), + tint = MaterialTheme.colorScheme.primary, + ) + } +} + @Composable internal fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { Button( @@ -140,22 +156,19 @@ private fun ButtonsPreview() { columns = GridCells.Fixed(2), ) { item { - DownloadButton(onClick = { }) + DownloadButton { } + } + item { + CancelDownloadButtonWithProgressIndicator(55) { } } item { - CancelDownloadButtonWithProgressIndicator( - progress = 55, - onClick = { } - ) + OpenButton(true) { } } item { - OpenButton( - isEnabled = true, - onClick = { } - ) + AddMapAreaButton { } } item { - AddMapAreaButton(onAdd = { }) + CancelButton { } } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index d4a4c42ab..7fb6018e2 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -77,4 +77,4 @@ internal fun calculateEnvelope(fullSize: IntSize, mapViewProxy: MapViewProxy): E return if (minResult != null && maxResult != null) { Envelope(min = minResult, max = maxResult) } else null -} \ No newline at end of file +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index d7aad5e2b..714627d37 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -24,10 +24,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -35,7 +33,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ImageNotSupported -import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -59,7 +56,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getString +import com.arcgismaps.toolkit.offline.OfflineMapMode import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.internal.utils.CancelButton import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton @@ -75,6 +74,7 @@ import kotlinx.coroutines.launch @Composable internal fun OnDemandMapAreas( onDemandMapAreasStates: List, + onDownloadDeleted: (OnDemandMapAreasState) -> Unit, modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } @@ -94,6 +94,7 @@ internal fun OnDemandMapAreas( sheetState = sheetState, scope = scope, onDismiss = { showSheet = false }, + offlineMapMode = OfflineMapMode.OnDemand, thumbnail = selectedOnDemandMapAreaState.thumbnail?.asImageBitmap(), title = selectedOnDemandMapAreaState.title, description = null, @@ -112,6 +113,7 @@ internal fun OnDemandMapAreas( isDeletable = selectedOnDemandMapAreaState.status.isDownloaded && !selectedOnDemandMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedOnDemandMapAreaState.removeDownloadedMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } + onDownloadDeleted(selectedOnDemandMapAreaState) } ) } @@ -205,6 +207,13 @@ internal fun OnDemandMapAreas( } } + state.status == OnDemandStatus.DownloadCancelled || state.status is OnDemandStatus.DownloadFailure -> { + CancelButton { + state.removeCancelledMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } + onDownloadDeleted(state) + } + } + state.status.isDownloaded -> { OpenButton(!state.isSelectedToOpen) { // Unselect all, then select this one @@ -237,6 +246,7 @@ private fun getOnDemandMapAreaStatusString(context: Context, status: OnDemandSta is OnDemandStatus.DownloadFailure -> getString(context, R.string.download_failed) OnDemandStatus.Downloaded -> getString(context, R.string.downloaded) OnDemandStatus.Downloading -> getString(context, R.string.downloading) + OnDemandStatus.DownloadCancelled -> getString(context, R.string.cancelled) OnDemandStatus.PackageFailure -> getString(context, R.string.packaging_failed) OnDemandStatus.Packaged -> getString(context, R.string.ready_to_download) OnDemandStatus.Packaging -> getString(context, R.string.packaging) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 6cbfb8d77..c02e2ce70 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -251,6 +251,25 @@ internal class OnDemandMapAreasState( } } + /** + * Removes the cancelled on-demand map area from the device. + * If specified, it also removes the offline map information from the repository. + * + * @param shouldRemoveOfflineMapInfo A lambda function that determines whether to remove offline map info. + * + * @since 200.8.0 + */ + internal fun removeCancelledMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { + // Reset the status to reflect the deletion + _status = OnDemandStatus.NotLoaded + if (shouldRemoveOfflineMapInfo()) { + OfflineRepository.removeOfflineMapInfo( + context = context, + portalItemID = item.itemId + ) + } + } + /** * Updates the current state of this on-demand map area instance. * @@ -348,6 +367,11 @@ internal sealed class OnDemandStatus { */ data object Downloaded : OnDemandStatus() + /** + * On-Demand map area download is cancelled. + */ + data object DownloadCancelled : OnDemandStatus() + /** * On-Demand map area failed to download. */ @@ -364,7 +388,7 @@ internal sealed class OnDemandStatus { val canLoadOnDemandMapArea: Boolean get() = when (this) { is NotLoaded, is LoadFailure, is PackageFailure -> true - is Loading, is Packaging, is Packaged, is Downloading, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false + is Loading, is Packaging, is Packaged, is Downloading, is DownloadCancelled, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false } /** @@ -372,8 +396,8 @@ internal sealed class OnDemandStatus { */ val allowsDownload: Boolean get() = when (this) { - is Packaged, is DownloadFailure -> true - is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is Downloaded, is MmpkLoadFailure -> false + is Packaged -> true + is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is DownloadCancelled, is DownloadFailure,is Downloaded, is MmpkLoadFailure -> false } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index e558a68a1..16184c994 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getString +import com.arcgismaps.toolkit.offline.OfflineMapMode import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton @@ -92,6 +93,7 @@ internal fun PreplannedMapAreas( sheetState = sheetState, scope = scope, onDismiss = { showSheet = false }, + offlineMapMode = OfflineMapMode.Preplanned, thumbnail = selectedPreplannedMapAreaState.thumbnail?.asImageBitmap(), title = selectedPreplannedMapAreaState.title, description = selectedPreplannedMapAreaState.description, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index 45fd3cf9f..af9abd0e8 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ImageNotSupported import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -53,6 +54,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.arcgismaps.toolkit.offline.OfflineMapMode import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.formatSize import com.arcgismaps.toolkit.offline.internal.utils.htmlToPlainText @@ -67,6 +69,7 @@ internal fun MapAreaDetailsBottomSheet( sheetState: SheetState, scope: CoroutineScope, onDismiss: () -> Unit, + offlineMapMode: OfflineMapMode, thumbnail: ImageBitmap?, title: String, description: String?, @@ -86,6 +89,7 @@ internal fun MapAreaDetailsBottomSheet( sheetState = sheetState ) { MapAreaDetailsScreen( + offlineMapMode = offlineMapMode, thumbnail = thumbnail, title = title, description = description, @@ -101,6 +105,7 @@ internal fun MapAreaDetailsBottomSheet( @Composable internal fun MapAreaDetailsScreen( + offlineMapMode: OfflineMapMode, thumbnail: ImageBitmap?, title: String, description: String?, @@ -190,7 +195,12 @@ internal fun MapAreaDetailsScreen( } if (isDeletable) { Button(onClick = { onDeleteDownload() }) { - Text(stringResource(id = R.string.remove_download)) + Text( + if (offlineMapMode == OfflineMapMode.Preplanned) + stringResource(id = R.string.remove_download) + else + stringResource(R.string.delete_download) + ) } } } @@ -203,6 +213,7 @@ private fun PreviewMapAreaDetailsScreen() { MaterialTheme { Surface { MapAreaDetailsScreen( + offlineMapMode = OfflineMapMode.Preplanned, thumbnail = null, title = "City Hall Area", description = "A map that contains stormwater network within...", diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt index 4978151da..90272260c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt @@ -31,27 +31,27 @@ import java.io.File internal fun logWorkInfo(workInfo: WorkInfo) { when (workInfo.state) { WorkInfo.State.ENQUEUED -> { - Log.e(TAG, "${workInfo.tags}: ENQUEUED") + Log.d(TAG, "${workInfo.tags}: Enqueued") } WorkInfo.State.SUCCEEDED -> { - Log.e(TAG, "${workInfo.tags}: SUCCEEDED") + Log.d(TAG, "${workInfo.tags}: Succeeded") } WorkInfo.State.BLOCKED -> { - Log.e(TAG, "${workInfo.tags}: BLOCKED") + Log.d(TAG, "${workInfo.tags}: Blocked") } WorkInfo.State.RUNNING -> { - Log.e(TAG, "${workInfo.tags}: RUNNING ${workInfo.progress.getInt("Progress", 0)}") + Log.d(TAG, "${workInfo.tags}: Running - Progress: ${workInfo.progress.getInt("Progress", 0)}") } WorkInfo.State.FAILED -> { - Log.e(TAG, "${workInfo.tags}: FAILED: ${workInfo.outputData.getString("Error")} - Details: ${workInfo.outputData.keyValueMap}") + Log.e(TAG, "${workInfo.tags}: Failed: ${workInfo.outputData.getString("Error")} - Details: ${workInfo.outputData.keyValueMap}") } WorkInfo.State.CANCELLED -> { - Log.e(TAG, "${workInfo.tags}: CANCELLED - Stop reason: ${workInfo.stopReason.toStopReasonString()}, ${workInfo.outputData.getString("Error")} - Details: ${workInfo.outputData.keyValueMap}") + Log.e(TAG, "${workInfo.tags}: Cancelled - Stop reason: ${workInfo.stopReason.toStopReasonString()}}") } } } diff --git a/toolkit/offline/src/main/res/values/strings.xml b/toolkit/offline/src/main/res/values/strings.xml index 19e59e7d1..9a74de1d4 100644 --- a/toolkit/offline/src/main/res/values/strings.xml +++ b/toolkit/offline/src/main/res/values/strings.xml @@ -33,8 +33,10 @@ Description Download Map Area Remove Download + Delete Download No Image Available Refresh + Cancelled No Internet Connection No internet connection. Showing downloaded areas only. Could not retrieve map areas for this map. From 149742b5a8e2bd459dfa7c8bbcbdb61bac8f44dd Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 15:25:44 -0700 Subject: [PATCH 092/132] code comments --- .../toolkit/offline/OfflineMapAreas.kt | 18 ++++++-- .../toolkit/offline/OfflineMapState.kt | 24 ++++++++--- .../toolkit/offline/internal/utils/Utils.kt | 41 +++++++++++++++++-- .../offline/ondemand/OnDemandMapAreasState.kt | 4 +- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index bc14e063c..c8e30b6aa 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -139,18 +139,22 @@ internal fun PreplannedLayoutContainer( onRefresh: () -> Unit ) { Column { + // Show "No Internet" message if offline models are displayed if (preplannedMapAreaStates.isNotEmpty()) { PreplannedMapAreas( preplannedMapAreaStates = preplannedMapAreaStates, modifier = modifier ) } + // Show "No Internet" message if offline models are displayed if (isShowingOnlyOfflineModels) { NoInternetNoAreas( onlyFooterVisible = preplannedMapAreaStates.isNotEmpty(), onRefresh = onRefresh ) - } else if (preplannedMapAreaStates.isEmpty()) { + } + // Show empty state message if no preplanned areas and online mode + else if (preplannedMapAreaStates.isEmpty()) { EmptyPreplannedOfflineAreas(onRefresh = onRefresh) } } @@ -166,28 +170,36 @@ internal fun OnDemandLayoutContainer( onDownloadDeleted: (OnDemandMapAreasState) -> Unit, onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit ) { + // Track visibility of the map area selector var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } - var isProposedTitleChangeUnique by rememberSaveable { mutableStateOf(false) } + // Track if the proposed map area title is unique + var isProposedTitleChangeUnique by rememberSaveable { mutableStateOf(true) } Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Show on-demand map areas if available if (onDemandMapAreaStates.isNotEmpty()) { OnDemandMapAreas( onDemandMapAreasStates = onDemandMapAreaStates, onDownloadDeleted = onDownloadDeleted, modifier = modifier ) + // Show "Add Map Area" button if not in offline-only mode if (!isShowingOnlyOfflineModels) { AddMapAreaButton { isOnDemandMapAreaSelectorVisible = true } } } + // Show "No Internet" message if offline models are displayed if (isShowingOnlyOfflineModels) { NoInternetNoAreas( onlyFooterVisible = onDemandMapAreaStates.isNotEmpty(), onRefresh = onRefresh ) - } else if (onDemandMapAreaStates.isEmpty()) { + } + // Show empty state message if no on-demand areas and online mode + else if (onDemandMapAreaStates.isEmpty()) { EmptyOnDemandOfflineAreas { isOnDemandMapAreaSelectorVisible = true } } } + // Map area selection bottom sheet OnDemandMapAreaSelector( localMap = localMap, showBottomSheet = isOnDemandMapAreaSelectorVisible, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index b209724a5..e35a2b4b5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -353,6 +353,11 @@ public class OfflineMapState( return null } + /** + * Creates and adds a new [OnDemandMapAreasState] instance based on the provided [configuration]. + * + * @since 200.8.0 + */ internal fun makeOnDemandMapAreaState( context: Context, configuration: OnDemandMapAreaConfiguration @@ -387,18 +392,25 @@ public class OfflineMapState( _initializationStatus.value = InitializationStatus.NotInitialized } - internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { - if (state.isSelectedToOpen){ + /** + * Removes a specific [PreplannedMapAreaState] from the list of preplanned map areas. + */ + internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { + if (state.isSelectedToOpen) { resetSelectedMapArea() } - _onDemandMapAreaStates.remove(state) + _preplannedMapAreaStates.remove(state) } - internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { - if (state.isSelectedToOpen){ + /** + * Removes a specific [OnDemandMapAreasState] from the list of on-demand map areas. + * @since 200.8.0 + */ + internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { + if (state.isSelectedToOpen) { resetSelectedMapArea() } - _preplannedMapAreaStates.remove(state) + _onDemandMapAreaStates.remove(state) } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index 7fb6018e2..c40efc3f5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -26,6 +26,11 @@ import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import java.io.File +/** + * Calculates the total size of a [path] recursively. + * + * @since 200.8.0 + */ internal fun getDirectorySize(path: String): Int { val file = File(path) if (!file.exists()) return 0 @@ -33,6 +38,12 @@ internal fun getDirectorySize(path: String): Int { return file.walkTopDown().filter { it.isFile }.map { it.length() }.sum().toInt() } +/** + * Formats a given [size] in bytes into readable string. + * (e.g., `123 B`, `1.2 KB`, `5.6 MB`, `2.3 GB`) + * + * @since 200.8.0 + */ internal fun formatSize(size: Int): String { return when { size < 1000 -> "$size B" @@ -42,10 +53,21 @@ internal fun formatSize(size: Int): String { } } + +/** + * Converts an [html] string to a plain text string. + * + * @since 200.8.0 + */ internal fun htmlToPlainText(html: String): String { return Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY).toString() } +/** + * Returns a unique default map area title: "Area 1","Area 2"... + * + * @since 200.8.0 + */ internal fun getDefaultMapAreaTitle(onDemandMapAreaStates: List): String { for (i in 1..Int.MAX_VALUE) { val title = "Area $i" @@ -56,6 +78,11 @@ internal fun getDefaultMapAreaTitle(onDemandMapAreaStates: List @@ -67,11 +94,17 @@ internal fun isValidMapAreaTitle( return true } -internal fun calculateEnvelope(fullSize: IntSize, mapViewProxy: MapViewProxy): Envelope? { - val inh = fullSize.width * 0.2 / 2 - val inv = fullSize.height * 0.2 / 2 +/** + * Returns an [Envelope] which is equal to 80% of the ratio of [mapViewSize] + * at the current viewpoint, to be treated as the area of interest. + * + * @since 200.8.0 + */ +internal fun calculateEnvelope(mapViewSize: IntSize, mapViewProxy: MapViewProxy): Envelope? { + val inh = mapViewSize.width * 0.2 / 2 + val inv = mapViewSize.height * 0.2 / 2 val minScreen = ScreenCoordinate(x = inh, y = inv) - val maxScreen = ScreenCoordinate(x = fullSize.width - inh, y = fullSize.height - inv) + val maxScreen = ScreenCoordinate(x = mapViewSize.width - inh, y = mapViewSize.height - inv) val minResult = mapViewProxy.screenToLocationOrNull(minScreen) val maxResult = mapViewProxy.screenToLocationOrNull(maxScreen) return if (minResult != null && maxResult != null) { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index c02e2ce70..0f3eaf20c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.graphics.drawable.toDrawable import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.Item @@ -178,6 +179,7 @@ internal class OnDemandMapAreasState( itemInfo?.apply { title = this@OnDemandMapAreasState.title description = "" + thumbnail = configuration?.thumbnail?.toDrawable(context.resources) } } @@ -397,7 +399,7 @@ internal sealed class OnDemandStatus { val allowsDownload: Boolean get() = when (this) { is Packaged -> true - is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is DownloadCancelled, is DownloadFailure,is Downloaded, is MmpkLoadFailure -> false + is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is DownloadCancelled, is DownloadFailure, is Downloaded, is MmpkLoadFailure -> false } /** From 33b72de3a8027a8da372ee1208f95eff89007e37 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 15:47:35 -0700 Subject: [PATCH 093/132] add preplanned onDownloadDeleted support --- .../main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt | 1 + .../arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index c8e30b6aa..910fb69fe 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -143,6 +143,7 @@ internal fun PreplannedLayoutContainer( if (preplannedMapAreaStates.isNotEmpty()) { PreplannedMapAreas( preplannedMapAreaStates = preplannedMapAreaStates, + onDownloadDeleted = { if (isShowingOnlyOfflineModels) onDownloadDeleted(it) }, modifier = modifier ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 16184c994..49fcb52a2 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.launch @Composable internal fun PreplannedMapAreas( preplannedMapAreaStates: List, + onDownloadDeleted: (PreplannedMapAreaState) -> Unit, modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } @@ -112,6 +113,7 @@ internal fun PreplannedMapAreas( isDeletable = selectedPreplannedMapAreaState.status.isDownloaded && !selectedPreplannedMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedPreplannedMapAreaState.removeDownloadedMapArea { !preplannedMapAreaStates.any { it.status.isDownloaded } } + onDownloadDeleted(selectedPreplannedMapAreaState) } ) } From e7e269f401cc0ca3d4051ac29a1d8f4c830a9c96 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 15:48:58 -0700 Subject: [PATCH 094/132] add preplanned onDownloadDeleted support --- .../main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt | 1 + .../arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index c8e30b6aa..910fb69fe 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -143,6 +143,7 @@ internal fun PreplannedLayoutContainer( if (preplannedMapAreaStates.isNotEmpty()) { PreplannedMapAreas( preplannedMapAreaStates = preplannedMapAreaStates, + onDownloadDeleted = { if (isShowingOnlyOfflineModels) onDownloadDeleted(it) }, modifier = modifier ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 16184c994..49fcb52a2 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -74,6 +74,7 @@ import kotlinx.coroutines.launch @Composable internal fun PreplannedMapAreas( preplannedMapAreaStates: List, + onDownloadDeleted: (PreplannedMapAreaState) -> Unit, modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } @@ -112,6 +113,7 @@ internal fun PreplannedMapAreas( isDeletable = selectedPreplannedMapAreaState.status.isDownloaded && !selectedPreplannedMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedPreplannedMapAreaState.removeDownloadedMapArea { !preplannedMapAreaStates.any { it.status.isDownloaded } } + onDownloadDeleted(selectedPreplannedMapAreaState) } ) } From 4d6d42eb207372f35f0a8b9f44e93ba1215c94e3 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 16:54:14 -0700 Subject: [PATCH 095/132] bottom sheet cleanup --- .../toolkit/offline/OfflineMapAreas.kt | 2 +- .../ondemand/OnDemandMapAreaSelector.kt | 4 +-- .../offline/ondemand/OnDemandMapAreas.kt | 28 ++++++++--------- .../offline/ondemand/OnDemandMapAreasState.kt | 2 +- .../offline/preplanned/PreplannedMapAreas.kt | 30 +++++++++---------- .../offline/ui/MapAreaDetailsScreen.kt | 6 +--- 6 files changed, 34 insertions(+), 38 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 910fb69fe..6d3e7f418 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -203,7 +203,7 @@ internal fun OnDemandLayoutContainer( // Map area selection bottom sheet OnDemandMapAreaSelector( localMap = localMap, - showBottomSheet = isOnDemandMapAreaSelectorVisible, + showSheet = isOnDemandMapAreaSelectorVisible, uniqueMapAreaTitle = getDefaultMapAreaTitle(onDemandMapAreaStates), isProposedTitleChangeUnique = isProposedTitleChangeUnique, onDismiss = { isOnDemandMapAreaSelectorVisible = false }, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 39bdecd64..ee6a0d8b3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -101,7 +101,7 @@ private val mapViewProxy = MapViewProxy() internal fun OnDemandMapAreaSelector( localMap: ArcGISMap, uniqueMapAreaTitle: String, - showBottomSheet: Boolean, + showSheet: Boolean, onDismiss: () -> Unit, isProposedTitleChangeUnique: Boolean, onProposedTitleChange: (String) -> Unit, @@ -118,7 +118,7 @@ internal fun OnDemandMapAreaSelector( onDismiss() } } - if (showBottomSheet) { + if (showSheet) { ModalBottomSheet( modifier = Modifier.systemBarsPadding(), onDismissRequest = { onHideSheet = true }, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 714627d37..87b412c6c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -38,10 +38,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -64,7 +64,6 @@ import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState -import kotlinx.coroutines.launch /** * Displays a list of on-demand map areas. @@ -78,21 +77,27 @@ internal fun OnDemandMapAreas( modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var onHideSheet by rememberSaveable { mutableStateOf(false) } var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } val selectedOnDemandMapAreaState = selectedIndex.takeIf { it in onDemandMapAreasStates.indices } ?.let { onDemandMapAreasStates[it] } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - val scope = rememberCoroutineScope() + LaunchedEffect(onHideSheet, sheetState.isVisible) { + if (onHideSheet) { + sheetState.hide() + onHideSheet = false + } + if (!sheetState.isVisible) { + showSheet = false + } + } // Show the modal bottom sheet if needed if (showSheet && selectedOnDemandMapAreaState != null) { MapAreaDetailsBottomSheet( showSheet = true, sheetState = sheetState, - scope = scope, onDismiss = { showSheet = false }, offlineMapMode = OfflineMapMode.OnDemand, thumbnail = selectedOnDemandMapAreaState.thumbnail?.asImageBitmap(), @@ -102,18 +107,13 @@ internal fun OnDemandMapAreas( isAvailableToDownload = selectedOnDemandMapAreaState.status.allowsDownload, onStartDownload = { selectedOnDemandMapAreaState.downloadOnDemandMapArea() - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - showSheet = false - } - } + onHideSheet = true }, isDeletable = selectedOnDemandMapAreaState.status.isDownloaded && !selectedOnDemandMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedOnDemandMapAreaState.removeDownloadedMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } onDownloadDeleted(selectedOnDemandMapAreaState) + onHideSheet = true } ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 0f3eaf20c..542caa5b1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -220,7 +220,7 @@ internal class OnDemandMapAreasState( workerUUID = OfflineRepository.createOnDemandMapAreaRequestAndQueueDownload( context = context, jsonJobPath = jsonJobFile.path, - onDemandMapAreaTitle = item.title + onDemandMapAreaTitle = configuration?.title ?: item.title ) return workerUUID diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 49fcb52a2..baa077b25 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -39,10 +39,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -64,7 +64,6 @@ import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState -import kotlinx.coroutines.launch /** * Displays a list of preplanned map areas. @@ -82,18 +81,24 @@ internal fun PreplannedMapAreas( val selectedPreplannedMapAreaState = selectedIndex.takeIf { it in preplannedMapAreaStates.indices } ?.let { preplannedMapAreaStates[it] } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var onHideSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(onHideSheet, sheetState.isVisible) { + if (onHideSheet) { + sheetState.hide() + onHideSheet = false + } + if (!sheetState.isVisible) { + showSheet = false + } + } // Show the modal bottom sheet if needed if (showSheet && selectedPreplannedMapAreaState != null) { MapAreaDetailsBottomSheet( showSheet = true, sheetState = sheetState, - scope = scope, - onDismiss = { showSheet = false }, + onDismiss = { onHideSheet = true }, offlineMapMode = OfflineMapMode.Preplanned, thumbnail = selectedPreplannedMapAreaState.thumbnail?.asImageBitmap(), title = selectedPreplannedMapAreaState.title, @@ -102,18 +107,13 @@ internal fun PreplannedMapAreas( isAvailableToDownload = selectedPreplannedMapAreaState.status.allowsDownload, onStartDownload = { selectedPreplannedMapAreaState.downloadPreplannedMapArea() - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - showSheet = false - } - } + onHideSheet = true }, isDeletable = selectedPreplannedMapAreaState.status.isDownloaded && !selectedPreplannedMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedPreplannedMapAreaState.removeDownloadedMapArea { !preplannedMapAreaStates.any { it.status.isDownloaded } } onDownloadDeleted(selectedPreplannedMapAreaState) + onHideSheet = true } ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index af9abd0e8..1e9e3f055 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ImageNotSupported import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -60,14 +59,11 @@ import com.arcgismaps.toolkit.offline.internal.utils.formatSize import com.arcgismaps.toolkit.offline.internal.utils.htmlToPlainText import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.SheetState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch @Composable internal fun MapAreaDetailsBottomSheet( showSheet: Boolean, sheetState: SheetState, - scope: CoroutineScope, onDismiss: () -> Unit, offlineMapMode: OfflineMapMode, thumbnail: ImageBitmap?, @@ -82,7 +78,7 @@ internal fun MapAreaDetailsBottomSheet( if (showSheet) { // Launch expanded when shown LaunchedEffect(Unit) { - scope.launch { sheetState.expand() } + sheetState.expand() } ModalBottomSheet( onDismissRequest = onDismiss, From 72ef54f93886b7897fcd7f533cbaeb13547af43f Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 25 Jun 2025 18:36:16 -0700 Subject: [PATCH 096/132] Add refreshOfflineMapInfos to app --- .../toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt index afabb2a41..5cc19a826 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/MapListScreen.kt @@ -213,6 +213,7 @@ fun MapListScreen( } } else { // Showing on device maps + OfflineRepository.refreshOfflineMapInfos(LocalContext.current) OnDeviceMapInfo( offlineMapInfos = OfflineRepository.offlineMapInfos, onClick = { itemId -> onItemClick(itemId) } From 42fe994abb49a5e6614c0c63d1d5ba341655ba29 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Fri, 27 Jun 2025 08:16:19 -0700 Subject: [PATCH 097/132] prototyping restoration --- .../toolkit/offline/OfflineMapState.kt | 42 ++++++++++++ .../toolkit/offline/OfflineRepository.kt | 66 ++++++++++++++++++- .../offline/ondemand/OnDemandMapAreas.kt | 5 ++ .../offline/ondemand/OnDemandMapAreasState.kt | 26 ++++++-- .../offline/preplanned/PreplannedMapAreas.kt | 5 ++ .../preplanned/PreplannedMapAreasState.kt | 26 ++++++-- 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index e35a2b4b5..15631cce3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -216,6 +216,8 @@ public class OfflineMapState( } _preplannedMapAreaStates.add(preplannedMapAreaState) } + // restore any running download job state + restoreJobsAndUpdateState(context) } } } @@ -263,6 +265,8 @@ public class OfflineMapState( _onDemandMapAreaStates.add(it) } } + // restore any running download job state + restoreJobsAndUpdateState(context) } /** @@ -394,6 +398,8 @@ public class OfflineMapState( /** * Removes a specific [PreplannedMapAreaState] from the list of preplanned map areas. + * + * @since 200.8.0 */ internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { if (state.isSelectedToOpen) { @@ -404,6 +410,7 @@ public class OfflineMapState( /** * Removes a specific [OnDemandMapAreasState] from the list of on-demand map areas. + * * @since 200.8.0 */ internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { @@ -412,6 +419,41 @@ public class OfflineMapState( } _onDemandMapAreaStates.remove(state) } + + /** + * Restores the current preplanned & on-demand job state from preferences. + * + * @since 200.8.0 + */ + private suspend fun restoreJobsAndUpdateState(context: Context) { + OfflineRepository.getActiveOfflineJobs(context, portalItem.itemId) + .forEach { workerUuid -> + val mapAreaId = OfflineRepository.getMapAreaForOfflineJob( + context = context, + uuid = workerUuid, + portalItemId = portalItem.itemId + ) + if (mode == OfflineMapMode.Preplanned) { + val preplannedMapAreaState = PreplannedMapAreaState( + context = context, + item = portalItem, + onSelectionChanged = onSelectionChanged + ).apply { restoreOfflineMapJobState(workerUuid) } + val duplicateMapAreaStates = _preplannedMapAreaStates.filter { + it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaId) + } + _preplannedMapAreaStates.removeAll(duplicateMapAreaStates) + _preplannedMapAreaStates.add(preplannedMapAreaState) + } else if (mode == OfflineMapMode.OnDemand) { + val onDemandMapAreasState = OnDemandMapAreasState( + context = context, + item = portalItem, + onSelectionChanged = onSelectionChanged + ).apply { restoreOfflineMapJobState(workerUuid) } + _onDemandMapAreaStates.add(onDemandMapAreasState) + } + } + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index 0c613c242..ee9d62ddd 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -25,6 +25,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.WorkQuery import androidx.work.workDataOf import com.arcgismaps.mapping.PortalItem import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState @@ -43,6 +44,8 @@ import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile import com.arcgismaps.toolkit.offline.workmanager.onDemandAreas import com.arcgismaps.toolkit.offline.workmanager.preplannedMapAreas +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.util.UUID @@ -374,6 +377,8 @@ public object OfflineRepository { */ internal fun createPreplannedMapAreaRequestAndQueueDownload( context: Context, + portalItemId: String, + mapAreaId: String, jsonJobPath: String, preplannedMapAreaTitle: String ): UUID { @@ -381,6 +386,9 @@ public object OfflineRepository { val workRequest = OneTimeWorkRequestBuilder() // run it as an expedited work .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + // add the worker tags + .addTag(portalItemId) + .addTag(mapAreaId) // add the input data .setInputData( // add the notificationId and the json file path as a key/value pair @@ -415,6 +423,8 @@ public object OfflineRepository { */ internal fun createOnDemandMapAreaRequestAndQueueDownload( context: Context, + portalItemId: String, + mapAreaId: String, jsonJobPath: String, onDemandMapAreaTitle: String ): UUID { @@ -422,6 +432,9 @@ public object OfflineRepository { val workRequest = OneTimeWorkRequestBuilder() // run it as an expedited work .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + // add the worker tags + .addTag(portalItemId) + .addTag(mapAreaId) // add the input data .setInputData( // add the notificationId and the json file path as a key/value pair @@ -626,12 +639,63 @@ public object OfflineRepository { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloading) } // don't have to handle other states - else -> {} + else -> { + + } } } } } + /** + * Returns preplanned/on-demand map area ID using the corresponding job [UUID]. + * + * @since 200.8.0 + */ + internal suspend fun getMapAreaForOfflineJob( + context: Context, + uuid: UUID, + portalItemId: String + ): String? { + val workManager = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder + .fromIds(listOf(uuid)) + .build() + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfos(workQuery).get() + } + val workerTags = workInfos.firstOrNull()?.tags ?: return null + workerTags.forEach { tag -> + // Skip non relevant tags, like: com.arcgismaps.toolkit.offline.workmanager. + if (tag != portalItemId && tag.length < 42) { + return tag + } + } + return null + } + + /** + * Returns the list of [UUID] for active running/enqueued jobs for the given [portalItemId]. + * + * @since 200.8.0 + */ + internal suspend fun getActiveOfflineJobs( + context: Context, + portalItemId: String + ): List { + val workManager = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder + .fromTags(listOf(portalItemId)) + .build() + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfos(workQuery).get() + } + val activePortalItemWorkers = workInfos.filter { workInfo -> + (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) + } + return activePortalItemWorkers.map { it.id } + } + /** * Cancels a WorkManager request by its unique identifier (UUID). * diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 87b412c6c..9c3234c85 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -93,6 +94,10 @@ internal fun OnDemandMapAreas( } } + DisposableEffect(Unit) { + onDispose { onDemandMapAreasStates.forEach { it.disposeScope() } } + } + // Show the modal bottom sheet if needed if (showSheet && selectedOnDemandMapAreaState != null) { MapAreaDetailsBottomSheet( diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 542caa5b1..b699647df 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -98,7 +98,7 @@ internal class OnDemandMapAreasState( internal val directorySize: Int get() = _directorySize - private lateinit var scope: CoroutineScope + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) internal val title = configuration?.title ?: item.title @@ -111,7 +111,6 @@ internal class OnDemandMapAreasState( * @since 200.8.0 */ internal fun downloadOnDemandMapArea() = runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable @@ -124,7 +123,7 @@ internal class OnDemandMapAreasState( onDemandMapAreaID = onDemandMapAreaID, downloadMapArea = downloadMapArea, offlineMapTask = task - ) + ), onDemandMapAreaId = onDemandMapAreaID ) OfflineRepository.observeStatusForOnDemandWork( context = context, @@ -211,7 +210,10 @@ internal class OnDemandMapAreasState( * * @since 200.8.0 */ - private fun startOfflineMapJob(downloadOnDemandOfflineMapJob: GenerateOfflineMapJob): UUID { + private fun startOfflineMapJob( + downloadOnDemandOfflineMapJob: GenerateOfflineMapJob, + onDemandMapAreaId: String + ): UUID { val jsonJobFile = OfflineRepository.saveJobToDisk( jobPath = downloadOnDemandOfflineMapJob.downloadDirectoryPath, jobJson = downloadOnDemandOfflineMapJob.toJson() @@ -219,6 +221,8 @@ internal class OnDemandMapAreasState( workerUUID = OfflineRepository.createOnDemandMapAreaRequestAndQueueDownload( context = context, + portalItemId = item.itemId, + mapAreaId = onDemandMapAreaId, jsonJobPath = jsonJobFile.path, onDemandMapAreaTitle = configuration?.title ?: item.title ) @@ -318,6 +322,20 @@ internal class OnDemandMapAreasState( onSelectionChanged(map) } } + + fun restoreOfflineMapJobState(offlineWorkerUUID: UUID) { + scope.launch { + _status = OnDemandStatus.Downloading + OfflineRepository.observeStatusForOnDemandWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + onDemandMapAreasState = this@OnDemandMapAreasState, + portalItem = item as PortalItem, + offlineWorkerUUID = offlineWorkerUUID + ) + } + } + } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index baa077b25..28500323c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -93,6 +94,10 @@ internal fun PreplannedMapAreas( } } + DisposableEffect(Unit) { + onDispose { preplannedMapAreaStates.forEach { it.disposeScope() } } + } + // Show the modal bottom sheet if needed if (showSheet && selectedPreplannedMapAreaState != null) { MapAreaDetailsBottomSheet( diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 5acd9a7c5..5eb8fa50c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -84,7 +84,7 @@ internal class PreplannedMapAreaState( internal val directorySize: Int get() = _directorySize - private lateinit var scope: CoroutineScope + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) internal val title = item.title internal val description = item.description @@ -133,7 +133,6 @@ internal class PreplannedMapAreaState( */ internal fun downloadPreplannedMapArea() = runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) val area = preplannedMapArea ?: return@runCatchingCancellable val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable @@ -144,7 +143,7 @@ internal class PreplannedMapAreaState( downloadPreplannedOfflineMapJob = createOfflineMapJob( preplannedMapArea = area, offlineMapTask = task - ) + ), preplannedMapAreaId = area.portalItem.itemId ) OfflineRepository.observeStatusForPreplannedWork( context = context, @@ -222,14 +221,18 @@ internal class PreplannedMapAreaState( * * @since 200.8.0 */ - private fun startOfflineMapJob(downloadPreplannedOfflineMapJob: DownloadPreplannedOfflineMapJob): UUID { + private fun startOfflineMapJob( + downloadPreplannedOfflineMapJob: DownloadPreplannedOfflineMapJob, + preplannedMapAreaId: String + ): UUID { val jsonJobFile = OfflineRepository.saveJobToDisk( jobPath = downloadPreplannedOfflineMapJob.downloadDirectoryPath, jobJson = downloadPreplannedOfflineMapJob.toJson() ) - workerUUID = OfflineRepository.createPreplannedMapAreaRequestAndQueueDownload( context = context, + portalItemId = item.itemId, + mapAreaId = preplannedMapAreaId, jsonJobPath = jsonJobFile.path, preplannedMapAreaTitle = item.title ) @@ -315,6 +318,19 @@ internal class PreplannedMapAreaState( onSelectionChanged(map) } } + + fun restoreOfflineMapJobState(offlineWorkerUUID: UUID) { + scope.launch { + _status = PreplannedStatus.Downloading + OfflineRepository.observeStatusForPreplannedWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + preplannedMapAreaState = this@PreplannedMapAreaState, + portalItem = item as PortalItem, + offlineWorkerUUID = offlineWorkerUUID + ) + } + } } /** From 1ff1c279f522f84d3db075b2b63bf131d0d60e10 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Fri, 27 Jun 2025 14:22:36 -0700 Subject: [PATCH 098/132] prototyping DisposableEffect --- .../toolkit/offline/OfflineMapAreas.kt | 7 +++- .../toolkit/offline/OfflineMapState.kt | 33 +++++++++---------- .../toolkit/offline/OfflineRepository.kt | 5 +-- .../offline/ondemand/OnDemandMapAreasState.kt | 9 +++-- .../preplanned/PreplannedMapAreasState.kt | 10 ++++-- .../offline/workmanager/OfflineURLs.kt | 19 +++++++---- 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 6d3e7f418..62462626c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -65,11 +66,15 @@ public fun OfflineMapAreas( LaunchedEffect(offlineMapState, isRefreshEnabled) { if (isRefreshEnabled) { offlineMapState.resetInitialize() - } + } offlineMapState.initialize(context) isRefreshEnabled = false } + DisposableEffect(Unit) { + onDispose { isRefreshEnabled = true } + } + Surface(modifier = modifier) { when (initializationStatus) { is InitializationStatus.NotInitialized, InitializationStatus.Initializing -> { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 15631cce3..7cbe2c89f 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -229,18 +229,18 @@ public class OfflineMapState( * @since 200.8.0 */ private suspend fun loadOfflinePreplannedMapAreas(context: Context) { - val preplannedDirectory = File( - OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId) - ) - val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } - ?: emptyList() + OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId)?.let { preplannedDirPath -> + val preplannedMapAreaItemIds = + File(preplannedDirPath).listFiles()?.map { it.name.toString() } + ?: emptyList() - if (preplannedMapAreaItemIds.isNotEmpty()) - _mode = OfflineMapMode.Preplanned + if (preplannedMapAreaItemIds.isNotEmpty()) + _mode = OfflineMapMode.Preplanned - preplannedMapAreaItemIds.forEach { itemId -> - makeOfflinePreplannedMapAreaState(context, itemId) - ?.let { _preplannedMapAreaStates.add(it) } + preplannedMapAreaItemIds.forEach { itemId -> + makeOfflinePreplannedMapAreaState(context, itemId) + ?.let { _preplannedMapAreaStates.add(it) } + } } } @@ -280,13 +280,12 @@ public class OfflineMapState( context: Context, areaItemId: String ): PreplannedMapAreaState? { - val areaDir = File( - OfflineURLs.prePlannedDirectoryPath( - context = context, - portalItemID = portalItem.itemId, - preplannedMapAreaID = areaItemId - ) - ) + val preplannedDirPath = OfflineURLs.prePlannedDirectoryPath( + context = context, + portalItemID = portalItem.itemId, + preplannedMapAreaID = areaItemId + ) ?: return null + val areaDir = File(preplannedDirPath) if (!areaDir.exists() || !areaDir.isDirectory) return null val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index ee9d62ddd..7fd09d396 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -223,8 +223,9 @@ public object OfflineRepository { val destDirPath = OfflineURLs.prePlannedDirectoryPath( context = context, portalItemID = portalItemID, - preplannedMapAreaID = areaItemID - ) + preplannedMapAreaID = areaItemID, + isMakeDirsEnabled = true + ).toString() val destDir = File(destDirPath) cacheAreaDir.listFiles()?.forEach { child -> val target = File(destDir, child.name) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index b699647df..8057eeb6c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -44,6 +44,7 @@ import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File import java.util.UUID @@ -98,7 +99,7 @@ internal class OnDemandMapAreasState( internal val directorySize: Int get() = _directorySize - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) internal val title = configuration?.title ?: item.title @@ -115,7 +116,8 @@ internal class OnDemandMapAreasState( val portalItem = item as? PortalItem ?: return@runCatchingCancellable val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable val downloadMapArea = configuration.areaOfInterest - + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) scope.launch { _status = OnDemandStatus.Downloading val offlineWorkerUUID = startOfflineMapJob( @@ -324,7 +326,10 @@ internal class OnDemandMapAreasState( } fun restoreOfflineMapJobState(offlineWorkerUUID: UUID) { + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) scope.launch { + workerUUID = offlineWorkerUUID _status = OnDemandStatus.Downloading OfflineRepository.observeStatusForOnDemandWork( context = context, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 5eb8fa50c..febaaf722 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -44,6 +44,8 @@ import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File import java.util.UUID @@ -84,7 +86,7 @@ internal class PreplannedMapAreaState( internal val directorySize: Int get() = _directorySize - private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) internal val title = item.title internal val description = item.description @@ -136,7 +138,8 @@ internal class PreplannedMapAreaState( val area = preplannedMapArea ?: return@runCatchingCancellable val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable - + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) scope.launch { _status = PreplannedStatus.Downloading val offlineWorkerUUID = startOfflineMapJob( @@ -320,7 +323,10 @@ internal class PreplannedMapAreaState( } fun restoreOfflineMapJobState(offlineWorkerUUID: UUID) { + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) scope.launch { + workerUUID = offlineWorkerUUID _status = PreplannedStatus.Downloading OfflineRepository.observeStatusForPreplannedWork( context = context, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 58dd1d5f4..34d5f17b0 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -94,15 +94,22 @@ internal object OfflineURLs { internal fun prePlannedDirectoryPath( context: Context, portalItemID: String, - preplannedMapAreaID: String? = null - ): String { + preplannedMapAreaID: String? = null, + isMakeDirsEnabled: Boolean = false, + ): String? { val itemDir = File(portalItemDirectoryPath(context, portalItemID)) - val preplannedDir = File(itemDir, preplannedMapAreas).makeDirectoryIfItDoesNotExist() + val preplannedDir = File(itemDir, preplannedMapAreas) return if (preplannedMapAreaID != null) { - val areaDir = File(preplannedDir, preplannedMapAreaID).makeDirectoryIfItDoesNotExist() - areaDir.absolutePath + val areaDir = File(preplannedDir, preplannedMapAreaID) + if (isMakeDirsEnabled) { + areaDir.makeDirectoryIfItDoesNotExist() + } + if (areaDir.exists()) areaDir.absolutePath else null } else { - preplannedDir.absolutePath + if (isMakeDirsEnabled) { + preplannedDir.makeDirectoryIfItDoesNotExist() + } + if (preplannedDir.exists()) preplannedDir.absolutePath else null } } From 9e3123b04449503ee9e22595c3fb7ea534c38c99 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Fri, 27 Jun 2025 15:20:34 -0700 Subject: [PATCH 099/132] PR feedback and updated localizations --- .../toolkit/offline/OfflineMapAreas.kt | 7 ++++--- .../toolkit/offline/OfflineMapState.kt | 13 +++++++------ .../toolkit/offline/OfflineRepository.kt | 4 ---- .../toolkit/offline/internal/utils/Utils.kt | 13 +++++++++---- .../ondemand/OnDemandMapAreaSelector.kt | 18 +++++++++--------- .../toolkit/offline/ui/MapAreaDetailsScreen.kt | 5 ++++- .../offline/src/main/res/values/strings.xml | 9 +++++++++ 7 files changed, 42 insertions(+), 27 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 6d3e7f418..6b7a54c06 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -116,7 +116,7 @@ public fun OfflineMapAreas( onDownloadDeleted = offlineMapState::removeOnDemandMapArea, onDownloadMapAreaSelected = { onDemandConfig -> // Create the on-demand state and start the download - offlineMapState.makeOnDemandMapAreaState( + offlineMapState.createOnDemandMapAreaState( context = context, configuration = onDemandConfig ).downloadOnDemandMapArea() @@ -139,7 +139,7 @@ internal fun PreplannedLayoutContainer( onRefresh: () -> Unit ) { Column { - // Show "No Internet" message if offline models are displayed + // Show preplanned map areas if available if (preplannedMapAreaStates.isNotEmpty()) { PreplannedMapAreas( preplannedMapAreaStates = preplannedMapAreaStates, @@ -200,11 +200,12 @@ internal fun OnDemandLayoutContainer( EmptyOnDemandOfflineAreas { isOnDemandMapAreaSelectorVisible = true } } } + val context = LocalContext.current // Map area selection bottom sheet OnDemandMapAreaSelector( localMap = localMap, showSheet = isOnDemandMapAreaSelectorVisible, - uniqueMapAreaTitle = getDefaultMapAreaTitle(onDemandMapAreaStates), + uniqueMapAreaTitle = getDefaultMapAreaTitle(context, onDemandMapAreaStates), isProposedTitleChangeUnique = isProposedTitleChangeUnique, onDismiss = { isOnDemandMapAreaSelectorVisible = false }, onProposedTitleChange = { mapAreaTitle -> diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index e35a2b4b5..6660af938 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -237,7 +237,7 @@ public class OfflineMapState( _mode = OfflineMapMode.Preplanned preplannedMapAreaItemIds.forEach { itemId -> - makeOfflinePreplannedMapAreaState(context, itemId) + createOfflinePreplannedMapAreaState(context, itemId) ?.let { _preplannedMapAreaStates.add(it) } } } @@ -259,7 +259,7 @@ public class OfflineMapState( _mode = OfflineMapMode.OnDemand onDemandMapAreaItemIds.forEach { itemId -> - makeOfflineOnDemandMapAreaState(context, itemId)?.let { + createOfflineOnDemandMapAreaState(context, itemId)?.let { _onDemandMapAreaStates.add(it) } } @@ -272,7 +272,7 @@ public class OfflineMapState( * * @since 200.8.0 */ - private suspend fun makeOfflinePreplannedMapAreaState( + private suspend fun createOfflinePreplannedMapAreaState( context: Context, areaItemId: String ): PreplannedMapAreaState? { @@ -316,7 +316,7 @@ public class OfflineMapState( * * @since 200.8.0 */ - private suspend fun makeOfflineOnDemandMapAreaState( + private suspend fun createOfflineOnDemandMapAreaState( context: Context, areaItemId: String ): OnDemandMapAreasState? { @@ -349,8 +349,9 @@ public class OfflineMapState( mobileMapPackagePath = onDemandPath ) return onDemandMapAreasState - } else + } else { return null + } } /** @@ -358,7 +359,7 @@ public class OfflineMapState( * * @since 200.8.0 */ - internal fun makeOnDemandMapAreaState( + internal fun createOnDemandMapAreaState( context: Context, configuration: OnDemandMapAreaConfiguration ): OnDemandMapAreasState { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index 0c613c242..97cb9b0d8 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -512,10 +512,6 @@ public object OfflineRepository { } // if the work failed or was cancelled WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - // this removes the completed WorkInfo from the WorkManager's database - // otherwise, the observer will emit the WorkInfo on every launch - // until WorkManager auto-prunes - workManager.pruneWork() preplannedMapAreaState.updateStatus( PreplannedStatus.DownloadFailure( Exception( diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index c40efc3f5..122caadbd 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -18,11 +18,13 @@ package com.arcgismaps.toolkit.offline.internal.utils +import android.content.Context import android.text.Html import androidx.compose.ui.unit.IntSize import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.view.ScreenCoordinate import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import java.io.File @@ -68,9 +70,12 @@ internal fun htmlToPlainText(html: String): String { * * @since 200.8.0 */ -internal fun getDefaultMapAreaTitle(onDemandMapAreaStates: List): String { +internal fun getDefaultMapAreaTitle( + context: Context, + onDemandMapAreaStates: List +): String { for (i in 1..Int.MAX_VALUE) { - val title = "Area $i" + val title = context.getString(R.string.next_on_demand_area_title, i.toString()) if (onDemandMapAreaStates.none { it.title == title }) { return title } @@ -101,8 +106,8 @@ internal fun isValidMapAreaTitle( * @since 200.8.0 */ internal fun calculateEnvelope(mapViewSize: IntSize, mapViewProxy: MapViewProxy): Envelope? { - val inh = mapViewSize.width * 0.2 / 2 - val inv = mapViewSize.height * 0.2 / 2 + val inh = mapViewSize.width * 0.1 + val inv = mapViewSize.height * 0.1 val minScreen = ScreenCoordinate(x = inh, y = inv) val maxScreen = ScreenCoordinate(x = mapViewSize.width - inh, y = mapViewSize.height - inv) val minResult = mapViewProxy.screenToLocationOrNull(minScreen) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index ee6a0d8b3..32d6b15e3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -135,7 +135,7 @@ internal fun OnDemandMapAreaSelector( onDismiss = { onHideSheet = true }, onDownloadMapAreaSelected = { onDemandConfig -> onHideSheet = true - onDownloadMapAreaSelected.invoke(onDemandConfig) + onDownloadMapAreaSelected(onDemandConfig) } ) } @@ -175,7 +175,7 @@ private fun OnDemandMapAreaSelectorOptions( ) { Box(modifier = Modifier.fillMaxWidth()) { Text( - "Select area", + stringResource(R.string.select_area), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Center) ) @@ -184,7 +184,7 @@ private fun OnDemandMapAreaSelectorOptions( } } HorizontalDivider() - Text(text = "Pan and zoom to define the area", style = MaterialTheme.typography.labelSmall) + Text(text = stringResource(R.string.pan_and_zoom_text), style = MaterialTheme.typography.labelSmall) MapViewWithAreaSelector( modifier = Modifier.weight(1f), arcGISMap = localMap, @@ -213,7 +213,7 @@ private fun OnDemandMapAreaSelectorOptions( modifier = Modifier.size(16.dp) ) Spacer(Modifier.size(4.dp)) - Text("Rename", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.rename), style = MaterialTheme.typography.labelSmall) } } HorizontalDivider() @@ -252,7 +252,7 @@ private fun OnDemandMapAreaSelectorOptions( } } } - ) { Text("Download") } + ) { Text(stringResource(R.string.download)) } } } @@ -333,10 +333,10 @@ private fun AreaNameDialog( verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Enter a name", style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.enter_a_name), style = MaterialTheme.typography.titleLarge) OutlinedTextField( modifier = Modifier.fillMaxWidth(), - label = { Text("The name for the map area must be unique") }, + label = { Text(stringResource(R.string.unique_name_text)) }, value = areaName, singleLine = true, onValueChange = { newValue -> @@ -345,11 +345,11 @@ private fun AreaNameDialog( }, ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } TextButton( onClick = { onConfirm.invoke(areaName) }, enabled = isProposedTitleChangeUnique - ) { Text("Ok") } + ) { Text(stringResource(R.string.confirm)) } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index 1e9e3f055..6d818bcd9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -152,7 +152,10 @@ internal fun MapAreaDetailsScreen( // Size of the map area if (size != 0) { - Text(text = "Size: ${formatSize(size)}", style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.directory_size, formatSize(size)), + style = MaterialTheme.typography.bodyMedium + ) } if (description != null) { diff --git a/toolkit/offline/src/main/res/values/strings.xml b/toolkit/offline/src/main/res/values/strings.xml index 9a74de1d4..45e6a0340 100644 --- a/toolkit/offline/src/main/res/values/strings.xml +++ b/toolkit/offline/src/main/res/values/strings.xml @@ -37,6 +37,15 @@ No Image Available Refresh Cancelled + Cancel + OK + Rename + Enter a name + Select area + Pan and zoom to define the area + The name for the map area must be unique + Area %1$s + Size: %1$s No Internet Connection No internet connection. Showing downloaded areas only. Could not retrieve map areas for this map. From 53e1fb7fc482e9f4886be462bfd414b3fb195a78 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 08:36:02 -0700 Subject: [PATCH 100/132] moved JobResultToDestination step to Workers --- .../toolkit/offline/OfflineMapAreaMetadata.kt | 17 ++++ .../toolkit/offline/OfflineMapAreas.kt | 4 - .../toolkit/offline/OfflineMapState.kt | 80 ++++++++++++++----- .../toolkit/offline/OfflineRepository.kt | 18 ++--- .../offline/ondemand/OnDemandMapAreas.kt | 4 - .../offline/preplanned/PreplannedMapAreas.kt | 4 - .../workmanager/OnDemandMapAreaJobWorker.kt | 10 ++- .../workmanager/PreplannedMapAreaJobWorker.kt | 9 ++- 8 files changed, 97 insertions(+), 49 deletions(-) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt new file mode 100644 index 000000000..31a2ac2fe --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt @@ -0,0 +1,17 @@ +package com.arcgismaps.toolkit.offline + +import android.graphics.Bitmap +import androidx.annotation.StringRes + +internal data class OfflineMapAreaMetadata( + val title: String, + val thumbnailImage: Bitmap?, + val description: String, + val isDownloaded: Boolean, + val allowsDownload: Boolean, + val directorySize: Int, + val dismissMetadataViewOnDelete: Boolean, + @StringRes val removeDownloadButtonTextResId: Int +){ + +} \ No newline at end of file diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 90c37294a..ddf99366f 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -71,10 +71,6 @@ public fun OfflineMapAreas( isRefreshEnabled = false } - DisposableEffect(Unit) { - onDispose { isRefreshEnabled = true } - } - Surface(modifier = modifier) { when (initializationStatus) { is InitializationStatus.NotInitialized, InitializationStatus.Initializing -> { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 6660af938..e04fd43d8 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -216,6 +216,8 @@ public class OfflineMapState( } _preplannedMapAreaStates.add(preplannedMapAreaState) } + // restore any running download job state + restoreJobsAndUpdateState(context) } } } @@ -227,18 +229,18 @@ public class OfflineMapState( * @since 200.8.0 */ private suspend fun loadOfflinePreplannedMapAreas(context: Context) { - val preplannedDirectory = File( - OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId) - ) - val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } - ?: emptyList() + OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId)?.let { preplannedDirPath -> + val preplannedMapAreaItemIds = + File(preplannedDirPath).listFiles()?.map { it.name.toString() } + ?: emptyList() - if (preplannedMapAreaItemIds.isNotEmpty()) - _mode = OfflineMapMode.Preplanned + if (preplannedMapAreaItemIds.isNotEmpty()) + _mode = OfflineMapMode.Preplanned - preplannedMapAreaItemIds.forEach { itemId -> - createOfflinePreplannedMapAreaState(context, itemId) - ?.let { _preplannedMapAreaStates.add(it) } + preplannedMapAreaItemIds.forEach { itemId -> + createOfflinePreplannedMapAreaState(context, itemId) + ?.let { _preplannedMapAreaStates.add(it) } + } } } @@ -263,6 +265,8 @@ public class OfflineMapState( _onDemandMapAreaStates.add(it) } } + // restore any running download job state + restoreJobsAndUpdateState(context) } /** @@ -276,13 +280,12 @@ public class OfflineMapState( context: Context, areaItemId: String ): PreplannedMapAreaState? { - val areaDir = File( - OfflineURLs.prePlannedDirectoryPath( - context = context, - portalItemID = portalItem.itemId, - preplannedMapAreaID = areaItemId - ) - ) + val preplannedDirPath = OfflineURLs.prePlannedDirectoryPath( + context = context, + portalItemID = portalItem.itemId, + preplannedMapAreaID = areaItemId + ) ?: return null + val areaDir = File(preplannedDirPath) if (!areaDir.exists() || !areaDir.isDirectory) return null val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } @@ -349,9 +352,8 @@ public class OfflineMapState( mobileMapPackagePath = onDemandPath ) return onDemandMapAreasState - } else { + } else return null - } } /** @@ -395,6 +397,8 @@ public class OfflineMapState( /** * Removes a specific [PreplannedMapAreaState] from the list of preplanned map areas. + * + * @since 200.8.0 */ internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { if (state.isSelectedToOpen) { @@ -405,6 +409,7 @@ public class OfflineMapState( /** * Removes a specific [OnDemandMapAreasState] from the list of on-demand map areas. + * * @since 200.8.0 */ internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { @@ -413,6 +418,43 @@ public class OfflineMapState( } _onDemandMapAreaStates.remove(state) } + + /** + * Restores the current preplanned & on-demand job state from preferences. + * + * @since 200.8.0 + */ + private suspend fun restoreJobsAndUpdateState(context: Context) { + OfflineRepository.getActiveOfflineJobs(context, portalItem.itemId) + .forEach { workerUuid -> + val mapAreaId = OfflineRepository.getMapAreaForOfflineJob( + context = context, + uuid = workerUuid, + portalItemId = portalItem.itemId + ) + if (mode == OfflineMapMode.Preplanned) { + val restoredState = PreplannedMapAreaState( + context = context, + item = portalItem, + onSelectionChanged = onSelectionChanged + ).apply { restoreOfflineMapJobState(workerUuid) } + val duplicateMapAreaStateIndex = _preplannedMapAreaStates.indexOfFirst { + it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaId) + } + // replace the loaded duplicate preplanned area, with the in-progress job state + _preplannedMapAreaStates[duplicateMapAreaStateIndex] = restoredState + + } else if (mode == OfflineMapMode.OnDemand) { + val restoredState = OnDemandMapAreasState( + context = context, + item = portalItem, + onSelectionChanged = onSelectionChanged + ).apply { restoreOfflineMapJobState(workerUuid) } + // add the in-progress job states after loading on-demand map areas + _onDemandMapAreaStates.add(restoredState) + } + } + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index d7fc7dbde..8af9d47cd 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -187,7 +187,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun loadOfflineMapInfos(context: Context): List { + internal fun loadOfflineMapInfos(context: Context): List { val baseDir = File(OfflineURLs.offlineRepositoryDirectoryPath(context)) val offlineMapInfos = mutableListOf() if (!baseDir.exists() || !baseDir.isDirectory) { @@ -212,7 +212,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun movePreplannedJobResultToDestination( + internal fun movePreplannedJobResultToDestination( context: Context, offlineMapCacheDownloadPath: String ): File { @@ -244,7 +244,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun moveOnDemandJobResultToDestination( + internal fun moveOnDemandJobResultToDestination( context: Context, offlineMapCacheDownloadPath: String ): File { @@ -493,12 +493,10 @@ public object OfflineRepository { // if work completed successfully WorkInfo.State.SUCCEEDED -> { preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) - workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> - // using the pending path, move the result to final destination path - val destDir = movePreplannedJobResultToDestination(context, path) + workInfo.outputData.getString(mobileMapPackagePathKey)?.let { mmpkPath -> // create & load the downloaded map preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = destDir.absolutePath + mobileMapPackagePath = mmpkPath ) // skip adding map info if it already exists in the list if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { @@ -583,12 +581,10 @@ public object OfflineRepository { // if work completed successfully WorkInfo.State.SUCCEEDED -> { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) - workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> - // using the pending path, move the result to final destination path - val destDir = moveOnDemandJobResultToDestination(context, path) + workInfo.outputData.getString(mobileMapPackagePathKey)?.let { mmpkPath -> // create & load the downloaded map onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = destDir.absolutePath + mobileMapPackagePath = mmpkPath ) // skip adding map info if it already exists in the list if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 9c3234c85..192d8422d 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -94,10 +94,6 @@ internal fun OnDemandMapAreas( } } - DisposableEffect(Unit) { - onDispose { onDemandMapAreasStates.forEach { it.disposeScope() } } - } - // Show the modal bottom sheet if needed if (showSheet && selectedOnDemandMapAreaState != null) { MapAreaDetailsBottomSheet( diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 28500323c..026c9eb0e 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -94,10 +94,6 @@ internal fun PreplannedMapAreas( } } - DisposableEffect(Unit) { - onDispose { preplannedMapAreaStates.forEach { it.disposeScope() } } - } - // Show the modal bottom sheet if needed if (showSheet && selectedPreplannedMapAreaState != null) { MapAreaDetailsBottomSheet( diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt index 6b6d97299..279b8b504 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import com.arcgismaps.tasks.JobStatus import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob +import com.arcgismaps.toolkit.offline.OfflineRepository.moveOnDemandJobResultToDestination import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin @@ -160,11 +161,12 @@ internal class OnDemandMapAreaJobWorker( if (jobResult.isSuccess) { workerNotification.showStatusNotification("The download for $jobAreaTitle has completed successfully.") val downloadOnDemandOfflineMapResult = jobResult.getOrNull() - val outputData = workDataOf( - mobileMapPackagePathKey to (downloadOnDemandOfflineMapResult?.mobileMapPackage?.path - ?: ""), + // using the pending path, move the result to final destination path + val mmpkDestDir = moveOnDemandJobResultToDestination( + context = context, + offlineMapCacheDownloadPath = downloadOnDemandOfflineMapResult?.mobileMapPackage?.path.toString() ) - Result.success(outputData) + Result.success(workDataOf(mobileMapPackagePathKey to mmpkDestDir.absolutePath)) } else { val errorMessage = jobResult.exceptionOrNull()?.message ?: "Unknown error during job execution" diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt index ec13a0eb3..26286daac 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import com.arcgismaps.tasks.JobStatus import com.arcgismaps.tasks.offlinemaptask.DownloadPreplannedOfflineMapJob +import com.arcgismaps.toolkit.offline.OfflineRepository.movePreplannedJobResultToDestination import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -159,10 +160,12 @@ internal class PreplannedMapAreaJobWorker( if (jobResult.isSuccess) { workerNotification.showStatusNotification("The download for $jobAreaTitle has completed successfully.") val downloadPreplannedOfflineMapResult = jobResult.getOrNull() - val outputData = workDataOf( - mobileMapPackagePathKey to (downloadPreplannedOfflineMapResult?.mobileMapPackage?.path ?: ""), + // using the pending path, move the result to final destination path + val mmpkDestDir = movePreplannedJobResultToDestination( + context = context, + offlineMapCacheDownloadPath = downloadPreplannedOfflineMapResult?.mobileMapPackage?.path.toString() ) - Result.success(outputData) + Result.success(workDataOf(mobileMapPackagePathKey to mmpkDestDir.absolutePath)) } else { val errorMessage = jobResult.exceptionOrNull()?.message ?: "Unknown error during job execution" From 42184aeba7c15e3c9335228ee2f27087b1081a5a Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 12:27:48 -0700 Subject: [PATCH 101/132] added metadata functionality --- .../toolkit/offline/OfflineMapAreaMetadata.kt | 90 ++++++- .../toolkit/offline/OfflineMapState.kt | 10 +- .../toolkit/offline/OfflineRepository.kt | 228 +++++++++++------- .../offline/ondemand/OnDemandMapAreasState.kt | 20 +- .../preplanned/PreplannedMapAreasState.kt | 19 +- .../toolkit/offline/workmanager/Constants.kt | 1 + .../offline/workmanager/OfflineURLs.kt | 20 ++ 7 files changed, 276 insertions(+), 112 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt index 31a2ac2fe..c7f3abf2c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt @@ -1,17 +1,87 @@ package com.arcgismaps.toolkit.offline import android.graphics.Bitmap -import androidx.annotation.StringRes +import android.graphics.BitmapFactory +import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration +import com.arcgismaps.toolkit.offline.workmanager.offlineAreaMetadataJsonFile +import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileOutputStream +@Serializable internal data class OfflineMapAreaMetadata( + val areaId: String, + @Transient val thumbnailImage: Bitmap? = null, val title: String, - val thumbnailImage: Bitmap?, - val description: String, - val isDownloaded: Boolean, - val allowsDownload: Boolean, - val directorySize: Int, - val dismissMetadataViewOnDelete: Boolean, - @StringRes val removeDownloadButtonTextResId: Int -){ + val description: String +) { -} \ No newline at end of file + companion object { + internal fun createPreplannedMetadata(preplannedMapArea: PreplannedMapArea): OfflineMapAreaMetadata { + return OfflineMapAreaMetadata( + areaId = preplannedMapArea.portalItem.itemId, + title = preplannedMapArea.portalItem.title, + thumbnailImage = preplannedMapArea.portalItem.thumbnail?.image?.bitmap, + description = preplannedMapArea.portalItem.description + ) + } + + internal fun createOnDemandMetadata(onDemandMapAreaConfiguration: OnDemandMapAreaConfiguration): OfflineMapAreaMetadata { + return OfflineMapAreaMetadata( + areaId = onDemandMapAreaConfiguration.areaID, + title = onDemandMapAreaConfiguration.title, + thumbnailImage = onDemandMapAreaConfiguration.thumbnail, + description = "" + ) + } + + internal fun createFromDirectory(directory: File): OfflineMapAreaMetadata? { + val metadataFile = File(directory, offlineAreaMetadataJsonFile) + if (!metadataFile.exists()) { + return null + } + val jsonString = runCatching { metadataFile.readText(Charsets.UTF_8) }.getOrNull() + ?: return null + val baseMetadata = runCatching { + Json.decodeFromString(serializer(), jsonString) + }.getOrNull() ?: return null + val thumbnailFile = File(directory, offlineMapInfoThumbnailFile) + val thumbnail: Bitmap? = if (thumbnailFile.exists()) { + BitmapFactory.decodeFile(thumbnailFile.absolutePath) + } else { + null + } + return OfflineMapAreaMetadata( + areaId = baseMetadata.areaId, + thumbnailImage = thumbnail, + title = baseMetadata.title, + description = baseMetadata.description + ) + } + + internal fun isSerializedFilePresent(directory: File): Boolean { + return File(directory, offlineAreaMetadataJsonFile).exists() + } + } + + internal fun saveToDirectory(directory: File) { + if (!directory.exists()) { + directory.mkdirs() + } + val metadataFile = File(directory, offlineAreaMetadataJsonFile) + val jsonString = Json.encodeToString(serializer(), this) + runCatching { metadataFile.writeText(jsonString, Charsets.UTF_8) } + thumbnailImage?.let { bmp -> + val thumbFile = File(directory, offlineMapInfoThumbnailFile) + runCatching { + FileOutputStream(thumbFile).use { out -> + bmp.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } + } + } +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index e04fd43d8..de4e50494 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -427,19 +427,19 @@ public class OfflineMapState( private suspend fun restoreJobsAndUpdateState(context: Context) { OfflineRepository.getActiveOfflineJobs(context, portalItem.itemId) .forEach { workerUuid -> - val mapAreaId = OfflineRepository.getMapAreaForOfflineJob( + val mapAreaMetadata = OfflineRepository.getMapAreaMetadataForOfflineJob( context = context, uuid = workerUuid, portalItemId = portalItem.itemId - ) + ) ?: return@forEach if (mode == OfflineMapMode.Preplanned) { val restoredState = PreplannedMapAreaState( context = context, item = portalItem, onSelectionChanged = onSelectionChanged - ).apply { restoreOfflineMapJobState(workerUuid) } + ).apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } val duplicateMapAreaStateIndex = _preplannedMapAreaStates.indexOfFirst { - it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaId) + it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaMetadata.areaId) } // replace the loaded duplicate preplanned area, with the in-progress job state _preplannedMapAreaStates[duplicateMapAreaStateIndex] = restoredState @@ -449,7 +449,7 @@ public class OfflineMapState( context = context, item = portalItem, onSelectionChanged = onSelectionChanged - ).apply { restoreOfflineMapJobState(workerUuid) } + ).apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } // add the in-progress job states after loading on-demand map areas _onDemandMapAreaStates.add(restoredState) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index 8af9d47cd..c5a76558a 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -40,6 +40,7 @@ import com.arcgismaps.toolkit.offline.workmanager.jobAreaTitleKey import com.arcgismaps.toolkit.offline.workmanager.jobWorkerUuidKey import com.arcgismaps.toolkit.offline.workmanager.jsonJobPathKey import com.arcgismaps.toolkit.offline.workmanager.mobileMapPackagePathKey +import com.arcgismaps.toolkit.offline.workmanager.offlineAreaMetadataJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile import com.arcgismaps.toolkit.offline.workmanager.onDemandAreas @@ -110,6 +111,10 @@ public object OfflineRepository { /** * Saves the [OfflineMapInfo] to the pending folder for a particular web map's portal item. * The info will stay in that folder until the job completes. + * + * - `/OfflineMapAreasCache/PendingMapInfo//info.json` + * + * @since 200.8.0 */ private fun savePendingMapInfo(context: Context, portalItem: PortalItem) { val pendingMapInfoDir = File( @@ -121,6 +126,84 @@ public object OfflineRepository { } } + /** + * Saves the [OfflineMapAreaMetadata] to the pending folder for map area of a web map's portal item. + * The info will stay in this folder until the job completes. + * + * - `/OfflineMapAreasCache/PendingMapInfo///metadata.json` + * + * @since 200.8.0 + */ + private fun savePendingMapAreaMetadata( + context: Context, + portalItem: PortalItem, + areaMetadata: OfflineMapAreaMetadata + ) { + val pendingAreaMetadataDir = File( + OfflineURLs.pendingAreaMetadataDirectoryPath( + context, portalItem.itemId, areaMetadata.areaId + ) + ) + if (!OfflineMapAreaMetadata.isSerializedFilePresent(pendingAreaMetadataDir)) { + areaMetadata.saveToDirectory(pendingAreaMetadataDir) + } + } + /** + * Returns preplanned/on-demand map area [OfflineMapAreaMetadata] using the corresponding job [UUID]. + * + * @since 200.8.0 + */ + internal suspend fun getMapAreaMetadataForOfflineJob( + context: Context, + uuid: UUID, + portalItemId: String + ): OfflineMapAreaMetadata? { + val workManager = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder + .fromIds(listOf(uuid)) + .build() + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfos(workQuery).get() + } + val workerTags = workInfos.firstOrNull()?.tags ?: return null + workerTags.forEach { tag -> + // Skip non relevant tags, like: com.arcgismaps.toolkit.offline.workmanager. + if (tag != portalItemId && tag.length < 42) { + val areaMetadataDir = File( + OfflineURLs.pendingAreaMetadataDirectoryPath( + context, portalItemId, tag + ) + ) + if (OfflineMapAreaMetadata.isSerializedFilePresent(areaMetadataDir)) { + return OfflineMapAreaMetadata.createFromDirectory(areaMetadataDir) + } + } + } + return null + } + + /** + * Returns the list of [UUID] for active running/enqueued jobs for the given [portalItemId]. + * + * @since 200.8.0 + */ + internal suspend fun getActiveOfflineJobs( + context: Context, + portalItemId: String + ): List { + val workManager = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder + .fromTags(listOf(portalItemId)) + .build() + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfos(workQuery).get() + } + val activePortalItemWorkers = workInfos.filter { workInfo -> + (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) + } + return activePortalItemWorkers.map { it.id } + } + /** * Creates and returns the directory file for a pending preplanned job. * @@ -476,8 +559,15 @@ public object OfflineRepository { portalItem: PortalItem, onWorkInfoStateChanged: (WorkInfo) -> Unit, ) { - val workManager = WorkManager.getInstance(context) savePendingMapInfo(context, portalItem) + preplannedMapAreaState.preplannedMapArea?.let { mapArea -> + savePendingMapAreaMetadata( + context = context, + portalItem = portalItem, + areaMetadata = OfflineMapAreaMetadata.createPreplannedMetadata(mapArea) + ) + } + val workManager = WorkManager.getInstance(context) // collect the flow to get the latest work info list workManager.getWorkInfoByIdFlow(offlineWorkerUUID) .collect { workInfo -> @@ -493,27 +583,28 @@ public object OfflineRepository { // if work completed successfully WorkInfo.State.SUCCEEDED -> { preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) - workInfo.outputData.getString(mobileMapPackagePathKey)?.let { mmpkPath -> - // create & load the downloaded map - preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = mmpkPath - ) - // skip adding map info if it already exists in the list - if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { - // create offline map information from local directory - OfflineMapInfo.createFromDirectory( - directory = File( - OfflineURLs.portalItemDirectoryPath( - context = context, - portalItemID = portalItem.itemId + workInfo.outputData.getString(mobileMapPackagePathKey) + ?.let { mmpkPath -> + // create & load the downloaded map + preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = mmpkPath + ) + // skip adding map info if it already exists in the list + if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { + // create offline map information from local directory + OfflineMapInfo.createFromDirectory( + directory = File( + OfflineURLs.portalItemDirectoryPath( + context = context, + portalItemID = portalItem.itemId + ) ) - ) - )?.let { - // if non-null info was created, add it to the list - _offlineMapInfos.add(it) + )?.let { + // if non-null info was created, add it to the list + _offlineMapInfos.add(it) + } } - } - } ?: run { + } ?: run { preplannedMapAreaState.updateStatus( PreplannedStatus.MmpkLoadFailure( Exception("Mobile Map Package path is null") @@ -564,8 +655,15 @@ public object OfflineRepository { portalItem: PortalItem, onWorkInfoStateChanged: (WorkInfo) -> Unit, ) { - val workManager = WorkManager.getInstance(context) savePendingMapInfo(context, portalItem) + onDemandMapAreasState.configuration?.let { mapArea -> + savePendingMapAreaMetadata( + context = context, + portalItem = portalItem, + areaMetadata = OfflineMapAreaMetadata.createOnDemandMetadata(mapArea) + ) + } + val workManager = WorkManager.getInstance(context) // collect the flow to get the latest work info list workManager.getWorkInfoByIdFlow(offlineWorkerUUID) .collect { workInfo -> @@ -581,27 +679,28 @@ public object OfflineRepository { // if work completed successfully WorkInfo.State.SUCCEEDED -> { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) - workInfo.outputData.getString(mobileMapPackagePathKey)?.let { mmpkPath -> - // create & load the downloaded map - onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = mmpkPath - ) - // skip adding map info if it already exists in the list - if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { - // create offline map information from local directory - OfflineMapInfo.createFromDirectory( - directory = File( - OfflineURLs.portalItemDirectoryPath( - context = context, - portalItemID = portalItem.itemId + workInfo.outputData.getString(mobileMapPackagePathKey) + ?.let { mmpkPath -> + // create & load the downloaded map + onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = mmpkPath + ) + // skip adding map info if it already exists in the list + if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { + // create offline map information from local directory + OfflineMapInfo.createFromDirectory( + directory = File( + OfflineURLs.portalItemDirectoryPath( + context = context, + portalItemID = portalItem.itemId + ) ) - ) - )?.let { - // if non-null info was created, add it to the list - _offlineMapInfos.add(it) + )?.let { + // if non-null info was created, add it to the list + _offlineMapInfos.add(it) + } } - } - } ?: run { + } ?: run { onDemandMapAreasState.updateStatus( OnDemandStatus.MmpkLoadFailure( Exception("Mobile Map Package path is null") @@ -640,55 +739,6 @@ public object OfflineRepository { } } - /** - * Returns preplanned/on-demand map area ID using the corresponding job [UUID]. - * - * @since 200.8.0 - */ - internal suspend fun getMapAreaForOfflineJob( - context: Context, - uuid: UUID, - portalItemId: String - ): String? { - val workManager = WorkManager.getInstance(context) - val workQuery = WorkQuery.Builder - .fromIds(listOf(uuid)) - .build() - val workInfos = withContext(Dispatchers.IO) { - workManager.getWorkInfos(workQuery).get() - } - val workerTags = workInfos.firstOrNull()?.tags ?: return null - workerTags.forEach { tag -> - // Skip non relevant tags, like: com.arcgismaps.toolkit.offline.workmanager. - if (tag != portalItemId && tag.length < 42) { - return tag - } - } - return null - } - - /** - * Returns the list of [UUID] for active running/enqueued jobs for the given [portalItemId]. - * - * @since 200.8.0 - */ - internal suspend fun getActiveOfflineJobs( - context: Context, - portalItemId: String - ): List { - val workManager = WorkManager.getInstance(context) - val workQuery = WorkQuery.Builder - .fromTags(listOf(portalItemId)) - .build() - val workInfos = withContext(Dispatchers.IO) { - workManager.getWorkInfos(workQuery).get() - } - val activePortalItemWorkers = workInfos.filter { workInfo -> - (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) - } - return activePortalItemWorkers.map { it.id } - } - /** * Cancels a WorkManager request by its unique identifier (UUID). * diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 8057eeb6c..76678c81a 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -35,6 +35,7 @@ import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapUpdateMode import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask +import com.arcgismaps.toolkit.offline.OfflineMapAreaMetadata import com.arcgismaps.toolkit.offline.OfflineRepository import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize @@ -71,7 +72,7 @@ internal data class OnDemandMapAreaConfiguration( internal class OnDemandMapAreasState( private val context: Context, private val item: Item, - private val configuration: OnDemandMapAreaConfiguration? = null, + internal val configuration: OnDemandMapAreaConfiguration? = null, private val offlineMapTask: OfflineMapTask? = null, private val onSelectionChanged: (ArcGISMap) -> Unit ) { @@ -101,10 +102,13 @@ internal class OnDemandMapAreasState( private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - internal val title = configuration?.title ?: item.title + private var _title by mutableStateOf(configuration?.title ?: item.title) + internal val title get() = _title - internal val thumbnail: Bitmap? - get() = configuration?.thumbnail ?: item.thumbnail?.image?.bitmap + private var _thumbnail by mutableStateOf( + configuration?.thumbnail ?: item.thumbnail?.image?.bitmap + ) + internal val thumbnail: Bitmap? get() = _thumbnail /** * Initiates downloading of the associated on-demand map area for offline use. @@ -325,7 +329,13 @@ internal class OnDemandMapAreasState( } } - fun restoreOfflineMapJobState(offlineWorkerUUID: UUID) { + fun restoreOfflineMapJobState( + offlineWorkerUUID: UUID, + offlineMapAreaMetadata: OfflineMapAreaMetadata + ) { + _title = offlineMapAreaMetadata.title + _thumbnail = offlineMapAreaMetadata.thumbnailImage + if (!scope.isActive) scope = CoroutineScope(Dispatchers.IO) scope.launch { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index febaaf722..5357bfe85 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -36,6 +36,7 @@ import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea import com.arcgismaps.tasks.offlinemaptask.PreplannedPackagingStatus import com.arcgismaps.tasks.offlinemaptask.PreplannedUpdateMode +import com.arcgismaps.toolkit.offline.OfflineMapAreaMetadata import com.arcgismaps.toolkit.offline.OfflineRepository import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize import com.arcgismaps.toolkit.offline.runCatchingCancellable @@ -88,8 +89,13 @@ internal class PreplannedMapAreaState( private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - internal val title = item.title - internal val description = item.description + private var _title by mutableStateOf(preplannedMapArea?.portalItem?.title ?: item.title) + internal val title get() = _title + + private var _description by mutableStateOf( + preplannedMapArea?.portalItem?.description ?: item.description + ) + internal val description get() = _description private var _thumbnail by mutableStateOf(null) internal val thumbnail: Bitmap? get() = _thumbnail ?: item.thumbnail?.image?.bitmap @@ -322,7 +328,14 @@ internal class PreplannedMapAreaState( } } - fun restoreOfflineMapJobState(offlineWorkerUUID: UUID) { + fun restoreOfflineMapJobState( + offlineWorkerUUID: UUID, + offlineMapAreaMetadata: OfflineMapAreaMetadata + ) { + _title = offlineMapAreaMetadata.title + _description = offlineMapAreaMetadata.description + _thumbnail = offlineMapAreaMetadata.thumbnailImage + if (!scope.isActive) scope = CoroutineScope(Dispatchers.IO) scope.launch { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt index 673b4efc5..e70d57c58 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt @@ -37,6 +37,7 @@ internal const val onDemandAreas = "OnDemand" // Offline Map Info constants internal const val offlineMapInfoJsonFile = "info.json" +internal const val offlineAreaMetadataJsonFile = "metadata.json" internal const val offlineMapInfoThumbnailFile = "thumbnail.png" // Notification constants diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 34d5f17b0..522f9a76c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -156,6 +156,26 @@ internal object OfflineURLs { return itemPendingDir.absolutePath } + /** + * Returns the path to the map area's metadata directory from the external cache, + * creates the directory if it doesn’t already exist: + * + * - `/OfflineMapAreasCache/PendingMapInfo///` + * + * @since 200.8.0 + */ + internal fun pendingAreaMetadataDirectoryPath( + context: Context, + portalItemID: String, + mapAreaID: String + ): String { + val caches = getOfflineCacheDirPath(context) + val pendingBase = File(caches, pendingMapInfoDir).makeDirectoryIfItDoesNotExist() + val itemPendingDir = File(pendingBase, portalItemID).makeDirectoryIfItDoesNotExist() + val areaPendingDir = File(itemPendingDir, mapAreaID).makeDirectoryIfItDoesNotExist() + return areaPendingDir.absolutePath + } + /** * Returns the path to the “PendingMapInfo” directory from the external cache, * creates the directory if it doesn’t already exist: From 62c6860d4c69f828b8191a52f7899eb82516b1ec Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 13:44:11 -0700 Subject: [PATCH 102/132] doc cleanup --- .../toolkit/offline/OfflineMapAreaMetadata.kt | 50 ++++++++++- .../toolkit/offline/OfflineMapAreas.kt | 3 +- .../toolkit/offline/OfflineMapState.kt | 26 +++--- .../toolkit/offline/OfflineRepository.kt | 85 +++++++++---------- .../offline/ondemand/OnDemandMapAreas.kt | 1 - .../offline/ondemand/OnDemandMapAreasState.kt | 9 +- .../offline/preplanned/PreplannedMapAreas.kt | 1 - .../preplanned/PreplannedMapAreasState.kt | 9 +- .../offline/workmanager/OfflineURLs.kt | 18 ++-- 9 files changed, 123 insertions(+), 79 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt index c7f3abf2c..6529a3111 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt @@ -1,3 +1,21 @@ +/* + * + * Copyright 2025 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.offline import android.graphics.Bitmap @@ -12,15 +30,25 @@ import kotlinx.serialization.json.Json import java.io.File import java.io.FileOutputStream +/** + * Represents the metadata for an area of an offline map. + * + * @since 200.8.0 + */ @Serializable internal data class OfflineMapAreaMetadata( val areaId: String, - @Transient val thumbnailImage: Bitmap? = null, val title: String, - val description: String + val description: String, + @Transient val thumbnailImage: Bitmap? = null ) { companion object { + /** + * Creates an [OfflineMapAreaMetadata] from a [PreplannedMapArea]. + * + * @since 200.8.0 + */ internal fun createPreplannedMetadata(preplannedMapArea: PreplannedMapArea): OfflineMapAreaMetadata { return OfflineMapAreaMetadata( areaId = preplannedMapArea.portalItem.itemId, @@ -30,6 +58,11 @@ internal data class OfflineMapAreaMetadata( ) } + /** + * Creates an [OfflineMapAreaMetadata] from an [OnDemandMapAreaConfiguration]. + * + * @since 200.8.0 + */ internal fun createOnDemandMetadata(onDemandMapAreaConfiguration: OnDemandMapAreaConfiguration): OfflineMapAreaMetadata { return OfflineMapAreaMetadata( areaId = onDemandMapAreaConfiguration.areaID, @@ -39,6 +72,11 @@ internal data class OfflineMapAreaMetadata( ) } + /** + * Creates an [OfflineMapAreaMetadata] from a [directory] on disk, if “metadata.json” exists. + * + * @since 200.8.0 + */ internal fun createFromDirectory(directory: File): OfflineMapAreaMetadata? { val metadataFile = File(directory, offlineAreaMetadataJsonFile) if (!metadataFile.exists()) { @@ -63,11 +101,19 @@ internal data class OfflineMapAreaMetadata( ) } + /** + * Returns true if metadata.json is found in the [directory]. + */ internal fun isSerializedFilePresent(directory: File): Boolean { return File(directory, offlineAreaMetadataJsonFile).exists() } } + /** + * Save this OfflineMapAreaMetadata into a [directory] on disk. + * + * @since 200.8.0 + */ internal fun saveToDirectory(directory: File) { if (!directory.exists()) { directory.mkdirs() diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index ddf99366f..6b7a54c06 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -66,7 +65,7 @@ public fun OfflineMapAreas( LaunchedEffect(offlineMapState, isRefreshEnabled) { if (isRefreshEnabled) { offlineMapState.resetInitialize() - } + } offlineMapState.initialize(context) isRefreshEnabled = false } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index de4e50494..ed33b0602 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -229,18 +229,17 @@ public class OfflineMapState( * @since 200.8.0 */ private suspend fun loadOfflinePreplannedMapAreas(context: Context) { - OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId)?.let { preplannedDirPath -> - val preplannedMapAreaItemIds = - File(preplannedDirPath).listFiles()?.map { it.name.toString() } - ?: emptyList() - - if (preplannedMapAreaItemIds.isNotEmpty()) - _mode = OfflineMapMode.Preplanned + val preplannedDirectory = File( + OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId) + ) + val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } + ?: emptyList() + if (preplannedMapAreaItemIds.isNotEmpty()) + _mode = OfflineMapMode.Preplanned + preplannedMapAreaItemIds.forEach { itemId -> + createOfflinePreplannedMapAreaState(context, itemId) + ?.let { _preplannedMapAreaStates.add(it) } - preplannedMapAreaItemIds.forEach { itemId -> - createOfflinePreplannedMapAreaState(context, itemId) - ?.let { _preplannedMapAreaStates.add(it) } - } } } @@ -284,7 +283,7 @@ public class OfflineMapState( context = context, portalItemID = portalItem.itemId, preplannedMapAreaID = areaItemId - ) ?: return null + ) val areaDir = File(preplannedDirPath) if (!areaDir.exists() || !areaDir.isDirectory) return null val mmpk = MobileMapPackage(areaDir.absolutePath).apply { @@ -308,8 +307,9 @@ public class OfflineMapState( mobileMapPackagePath = preplannedPath ) return preplannedMapAreaState - } else + } else { return null + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index c5a76558a..ba0c6f23d 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -306,8 +306,7 @@ public object OfflineRepository { val destDirPath = OfflineURLs.prePlannedDirectoryPath( context = context, portalItemID = portalItemID, - preplannedMapAreaID = areaItemID, - isMakeDirsEnabled = true + preplannedMapAreaID = areaItemID ).toString() val destDir = File(destDirPath) cacheAreaDir.listFiles()?.forEach { child -> @@ -583,28 +582,27 @@ public object OfflineRepository { // if work completed successfully WorkInfo.State.SUCCEEDED -> { preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) - workInfo.outputData.getString(mobileMapPackagePathKey) - ?.let { mmpkPath -> - // create & load the downloaded map - preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = mmpkPath - ) - // skip adding map info if it already exists in the list - if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { - // create offline map information from local directory - OfflineMapInfo.createFromDirectory( - directory = File( - OfflineURLs.portalItemDirectoryPath( - context = context, - portalItemID = portalItem.itemId - ) + workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> + // create & load the downloaded map + preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = path + ) + // skip adding map info if it already exists in the list + if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { + // create offline map information from local directory + OfflineMapInfo.createFromDirectory( + directory = File( + OfflineURLs.portalItemDirectoryPath( + context = context, + portalItemID = portalItem.itemId ) - )?.let { - // if non-null info was created, add it to the list - _offlineMapInfos.add(it) - } + ) + )?.let { + // if non-null info was created, add it to the list + _offlineMapInfos.add(it) } - } ?: run { + } + } ?: run { preplannedMapAreaState.updateStatus( PreplannedStatus.MmpkLoadFailure( Exception("Mobile Map Package path is null") @@ -679,28 +677,27 @@ public object OfflineRepository { // if work completed successfully WorkInfo.State.SUCCEEDED -> { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) - workInfo.outputData.getString(mobileMapPackagePathKey) - ?.let { mmpkPath -> - // create & load the downloaded map - onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = mmpkPath - ) - // skip adding map info if it already exists in the list - if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { - // create offline map information from local directory - OfflineMapInfo.createFromDirectory( - directory = File( - OfflineURLs.portalItemDirectoryPath( - context = context, - portalItemID = portalItem.itemId - ) + workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> + // create & load the downloaded map + onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = path + ) + // skip adding map info if it already exists in the list + if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { + // create offline map information from local directory + OfflineMapInfo.createFromDirectory( + directory = File( + OfflineURLs.portalItemDirectoryPath( + context = context, + portalItemID = portalItem.itemId ) - )?.let { - // if non-null info was created, add it to the list - _offlineMapInfos.add(it) - } + ) + )?.let { + // if non-null info was created, add it to the list + _offlineMapInfos.add(it) } - } ?: run { + } + } ?: run { onDemandMapAreasState.updateStatus( OnDemandStatus.MmpkLoadFailure( Exception("Mobile Map Package path is null") @@ -731,9 +728,7 @@ public object OfflineRepository { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloading) } // don't have to handle other states - else -> { - - } + else -> {} } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 192d8422d..87b412c6c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -38,7 +38,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 76678c81a..39a48424b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -211,6 +211,7 @@ internal class OnDemandMapAreasState( * in WorkManager. * * @param downloadOnDemandOfflineMapJob The on-demand offline map job to execute using WorkManager. + * @param onDemandMapAreaId The map area ID of used to track the job state. * * @return A unique identifier ([UUID]) associated with this task within WorkManager's queue system. * @@ -329,13 +330,19 @@ internal class OnDemandMapAreasState( } } + /** + * Restores and observes the state of a given offline map download job. + * + * @since 200.8.0 + */ fun restoreOfflineMapJobState( offlineWorkerUUID: UUID, offlineMapAreaMetadata: OfflineMapAreaMetadata ) { + // restore the UI state _title = offlineMapAreaMetadata.title _thumbnail = offlineMapAreaMetadata.thumbnailImage - + // observe the active job if (!scope.isActive) scope = CoroutineScope(Dispatchers.IO) scope.launch { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 026c9eb0e..baa077b25 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 5357bfe85..0fbb2b1c9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -225,6 +225,7 @@ internal class PreplannedMapAreaState( * in WorkManager. * * @param downloadPreplannedOfflineMapJob The prepared offline map job to execute using WorkManager. + * @param preplannedMapAreaId The map area ID of used to track the job state. * * @return A unique identifier ([UUID]) associated with this task within WorkManager's queue system. * @@ -328,14 +329,20 @@ internal class PreplannedMapAreaState( } } + /** + * Restores and observes the state of a given offline map download job. + * + * @since 200.8.0 + */ fun restoreOfflineMapJobState( offlineWorkerUUID: UUID, offlineMapAreaMetadata: OfflineMapAreaMetadata ) { + // restore the UI state _title = offlineMapAreaMetadata.title _description = offlineMapAreaMetadata.description _thumbnail = offlineMapAreaMetadata.thumbnailImage - + // observe the active job if (!scope.isActive) scope = CoroutineScope(Dispatchers.IO) scope.launch { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 522f9a76c..7d5d8f155 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -81,7 +81,6 @@ internal object OfflineURLs { /** * Returns the path to the “Preplanned” subdirectory for a portal item, - * creates the directory if it doesn’t already exist: * * - `/com.esri.toolkit.offline//Preplanned/` * @@ -95,21 +94,14 @@ internal object OfflineURLs { context: Context, portalItemID: String, preplannedMapAreaID: String? = null, - isMakeDirsEnabled: Boolean = false, - ): String? { + ): String { val itemDir = File(portalItemDirectoryPath(context, portalItemID)) - val preplannedDir = File(itemDir, preplannedMapAreas) + val preplannedDir = File(itemDir, preplannedMapAreas).makeDirectoryIfItDoesNotExist() return if (preplannedMapAreaID != null) { - val areaDir = File(preplannedDir, preplannedMapAreaID) - if (isMakeDirsEnabled) { - areaDir.makeDirectoryIfItDoesNotExist() - } - if (areaDir.exists()) areaDir.absolutePath else null + val areaDir = File(preplannedDir, preplannedMapAreaID).makeDirectoryIfItDoesNotExist() + areaDir.absolutePath } else { - if (isMakeDirsEnabled) { - preplannedDir.makeDirectoryIfItDoesNotExist() - } - if (preplannedDir.exists()) preplannedDir.absolutePath else null + preplannedDir.absolutePath } } From cf496de472798cc321dd07659e297137c33e799b Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 14:00:27 -0700 Subject: [PATCH 103/132] more doc cleanup --- .../java/com/arcgismaps/toolkit/offline/OfflineMapState.kt | 6 ++++-- .../com/arcgismaps/toolkit/offline/OfflineRepository.kt | 2 +- .../toolkit/offline/ondemand/OnDemandMapAreasState.kt | 1 - .../arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index ed33b0602..81a834db7 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -234,12 +234,13 @@ public class OfflineMapState( ) val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } ?: emptyList() + if (preplannedMapAreaItemIds.isNotEmpty()) _mode = OfflineMapMode.Preplanned + preplannedMapAreaItemIds.forEach { itemId -> createOfflinePreplannedMapAreaState(context, itemId) ?.let { _preplannedMapAreaStates.add(it) } - } } @@ -352,8 +353,9 @@ public class OfflineMapState( mobileMapPackagePath = onDemandPath ) return onDemandMapAreasState - } else + } else { return null + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index ba0c6f23d..344dd4ef8 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -307,7 +307,7 @@ public object OfflineRepository { context = context, portalItemID = portalItemID, preplannedMapAreaID = areaItemID - ).toString() + ) val destDir = File(destDirPath) cacheAreaDir.listFiles()?.forEach { child -> val target = File(destDir, child.name) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 39a48424b..937c7979b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -357,7 +357,6 @@ internal class OnDemandMapAreasState( ) } } - } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 7d5d8f155..e83b95f45 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -81,6 +81,7 @@ internal object OfflineURLs { /** * Returns the path to the “Preplanned” subdirectory for a portal item, + * creates the directory if it doesn’t already exist: * * - `/com.esri.toolkit.offline//Preplanned/` * @@ -93,7 +94,7 @@ internal object OfflineURLs { internal fun prePlannedDirectoryPath( context: Context, portalItemID: String, - preplannedMapAreaID: String? = null, + preplannedMapAreaID: String? = null ): String { val itemDir = File(portalItemDirectoryPath(context, portalItemID)) val preplannedDir = File(itemDir, preplannedMapAreas).makeDirectoryIfItDoesNotExist() From 45e7f0082eb21621a0436de01931cc5900e815a5 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 14:25:57 -0700 Subject: [PATCH 104/132] testing minor fixes --- .../java/com/arcgismaps/toolkit/offline/OfflineMapState.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 81a834db7..604533b7c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -65,7 +65,7 @@ public class OfflineMapState( onSelectionChanged = onSelectionChanged ) - private var _mode: OfflineMapMode = OfflineMapMode.Unknown + private var _mode: OfflineMapMode by mutableStateOf(OfflineMapMode.Unknown) internal val mode: OfflineMapMode get() = _mode @@ -446,7 +446,7 @@ public class OfflineMapState( // replace the loaded duplicate preplanned area, with the in-progress job state _preplannedMapAreaStates[duplicateMapAreaStateIndex] = restoredState - } else if (mode == OfflineMapMode.OnDemand) { + } else { val restoredState = OnDemandMapAreasState( context = context, item = portalItem, From 21ff99d04476513e7d68f149204a8429ea216933 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 16:43:07 -0700 Subject: [PATCH 105/132] Add functionality to OnDemand (#904) --- .../toolkit/offline/OfflineMapAreas.kt | 168 ++++++++++++------ .../toolkit/offline/OfflineMapState.kt | 135 +++++++++++--- .../toolkit/offline/OfflineRepository.kt | 40 +++-- .../toolkit/offline/internal/utils/Buttons.kt | 87 ++++++--- .../toolkit/offline/internal/utils/Utils.kt | 75 ++++++++ .../ondemand/OnDemandMapAreaSelector.kt | 157 ++++++++-------- .../offline/ondemand/OnDemandMapAreas.kt | 57 +++--- .../offline/ondemand/OnDemandMapAreasState.kt | 129 ++++++++------ .../offline/preplanned/PreplannedMapAreas.kt | 34 ++-- .../offline/ui/MapAreaDetailsScreen.kt | 22 ++- .../offline/ui/OfflineMapAreasStatusScreen.kt | 20 +-- .../offline/workmanager/WorkManagerUtils.kt | 12 +- .../offline/src/main/res/values/strings.xml | 11 ++ 13 files changed, 638 insertions(+), 309 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 77fa21441..423190ca3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -27,21 +27,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.toolkit.offline.internal.utils.AddMapAreaButton +import com.arcgismaps.toolkit.offline.internal.utils.getDefaultMapAreaTitle +import com.arcgismaps.toolkit.offline.internal.utils.isValidMapAreaTitle +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaSelector import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreas +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState +import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreas import com.arcgismaps.toolkit.offline.ui.EmptyOnDemandOfflineAreas import com.arcgismaps.toolkit.offline.ui.EmptyPreplannedOfflineAreas import com.arcgismaps.toolkit.offline.ui.NoInternetNoAreas import com.arcgismaps.toolkit.offline.ui.OfflineDisabled import com.arcgismaps.toolkit.offline.ui.OfflineMapAreasError -import kotlinx.coroutines.launch /** * Take a web map offline by downloading map areas. @@ -54,10 +59,8 @@ public fun OfflineMapAreas( modifier: Modifier = Modifier ) { val context = LocalContext.current - val scope = rememberCoroutineScope() val initializationStatus by offlineMapState.initializationStatus var isRefreshEnabled by rememberSaveable { mutableStateOf(false) } - var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } LaunchedEffect(offlineMapState, isRefreshEnabled) { if (isRefreshEnabled) { @@ -94,61 +97,29 @@ public fun OfflineMapAreas( when (offlineMapState.mode) { // For preplanned, display online & offline map areas. OfflineMapMode.Preplanned -> { - Column { - PreplannedMapAreas( - preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, - modifier = modifier - ) - if (offlineMapState.isShowingOnlyOfflineModels) { - NoInternetNoAreas( - onlyFooterVisible = offlineMapState.preplannedMapAreaStates.isNotEmpty(), - onRefresh = { isRefreshEnabled = true } - ) - } else if (offlineMapState.preplannedMapAreaStates.isEmpty()) { - EmptyPreplannedOfflineAreas(onRefresh = { - isRefreshEnabled = true - }) - } - } + PreplannedLayoutContainer( + modifier = modifier, + preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, + isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, + onDownloadDeleted = offlineMapState::removePreplannedMapArea, + onRefresh = { isRefreshEnabled = true } + ) } // If not preplanned state & map has offline mode enabled, display the on demand areas OfflineMapMode.OnDemand, OfflineMapMode.Unknown -> { - if (!isOnDemandMapAreaSelectorVisible && offlineMapState.onDemandMapAreaStates.isNotEmpty()) { - OnDemandMapAreas( - onDemandMapAreasStates = offlineMapState.onDemandMapAreaStates, - modifier = modifier, - onDownloadNewMapArea = { - isOnDemandMapAreaSelectorVisible = true - } - ) - } - if (offlineMapState.onDemandMapAreaStates.isEmpty()) { - EmptyOnDemandOfflineAreas( - onAdd = { - isOnDemandMapAreaSelectorVisible = true - } - ) - } - OnDemandMapAreaSelector( + OnDemandLayoutContainer( + modifier = modifier, + onDemandMapAreaStates = offlineMapState.onDemandMapAreaStates, + isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, localMap = offlineMapState.localMap, - showBottomSheet = isOnDemandMapAreaSelectorVisible, - uniqueMapAreaTitle = "Area 1", // TODO: Ensure this is a unique area title - onDismiss = { - isOnDemandMapAreaSelectorVisible = false - }, - onDownloadMapAreaSelected = { envelope, mapAreaTitle, zoomLevel -> - scope.launch { - // TODO: This should be triggered from the area state - val onDemandMapAreaState = offlineMapState.createOnDemandMapAreasState( - context = context, - envelope = envelope, - mapAreaTitle = mapAreaTitle, - zoomLevel = zoomLevel - ) - // Start the on-demand download - onDemandMapAreaState.downloadOnDemandMapArea() - - } + onRefresh = { isRefreshEnabled = true }, + onDownloadDeleted = offlineMapState::removeOnDemandMapArea, + onDownloadMapAreaSelected = { onDemandConfig -> + // Create the on-demand state and start the download + offlineMapState.createOnDemandMapAreaState( + context = context, + configuration = onDemandConfig + ).downloadOnDemandMapArea() } ) } @@ -158,3 +129,90 @@ public fun OfflineMapAreas( } } } + +@Composable +internal fun PreplannedLayoutContainer( + modifier: Modifier, + preplannedMapAreaStates: List, + isShowingOnlyOfflineModels: Boolean, + onDownloadDeleted: (PreplannedMapAreaState) -> Unit, + onRefresh: () -> Unit +) { + Column { + // Show preplanned map areas if available + if (preplannedMapAreaStates.isNotEmpty()) { + PreplannedMapAreas( + preplannedMapAreaStates = preplannedMapAreaStates, + onDownloadDeleted = { if (isShowingOnlyOfflineModels) onDownloadDeleted(it) }, + modifier = modifier + ) + } + // Show "No Internet" message if offline models are displayed + if (isShowingOnlyOfflineModels) { + NoInternetNoAreas( + onlyFooterVisible = preplannedMapAreaStates.isNotEmpty(), + onRefresh = onRefresh + ) + } + // Show empty state message if no preplanned areas and online mode + else if (preplannedMapAreaStates.isEmpty()) { + EmptyPreplannedOfflineAreas(onRefresh = onRefresh) + } + } +} + +@Composable +internal fun OnDemandLayoutContainer( + modifier: Modifier, + onDemandMapAreaStates: List, + isShowingOnlyOfflineModels: Boolean, + localMap: ArcGISMap, + onRefresh: () -> Unit, + onDownloadDeleted: (OnDemandMapAreasState) -> Unit, + onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit +) { + // Track visibility of the map area selector + var isOnDemandMapAreaSelectorVisible by rememberSaveable { mutableStateOf(false) } + // Track if the proposed map area title is unique + var isProposedTitleChangeUnique by rememberSaveable { mutableStateOf(true) } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Show on-demand map areas if available + if (onDemandMapAreaStates.isNotEmpty()) { + OnDemandMapAreas( + onDemandMapAreasStates = onDemandMapAreaStates, + onDownloadDeleted = onDownloadDeleted, + modifier = modifier + ) + // Show "Add Map Area" button if not in offline-only mode + if (!isShowingOnlyOfflineModels) { + AddMapAreaButton { isOnDemandMapAreaSelectorVisible = true } + } + } + // Show "No Internet" message if offline models are displayed + if (isShowingOnlyOfflineModels) { + NoInternetNoAreas( + onlyFooterVisible = onDemandMapAreaStates.isNotEmpty(), + onRefresh = onRefresh + ) + } + // Show empty state message if no on-demand areas and online mode + else if (onDemandMapAreaStates.isEmpty()) { + EmptyOnDemandOfflineAreas { isOnDemandMapAreaSelectorVisible = true } + } + } + // Map area selection bottom sheet + OnDemandMapAreaSelector( + localMap = localMap, + showSheet = isOnDemandMapAreaSelectorVisible, + uniqueMapAreaTitle = getDefaultMapAreaTitle(onDemandMapAreaStates), + isProposedTitleChangeUnique = isProposedTitleChangeUnique, + onDismiss = { isOnDemandMapAreaSelectorVisible = false }, + onProposedTitleChange = { mapAreaTitle -> + isProposedTitleChangeUnique = isValidMapAreaTitle( + mapAreaTitle = mapAreaTitle, + onDemandMapAreaStates = onDemandMapAreaStates + ) + }, + onDownloadMapAreaSelected = onDownloadMapAreaSelected + ) +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index a970a8866..6660af938 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -28,20 +28,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import com.arcgismaps.LoadStatus -import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea -import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState +import com.arcgismaps.toolkit.offline.ondemand.OnDemandStatus import com.arcgismaps.toolkit.offline.preplanned.PreplannedMapAreaState import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException import java.io.File -import java.util.UUID /** * Represents the state of the offline map. @@ -164,7 +163,7 @@ public class OfflineMapState( // check if preplanned for loaded if (_mode != OfflineMapMode.Preplanned || _mode == OfflineMapMode.Unknown) { - // TODO: Load OnDemandMapAresState + loadOfflineOnDemandMapAreas(context) if (_mode == OfflineMapMode.Unknown) _mode = OfflineMapMode.OnDemand } @@ -238,30 +237,32 @@ public class OfflineMapState( _mode = OfflineMapMode.Preplanned preplannedMapAreaItemIds.forEach { itemId -> - makeOfflinePreplannedMapAreaState(context, itemId) + createOfflinePreplannedMapAreaState(context, itemId) ?.let { _preplannedMapAreaStates.add(it) } } } - // TODO: Should this be wired to call OnDemandMapAreasState.initialize? - internal fun createOnDemandMapAreasState( - context: Context, - envelope: Envelope, - mapAreaTitle: String, - zoomLevel: ZoomLevel - ): OnDemandMapAreasState { - val onDemandMapAreasState = OnDemandMapAreasState( - context = context, - item = portalItem, - onDemandAreaID = UUID.randomUUID().toString(), - title = mapAreaTitle, - mapAreaEnvelope = envelope, - zoomLevel = zoomLevel, - offlineMapTask = offlineMapTask, - onSelectionChanged = onSelectionChanged + /** + * Scans the local on-demand directory for downloaded maps and creates [OnDemandMapAreasState]s. + * Sets the [OfflineMapMode.OnDemand] when any local areas are found. + * + * @since 200.8.0 + */ + private suspend fun loadOfflineOnDemandMapAreas(context: Context) { + _onDemandMapAreaStates.clear() + val onDemandDirectory = File( + OfflineURLs.onDemandDirectoryPath(context, portalItem.itemId) ) - _onDemandMapAreaStates.add(onDemandMapAreasState) - return onDemandMapAreasState + val onDemandMapAreaItemIds = onDemandDirectory.listFiles()?.map { it.name.toString() } + ?: emptyList() + if (onDemandMapAreaItemIds.isNotEmpty()) + _mode = OfflineMapMode.OnDemand + + onDemandMapAreaItemIds.forEach { itemId -> + createOfflineOnDemandMapAreaState(context, itemId)?.let { + _onDemandMapAreaStates.add(it) + } + } } /** @@ -271,7 +272,7 @@ public class OfflineMapState( * * @since 200.8.0 */ - private suspend fun makeOfflinePreplannedMapAreaState( + private suspend fun createOfflinePreplannedMapAreaState( context: Context, areaItemId: String ): PreplannedMapAreaState? { @@ -308,6 +309,71 @@ public class OfflineMapState( return null } + /** + * Attempts to create a [OnDemandMapAreasState] for a given area ID by loading + * its [MobileMapPackage] from disk. Returns null if the directory is missing + * or the package fails to load; otherwise initializes status and map. + * + * @since 200.8.0 + */ + private suspend fun createOfflineOnDemandMapAreaState( + context: Context, + areaItemId: String + ): OnDemandMapAreasState? { + val areaDir = File( + OfflineURLs.onDemandDirectoryPath( + context = context, + portalItemID = portalItem.itemId, + onDemandMapAreaID = areaItemId + ) + ) + if (!areaDir.exists() || !areaDir.isDirectory) return null + val mmpk = MobileMapPackage(areaDir.absolutePath).apply { + load().getOrElse { return null } + } + val item = mmpk.item ?: return null + + val onDemandMapAreasState = OnDemandMapAreasState( + context = context, + item = item, + onSelectionChanged = onSelectionChanged + ) + val onDemandPath = OfflineRepository.isOnDemandAreaDownloaded( + context = context, + portalItemID = portalItem.itemId, + onDemandMapAreaID = areaItemId + ) + if (onDemandPath != null) { + onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) + onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( + mobileMapPackagePath = onDemandPath + ) + return onDemandMapAreasState + } else { + return null + } + } + + /** + * Creates and adds a new [OnDemandMapAreasState] instance based on the provided [configuration]. + * + * @since 200.8.0 + */ + internal fun createOnDemandMapAreaState( + context: Context, + configuration: OnDemandMapAreaConfiguration + ): OnDemandMapAreasState { + val onDemandMapAreasState = OnDemandMapAreasState( + context = context, + item = portalItem, + configuration = configuration, + offlineMapTask = offlineMapTask, + onSelectionChanged = onSelectionChanged + ) + _onDemandMapAreaStates.add(onDemandMapAreasState) + return onDemandMapAreasState + } + /** * Resets the current selection of preplanned map areas. * @@ -326,6 +392,27 @@ public class OfflineMapState( internal fun resetInitialize() { _initializationStatus.value = InitializationStatus.NotInitialized } + + /** + * Removes a specific [PreplannedMapAreaState] from the list of preplanned map areas. + */ + internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { + if (state.isSelectedToOpen) { + resetSelectedMapArea() + } + _preplannedMapAreaStates.remove(state) + } + + /** + * Removes a specific [OnDemandMapAreasState] from the list of on-demand map areas. + * @since 200.8.0 + */ + internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { + if (state.isSelectedToOpen) { + resetSelectedMapArea() + } + _onDemandMapAreaStates.remove(state) + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index a2abbb0b5..97cb9b0d8 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -41,6 +41,7 @@ import com.arcgismaps.toolkit.offline.workmanager.jsonJobPathKey import com.arcgismaps.toolkit.offline.workmanager.mobileMapPackagePathKey import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile +import com.arcgismaps.toolkit.offline.workmanager.onDemandAreas import com.arcgismaps.toolkit.offline.workmanager.preplannedMapAreas import java.io.File import java.util.UUID @@ -338,6 +339,28 @@ public object OfflineRepository { else null } + /** + * Checks whether a given [onDemandMapAreaID] associated with a [portalItemID] + * has already been downloaded locally. + * + * @return The path to the on-demand area’s local folder if it exists, + * otherwise `null`. + * @since 200.8.0 + */ + internal fun isOnDemandAreaDownloaded( + context: Context, + portalItemID: String, + onDemandMapAreaID: String + ): String? { + val destDir = File( + File(OfflineURLs.portalItemDirectoryPath(context, portalItemID), onDemandAreas), + onDemandMapAreaID + ) + return if (destDir.exists()) + destDir.path + else null + } + /** * Creates and enqueues a one-time WorkManager request for downloading an offline map area * using [PreplannedMapAreaJobWorker]. Sets up expedited work with input data containing @@ -489,10 +512,6 @@ public object OfflineRepository { } // if the work failed or was cancelled WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - // this removes the completed WorkInfo from the WorkManager's database - // otherwise, the observer will emit the WorkInfo on every launch - // until WorkManager auto-prunes - workManager.pruneWork() preplannedMapAreaState.updateStatus( PreplannedStatus.DownloadFailure( Exception( @@ -581,12 +600,8 @@ public object OfflineRepository { } onDemandMapAreasState.disposeScope() } - // if the work failed or was cancelled - WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { - // this removes the completed WorkInfo from the WorkManager's database - // otherwise, the observer will emit the WorkInfo on every launch - // until WorkManager auto-prunes - workManager.pruneWork() + // if the work failed + WorkInfo.State.FAILED -> { onDemandMapAreasState.updateStatus( OnDemandStatus.DownloadFailure( Exception( @@ -597,6 +612,11 @@ public object OfflineRepository { ) onDemandMapAreasState.disposeScope() } + // if the work was cancelled + WorkInfo.State.CANCELLED -> { + onDemandMapAreasState.updateStatus(OnDemandStatus.DownloadCancelled) + onDemandMapAreasState.disposeScope() + } // if the work is currently in progress WorkInfo.State.RUNNING -> { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloading) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt index 43fe4bf1c..86dd0df7c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -23,11 +23,18 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -35,6 +42,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -72,6 +80,9 @@ internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: ( ) { // Circular Progress Indicator CircularProgressIndicator( + modifier = Modifier + .size(30.dp) + .align(Alignment.Center), progress = { progress / 100f }, ) // Square Button to cancel the download @@ -79,7 +90,22 @@ internal fun CancelDownloadButtonWithProgressIndicator(progress: Int, onClick: ( modifier = Modifier .size(10.dp) .clip(RectangleShape) - .background(ButtonDefaults.buttonColors().containerColor), + .background(ButtonDefaults.buttonColors().containerColor) + .align(Alignment.Center), + ) + } +} + +@Composable +internal fun CancelButton(onClick: () -> Unit) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = onClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cancelled), + tint = MaterialTheme.colorScheme.primary, ) } } @@ -95,7 +121,24 @@ internal fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { Text( text = stringResource(R.string.open), maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelSmall + ) + } +} + +@Composable +internal fun AddMapAreaButton(onAdd: () -> Unit) { + Button(onClick = onAdd) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.Add, + contentDescription = "Icon to add map area" + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.add_map_area), + style = MaterialTheme.typography.labelSmall ) } } @@ -105,26 +148,28 @@ internal fun OpenButton(isEnabled: Boolean, onClick: () -> Unit) { private fun ButtonsPreview() { MaterialTheme { Surface { - Row( + LazyVerticalGrid( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(12.dp), + columns = GridCells.Fixed(2), ) { - DownloadButton( - onClick = { } - ) - CancelDownloadButtonWithProgressIndicator( - progress = 55, - onClick = { } - ) - OpenButton( - isEnabled = true, - onClick = { } - ) - OpenButton( - isEnabled = false, - onClick = { } - ) + item { + DownloadButton { } + } + item { + CancelDownloadButtonWithProgressIndicator(55) { } + } + item { + OpenButton(true) { } + } + item { + AddMapAreaButton { } + } + item { + CancelButton { } + } } } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index b23cc9adf..676a63e8d 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -19,8 +19,21 @@ package com.arcgismaps.toolkit.offline.internal.utils import android.text.Html +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize +import com.arcgismaps.geometry.Envelope +import com.arcgismaps.mapping.view.ScreenCoordinate +import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy +import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState import java.io.File +/** + * Calculates the total size of a [path] recursively. + * + * @since 200.8.0 + */ internal fun getDirectorySize(path: String): Int { val file = File(path) if (!file.exists()) return 0 @@ -28,6 +41,12 @@ internal fun getDirectorySize(path: String): Int { return file.walkTopDown().filter { it.isFile }.map { it.length() }.sum().toInt() } +/** + * Formats a given [size] in bytes into readable string. + * (e.g., `123 B`, `1.2 KB`, `5.6 MB`, `2.3 GB`) + * + * @since 200.8.0 + */ internal fun formatSize(size: Int): String { return when { size < 1000 -> "$size B" @@ -37,6 +56,62 @@ internal fun formatSize(size: Int): String { } } + +/** + * Converts an [html] string to a plain text string. + * + * @since 200.8.0 + */ internal fun htmlToPlainText(html: String): String { return Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY).toString() } + +/** + * Returns a unique default map area title: "Area 1","Area 2"... + * + * @since 200.8.0 + */ +@Composable +internal fun getDefaultMapAreaTitle(onDemandMapAreaStates: List): String { + for (i in 1..Int.MAX_VALUE) { + val title = stringResource(R.string.next_on_demand_area_title, i.toString()) + if (onDemandMapAreaStates.none { it.title == title }) { + return title + } + } + return "" +} + +/** + * Returns true if the [mapAreaTitle] is unique title among all [onDemandMapAreaStates]. + * + * @since 200.8.0 + */ +internal fun isValidMapAreaTitle( + mapAreaTitle: String, + onDemandMapAreaStates: List +): Boolean { + if (mapAreaTitle.isBlank()) + return false + if (onDemandMapAreaStates.any { it.title == mapAreaTitle }) + return false + return true +} + +/** + * Returns an [Envelope] which is equal to 80% of the ratio of [mapViewSize] + * at the current viewpoint, to be treated as the area of interest. + * + * @since 200.8.0 + */ +internal fun calculateEnvelope(mapViewSize: IntSize, mapViewProxy: MapViewProxy): Envelope? { + val inh = mapViewSize.width * 0.1 + val inv = mapViewSize.height * 0.1 + val minScreen = ScreenCoordinate(x = inh, y = inv) + val maxScreen = ScreenCoordinate(x = mapViewSize.width - inh, y = mapViewSize.height - inv) + val minResult = mapViewProxy.screenToLocationOrNull(minScreen) + val maxResult = mapViewProxy.screenToLocationOrNull(maxScreen) + return if (minResult != null && maxResult != null) { + Envelope(min = minResult, max = maxResult) + } else null +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 20a7ca253..32d6b15e3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -78,36 +78,20 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap -import com.arcgismaps.mapping.view.ScreenCoordinate import com.arcgismaps.toolkit.geoviewcompose.MapView import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel +import com.arcgismaps.toolkit.offline.internal.utils.calculateEnvelope import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheetProperties import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState import kotlinx.coroutines.launch - - -// TODO: Migrate these to the area state +import java.util.UUID private val mapViewProxy = MapViewProxy() -private fun calculateEnvelope(fullSize: IntSize): Envelope? { - val inh = fullSize.width * 0.2 / 2 - val inv = fullSize.height * 0.2 / 2 - val minScreen = ScreenCoordinate(x = inh, y = inv) - val maxScreen = ScreenCoordinate(x = fullSize.width - inh, y = fullSize.height - inv) - val minResult = mapViewProxy.screenToLocationOrNull(minScreen) - val maxResult = mapViewProxy.screenToLocationOrNull(maxScreen) - return if (minResult != null && maxResult != null) { - Envelope(min = minResult, max = maxResult) - } else null -} - - /** * Take a web map offline by downloading map areas. * @@ -115,45 +99,43 @@ private fun calculateEnvelope(fullSize: IntSize): Envelope? { */ @Composable internal fun OnDemandMapAreaSelector( - localMap: ArcGISMap? = null, + localMap: ArcGISMap, uniqueMapAreaTitle: String, - showBottomSheet: Boolean, + showSheet: Boolean, onDismiss: () -> Unit, - onDownloadMapAreaSelected: (Envelope, String, ZoomLevel) -> Unit + isProposedTitleChangeUnique: Boolean, + onProposedTitleChange: (String) -> Unit, + onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - - if (showBottomSheet) { + var onHideSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(onHideSheet, sheetState.isVisible) { + if (onHideSheet) { + sheetState.hide() + onHideSheet = false + } + if (!sheetState.isVisible) { + onDismiss() + } + } + if (showSheet) { ModalBottomSheet( modifier = Modifier.systemBarsPadding(), - onDismissRequest = { - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - onDismiss.invoke() - } - }, + onDismissRequest = { onHideSheet = true }, sheetState = sheetState, sheetGesturesEnabled = false, properties = ModalBottomSheetProperties(), - dragHandle = {}) { + dragHandle = {} + ) { OnDemandMapAreaSelectorOptions( - localMap = localMap, onDismiss = { - scope.launch { - sheetState.hide() - }.invokeOnCompletion { - onDismiss.invoke() - } - }, + localMap = localMap, currentAreaName = uniqueMapAreaTitle, - onDownloadMapAreaSelected = { mapViewSize, mapAreaName, zoomLevel -> - scope.launch { sheetState.hide() }.invokeOnCompletion { - onDismiss.invoke() - calculateEnvelope(mapViewSize)?.let { downloadArea -> - onDownloadMapAreaSelected.invoke(downloadArea, mapAreaName, zoomLevel) - } - } + isProposedTitleChangeUnique = isProposedTitleChangeUnique, + onProposedTitleChange = onProposedTitleChange, + onDismiss = { onHideSheet = true }, + onDownloadMapAreaSelected = { onDemandConfig -> + onHideSheet = true + onDownloadMapAreaSelected(onDemandConfig) } ) } @@ -163,17 +145,22 @@ internal fun OnDemandMapAreaSelector( @Composable private fun OnDemandMapAreaSelectorOptions( currentAreaName: String, - localMap: ArcGISMap? = null, - onDismiss: () -> Unit, - onDownloadMapAreaSelected: (IntSize, String, ZoomLevel) -> Unit + localMap: ArcGISMap, + isProposedTitleChangeUnique: Boolean, + onProposedTitleChange: (String) -> Unit, + onDownloadMapAreaSelected: (OnDemandMapAreaConfiguration) -> Unit, + onDismiss: () -> Unit ) { var isShowingAreaNameDialog by rememberSaveable { mutableStateOf(false) } var mapAreaName by rememberSaveable { mutableStateOf(currentAreaName) } var mapViewSize = IntSize(0, 0) var zoomLevel by rememberSaveable { mutableStateOf(ZoomLevel.STREET) } + val scope = rememberCoroutineScope() if (isShowingAreaNameDialog) { AreaNameDialog( currentAreaName = mapAreaName, + isProposedTitleChangeUnique = isProposedTitleChangeUnique, + onProposedTitleChange = onProposedTitleChange, onDismiss = { isShowingAreaNameDialog = false }, onConfirm = { newAreaName -> mapAreaName = newAreaName @@ -188,7 +175,7 @@ private fun OnDemandMapAreaSelectorOptions( ) { Box(modifier = Modifier.fillMaxWidth()) { Text( - "Select area", + stringResource(R.string.select_area), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Center) ) @@ -197,15 +184,15 @@ private fun OnDemandMapAreaSelectorOptions( } } HorizontalDivider() - Text(text = "Pan and zoom to define the area", style = MaterialTheme.typography.labelSmall) + Text(text = stringResource(R.string.pan_and_zoom_text), style = MaterialTheme.typography.labelSmall) MapViewWithAreaSelector( modifier = Modifier.weight(1f), - localMap = localMap, + arcGISMap = localMap, onMapViewSizeChanged = { newSize -> mapViewSize = newSize } ) Row( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 24.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -226,13 +213,13 @@ private fun OnDemandMapAreaSelectorOptions( modifier = Modifier.size(16.dp) ) Spacer(Modifier.size(4.dp)) - Text("Rename", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.rename), style = MaterialTheme.typography.labelSmall) } } HorizontalDivider() Row( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 24.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -246,16 +233,32 @@ private fun OnDemandMapAreaSelectorOptions( } Button( modifier = Modifier - .padding(horizontal = 12.dp) + .padding(horizontal = 24.dp) .fillMaxWidth(), - onClick = { onDownloadMapAreaSelected.invoke(mapViewSize, mapAreaName, zoomLevel) } - ) { Text("Download") } + onClick = { + scope.launch { + val thumbnail = mapViewProxy.exportImage().getOrNull()?.bitmap + calculateEnvelope(mapViewSize, mapViewProxy)?.let { downloadArea -> + onDownloadMapAreaSelected.invoke( + OnDemandMapAreaConfiguration( + areaID = UUID.randomUUID().toString(), + title = mapAreaName, + minScale = 0.0, + maxScale = zoomLevel.scale, + areaOfInterest = downloadArea, + thumbnail = thumbnail + ) + ) + } + } + } + ) { Text(stringResource(R.string.download)) } } } @Composable private fun MapViewWithAreaSelector( - localMap: ArcGISMap? = null, + arcGISMap: ArcGISMap, onMapViewSizeChanged: (IntSize) -> Unit, modifier: Modifier ) { @@ -263,13 +266,11 @@ private fun MapViewWithAreaSelector( modifier .fillMaxWidth() .onSizeChanged { onMapViewSizeChanged.invoke(it) }) { - localMap?.let { arcGISMap -> - MapView( - modifier = Modifier.matchParentSize(), - arcGISMap = arcGISMap, - mapViewProxy = mapViewProxy - ) - } + MapView( + modifier = Modifier.matchParentSize(), + arcGISMap = arcGISMap, + mapViewProxy = mapViewProxy + ) MapAreaSelectorOverlay( modifier = Modifier.matchParentSize() ) @@ -313,6 +314,8 @@ private fun MapAreaSelectorOverlay( @Composable private fun AreaNameDialog( currentAreaName: String, + isProposedTitleChangeUnique: Boolean, + onProposedTitleChange: (String) -> Unit, onDismiss: () -> Unit, onConfirm: (String) -> Unit ) { @@ -330,17 +333,23 @@ private fun AreaNameDialog( verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Enter a name", style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.enter_a_name), style = MaterialTheme.typography.titleLarge) OutlinedTextField( modifier = Modifier.fillMaxWidth(), - label = { Text("The name for the map area must be unique") }, + label = { Text(stringResource(R.string.unique_name_text)) }, value = areaName, singleLine = true, - onValueChange = { newValue -> areaName = newValue }, + onValueChange = { newValue -> + areaName = newValue + onProposedTitleChange(areaName) + }, ) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { Text("Cancel") } - TextButton(onClick = { onConfirm.invoke(areaName) }) { Text("Ok") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + TextButton( + onClick = { onConfirm.invoke(areaName) }, + enabled = isProposedTitleChangeUnique + ) { Text(stringResource(R.string.confirm)) } } } @@ -377,7 +386,9 @@ private fun DropDownMenuBox( contentDescription = null, modifier = Modifier.clickable { expanded = !expanded }) }, - modifier = Modifier.align(Alignment.BottomEnd).fillMaxWidth() + modifier = Modifier + .align(Alignment.BottomEnd) + .fillMaxWidth() ) DropdownMenu( expanded = expanded, @@ -415,6 +426,8 @@ private fun DropDownMenuBox( private fun AreaNameDialogPreview() { AreaNameDialog( currentAreaName = "Area 1", + isProposedTitleChangeUnique = true, + onProposedTitleChange = { }, onDismiss = { }, onConfirm = { } ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index a6e9df2b3..87b412c6c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -24,10 +24,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -35,16 +33,15 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ImageNotSupported -import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -59,13 +56,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getString +import com.arcgismaps.toolkit.offline.OfflineMapMode import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.internal.utils.CancelButton import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState -import kotlinx.coroutines.launch /** * Displays a list of on-demand map areas. @@ -75,26 +73,33 @@ import kotlinx.coroutines.launch @Composable internal fun OnDemandMapAreas( onDemandMapAreasStates: List, - modifier: Modifier, - onDownloadNewMapArea: () -> Unit + onDownloadDeleted: (OnDemandMapAreasState) -> Unit, + modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var onHideSheet by rememberSaveable { mutableStateOf(false) } var selectedIndex by rememberSaveable { mutableIntStateOf(-1) } val selectedOnDemandMapAreaState = selectedIndex.takeIf { it in onDemandMapAreasStates.indices } ?.let { onDemandMapAreasStates[it] } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - val scope = rememberCoroutineScope() + LaunchedEffect(onHideSheet, sheetState.isVisible) { + if (onHideSheet) { + sheetState.hide() + onHideSheet = false + } + if (!sheetState.isVisible) { + showSheet = false + } + } // Show the modal bottom sheet if needed if (showSheet && selectedOnDemandMapAreaState != null) { MapAreaDetailsBottomSheet( showSheet = true, sheetState = sheetState, - scope = scope, onDismiss = { showSheet = false }, + offlineMapMode = OfflineMapMode.OnDemand, thumbnail = selectedOnDemandMapAreaState.thumbnail?.asImageBitmap(), title = selectedOnDemandMapAreaState.title, description = null, @@ -102,17 +107,13 @@ internal fun OnDemandMapAreas( isAvailableToDownload = selectedOnDemandMapAreaState.status.allowsDownload, onStartDownload = { selectedOnDemandMapAreaState.downloadOnDemandMapArea() - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - showSheet = false - } - } + onHideSheet = true }, isDeletable = selectedOnDemandMapAreaState.status.isDownloaded && !selectedOnDemandMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedOnDemandMapAreaState.removeDownloadedMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } + onDownloadDeleted(selectedOnDemandMapAreaState) + onHideSheet = true } ) } @@ -206,6 +207,13 @@ internal fun OnDemandMapAreas( } } + state.status == OnDemandStatus.DownloadCancelled || state.status is OnDemandStatus.DownloadFailure -> { + CancelButton { + state.removeCancelledMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } + onDownloadDeleted(state) + } + } + state.status.isDownloaded -> { OpenButton(!state.isSelectedToOpen) { // Unselect all, then select this one @@ -215,19 +223,11 @@ internal fun OnDemandMapAreas( } } } - if (state.onDemandAreaID != onDemandMapAreasStates.last().onDemandAreaID) { + if (state != onDemandMapAreasStates.last()) { HorizontalDivider(modifier = Modifier.padding(start = 80.dp)) } } } - Spacer(Modifier.height(12.dp)) - // add a button to add a new on demand map area - Button(onClick = onDownloadNewMapArea) { - Text( - text = stringResource(R.string.add_map_area), - style = MaterialTheme.typography.labelSmall - ) - } } } @@ -246,6 +246,7 @@ private fun getOnDemandMapAreaStatusString(context: Context, status: OnDemandSta is OnDemandStatus.DownloadFailure -> getString(context, R.string.download_failed) OnDemandStatus.Downloaded -> getString(context, R.string.downloaded) OnDemandStatus.Downloading -> getString(context, R.string.downloading) + OnDemandStatus.DownloadCancelled -> getString(context, R.string.cancelled) OnDemandStatus.PackageFailure -> getString(context, R.string.packaging_failed) OnDemandStatus.Packaged -> getString(context, R.string.ready_to_download) OnDemandStatus.Packaging -> getString(context, R.string.packaging) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index d9c8f2fd9..542caa5b1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.graphics.drawable.toDrawable import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.Item @@ -47,13 +48,18 @@ import kotlinx.coroutines.launch import java.io.File import java.util.UUID -private data class OnDemandMapAreaConfiguration( - private val areaID: String, - private val title: String, - private val minScale: Double, - private val maxScale: Double, - private val areaOfInterest: Envelope, - private val thumbnail: Bitmap? +/** + * A data class to hold configuration for an on-demand map area. + * + * @since 200.8.0 + */ +internal data class OnDemandMapAreaConfiguration( + internal val areaID: String, + internal val title: String, + internal val minScale: Double, + internal val maxScale: Double, + internal val areaOfInterest: Envelope, + internal val thumbnail: Bitmap? ) /** @@ -64,10 +70,7 @@ private data class OnDemandMapAreaConfiguration( internal class OnDemandMapAreasState( private val context: Context, private val item: Item, - internal val onDemandAreaID: String, - internal val title: String, - private val zoomLevel: ZoomLevel = ZoomLevel.STREET, - internal val mapAreaEnvelope: Envelope? = null, + private val configuration: OnDemandMapAreaConfiguration? = null, private val offlineMapTask: OfflineMapTask? = null, private val onSelectionChanged: (ArcGISMap) -> Unit ) { @@ -97,49 +100,41 @@ internal class OnDemandMapAreasState( private lateinit var scope: CoroutineScope - private var _thumbnail by mutableStateOf(null) - internal val thumbnail: Bitmap? get() = _thumbnail ?: item.thumbnail?.image?.bitmap + internal val title = configuration?.title ?: item.title - private var configuration: OnDemandMapAreaConfiguration? = null - - /** - * Loads and initializes the associated on demand map area. - * - * @since 200.8.0 - */ - internal suspend fun initialize() = runCatchingCancellable { - // TODO - } + internal val thumbnail: Bitmap? + get() = configuration?.thumbnail ?: item.thumbnail?.image?.bitmap /** * Initiates downloading of the associated on-demand map area for offline use. * * @since 200.8.0 */ - internal fun downloadOnDemandMapArea() = - runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) - val area = mapAreaEnvelope ?: return@runCatchingCancellable - val task = offlineMapTask ?: return@runCatchingCancellable - val portalItem = item as? PortalItem ?: return@runCatchingCancellable - - scope.launch { - _status = OnDemandStatus.Downloading - val offlineWorkerUUID = startOfflineMapJob( - downloadOnDemandOfflineMapJob = createOfflineMapJob( - downloadMapArea = area, - offlineMapTask = task - ) - ) - OfflineRepository.observeStatusForOnDemandWork( - context = context, - onWorkInfoStateChanged = ::logWorkInfo, - onDemandMapAreasState = this@OnDemandMapAreasState, - portalItem = portalItem, - offlineWorkerUUID = offlineWorkerUUID + internal fun downloadOnDemandMapArea() = runCatchingCancellable { + scope = CoroutineScope(Dispatchers.IO) + val task = offlineMapTask ?: return@runCatchingCancellable + val portalItem = item as? PortalItem ?: return@runCatchingCancellable + val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable + val downloadMapArea = configuration.areaOfInterest + + scope.launch { + _status = OnDemandStatus.Downloading + val offlineWorkerUUID = startOfflineMapJob( + downloadOnDemandOfflineMapJob = createOfflineMapJob( + onDemandMapAreaID = onDemandMapAreaID, + downloadMapArea = downloadMapArea, + offlineMapTask = task ) - } + ) + OfflineRepository.observeStatusForOnDemandWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + onDemandMapAreasState = this@OnDemandMapAreasState, + portalItem = portalItem, + offlineWorkerUUID = offlineWorkerUUID + ) } + } /** * Cancels the current coroutine scope. @@ -159,6 +154,7 @@ internal class OnDemandMapAreasState( * Generates default parameters for downloading, including no updates mode and error handling settings. * Defines a directory path where map data will be stored and creates a download job using these configurations. * + * @param onDemandMapAreaID The String ID of the map area to download on demand. * @param downloadMapArea The target selected map area to be downloaded offline. * @param offlineMapTask The target [OfflineMapTask] to create the params & the job. * @return An instance of [GenerateOfflineMapJob] configured with download parameters. @@ -166,6 +162,7 @@ internal class OnDemandMapAreasState( * @since 200.8.0 */ private suspend fun createOfflineMapJob( + onDemandMapAreaID: String, downloadMapArea: Envelope, offlineMapTask: OfflineMapTask ): GenerateOfflineMapJob { @@ -174,7 +171,7 @@ internal class OnDemandMapAreasState( val params = offlineMapTask.createDefaultGenerateOfflineMapParameters( areaOfInterest = downloadMapArea, minScale = 0.0, - maxScale = zoomLevel.scale + maxScale = configuration?.maxScale ?: ZoomLevel.STREET.scale ).getOrThrow().apply { // Set the update mode to receive no updates updateMode = GenerateOfflineMapUpdateMode.NoUpdates @@ -182,6 +179,7 @@ internal class OnDemandMapAreasState( itemInfo?.apply { title = this@OnDemandMapAreasState.title description = "" + thumbnail = configuration?.thumbnail?.toDrawable(context.resources) } } @@ -189,7 +187,7 @@ internal class OnDemandMapAreasState( val onDemandMapAreaDownloadDirectory = OfflineRepository.createPendingOnDemandJobPath( context = context, portalItemID = item.itemId, - onDemandMapAreaID = onDemandAreaID + onDemandMapAreaID = onDemandMapAreaID ) // Create a job to download the on-demand offline map @@ -222,7 +220,7 @@ internal class OnDemandMapAreasState( workerUUID = OfflineRepository.createOnDemandMapAreaRequestAndQueueDownload( context = context, jsonJobPath = jsonJobFile.path, - onDemandMapAreaTitle = item.title + onDemandMapAreaTitle = configuration?.title ?: item.title ) return workerUUID @@ -250,16 +248,30 @@ internal class OnDemandMapAreasState( portalItemID = item.itemId ) } - val localScope = CoroutineScope(Dispatchers.IO) - localScope.launch { - initialize() - localScope.cancel() - } } else { Log.e(TAG, "Failed to delete on-demand map area: ${mobileMapPackage.path}") } } + /** + * Removes the cancelled on-demand map area from the device. + * If specified, it also removes the offline map information from the repository. + * + * @param shouldRemoveOfflineMapInfo A lambda function that determines whether to remove offline map info. + * + * @since 200.8.0 + */ + internal fun removeCancelledMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { + // Reset the status to reflect the deletion + _status = OnDemandStatus.NotLoaded + if (shouldRemoveOfflineMapInfo()) { + OfflineRepository.removeOfflineMapInfo( + context = context, + portalItemID = item.itemId + ) + } + } + /** * Updates the current state of this on-demand map area instance. * @@ -357,6 +369,11 @@ internal sealed class OnDemandStatus { */ data object Downloaded : OnDemandStatus() + /** + * On-Demand map area download is cancelled. + */ + data object DownloadCancelled : OnDemandStatus() + /** * On-Demand map area failed to download. */ @@ -373,7 +390,7 @@ internal sealed class OnDemandStatus { val canLoadOnDemandMapArea: Boolean get() = when (this) { is NotLoaded, is LoadFailure, is PackageFailure -> true - is Loading, is Packaging, is Packaged, is Downloading, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false + is Loading, is Packaging, is Packaged, is Downloading, is DownloadCancelled, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false } /** @@ -381,8 +398,8 @@ internal sealed class OnDemandStatus { */ val allowsDownload: Boolean get() = when (this) { - is Packaged, is DownloadFailure -> true - is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is Downloaded, is MmpkLoadFailure -> false + is Packaged -> true + is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is DownloadCancelled, is DownloadFailure, is Downloaded, is MmpkLoadFailure -> false } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index e558a68a1..baa077b25 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -39,10 +39,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -57,13 +57,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getString +import com.arcgismaps.toolkit.offline.OfflineMapMode import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState -import kotlinx.coroutines.launch /** * Displays a list of preplanned map areas. @@ -73,6 +73,7 @@ import kotlinx.coroutines.launch @Composable internal fun PreplannedMapAreas( preplannedMapAreaStates: List, + onDownloadDeleted: (PreplannedMapAreaState) -> Unit, modifier: Modifier ) { var showSheet by rememberSaveable { mutableStateOf(false) } @@ -80,18 +81,25 @@ internal fun PreplannedMapAreas( val selectedPreplannedMapAreaState = selectedIndex.takeIf { it in preplannedMapAreaStates.indices } ?.let { preplannedMapAreaStates[it] } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var onHideSheet by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(onHideSheet, sheetState.isVisible) { + if (onHideSheet) { + sheetState.hide() + onHideSheet = false + } + if (!sheetState.isVisible) { + showSheet = false + } + } // Show the modal bottom sheet if needed if (showSheet && selectedPreplannedMapAreaState != null) { MapAreaDetailsBottomSheet( showSheet = true, sheetState = sheetState, - scope = scope, - onDismiss = { showSheet = false }, + onDismiss = { onHideSheet = true }, + offlineMapMode = OfflineMapMode.Preplanned, thumbnail = selectedPreplannedMapAreaState.thumbnail?.asImageBitmap(), title = selectedPreplannedMapAreaState.title, description = selectedPreplannedMapAreaState.description, @@ -99,17 +107,13 @@ internal fun PreplannedMapAreas( isAvailableToDownload = selectedPreplannedMapAreaState.status.allowsDownload, onStartDownload = { selectedPreplannedMapAreaState.downloadPreplannedMapArea() - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - showSheet = false - } - } + onHideSheet = true }, isDeletable = selectedPreplannedMapAreaState.status.isDownloaded && !selectedPreplannedMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedPreplannedMapAreaState.removeDownloadedMapArea { !preplannedMapAreaStates.any { it.status.isDownloaded } } + onDownloadDeleted(selectedPreplannedMapAreaState) + onHideSheet = true } ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index 45fd3cf9f..6d818bcd9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -53,20 +53,19 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.arcgismaps.toolkit.offline.OfflineMapMode import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.formatSize import com.arcgismaps.toolkit.offline.internal.utils.htmlToPlainText import com.arcgismaps.toolkit.offline.ui.material3.ModalBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.SheetState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch @Composable internal fun MapAreaDetailsBottomSheet( showSheet: Boolean, sheetState: SheetState, - scope: CoroutineScope, onDismiss: () -> Unit, + offlineMapMode: OfflineMapMode, thumbnail: ImageBitmap?, title: String, description: String?, @@ -79,13 +78,14 @@ internal fun MapAreaDetailsBottomSheet( if (showSheet) { // Launch expanded when shown LaunchedEffect(Unit) { - scope.launch { sheetState.expand() } + sheetState.expand() } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState ) { MapAreaDetailsScreen( + offlineMapMode = offlineMapMode, thumbnail = thumbnail, title = title, description = description, @@ -101,6 +101,7 @@ internal fun MapAreaDetailsBottomSheet( @Composable internal fun MapAreaDetailsScreen( + offlineMapMode: OfflineMapMode, thumbnail: ImageBitmap?, title: String, description: String?, @@ -151,7 +152,10 @@ internal fun MapAreaDetailsScreen( // Size of the map area if (size != 0) { - Text(text = "Size: ${formatSize(size)}", style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.directory_size, formatSize(size)), + style = MaterialTheme.typography.bodyMedium + ) } if (description != null) { @@ -190,7 +194,12 @@ internal fun MapAreaDetailsScreen( } if (isDeletable) { Button(onClick = { onDeleteDownload() }) { - Text(stringResource(id = R.string.remove_download)) + Text( + if (offlineMapMode == OfflineMapMode.Preplanned) + stringResource(id = R.string.remove_download) + else + stringResource(R.string.delete_download) + ) } } } @@ -203,6 +212,7 @@ private fun PreviewMapAreaDetailsScreen() { MaterialTheme { Surface { MapAreaDetailsScreen( + offlineMapMode = OfflineMapMode.Preplanned, thumbnail = null, title = "City Hall Area", description = "A map that contains stormwater network within...", diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt index 8611069d2..69ccfc869 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/OfflineMapAreasStatusScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Error @@ -49,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.arcgismaps.toolkit.offline.R +import com.arcgismaps.toolkit.offline.internal.utils.AddMapAreaButton @Composable internal fun OfflineMapAreasStatusContent( @@ -69,10 +69,11 @@ internal fun OfflineMapAreasStatusContent( Row( modifier = Modifier .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) Text( modifier = Modifier.wrapContentSize(), text = message, @@ -160,20 +161,7 @@ internal fun EmptyOnDemandOfflineAreas(onlyFooterVisible: Boolean = false, onAdd title = stringResource(R.string.no_map_areas), message = stringResource(R.string.empty_on_demand_message), icon = Icons.Default.ArrowDownward, - actions = { - Button(onClick = onAdd) { - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Default.Add, - contentDescription = "Icon to add map area" - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringResource(R.string.add_map_area), - style = MaterialTheme.typography.labelSmall - ) - } - }, + actions = { AddMapAreaButton(onAdd) }, onlyFooterVisible = onlyFooterVisible ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt index 4978151da..90272260c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/WorkManagerUtils.kt @@ -31,27 +31,27 @@ import java.io.File internal fun logWorkInfo(workInfo: WorkInfo) { when (workInfo.state) { WorkInfo.State.ENQUEUED -> { - Log.e(TAG, "${workInfo.tags}: ENQUEUED") + Log.d(TAG, "${workInfo.tags}: Enqueued") } WorkInfo.State.SUCCEEDED -> { - Log.e(TAG, "${workInfo.tags}: SUCCEEDED") + Log.d(TAG, "${workInfo.tags}: Succeeded") } WorkInfo.State.BLOCKED -> { - Log.e(TAG, "${workInfo.tags}: BLOCKED") + Log.d(TAG, "${workInfo.tags}: Blocked") } WorkInfo.State.RUNNING -> { - Log.e(TAG, "${workInfo.tags}: RUNNING ${workInfo.progress.getInt("Progress", 0)}") + Log.d(TAG, "${workInfo.tags}: Running - Progress: ${workInfo.progress.getInt("Progress", 0)}") } WorkInfo.State.FAILED -> { - Log.e(TAG, "${workInfo.tags}: FAILED: ${workInfo.outputData.getString("Error")} - Details: ${workInfo.outputData.keyValueMap}") + Log.e(TAG, "${workInfo.tags}: Failed: ${workInfo.outputData.getString("Error")} - Details: ${workInfo.outputData.keyValueMap}") } WorkInfo.State.CANCELLED -> { - Log.e(TAG, "${workInfo.tags}: CANCELLED - Stop reason: ${workInfo.stopReason.toStopReasonString()}, ${workInfo.outputData.getString("Error")} - Details: ${workInfo.outputData.keyValueMap}") + Log.e(TAG, "${workInfo.tags}: Cancelled - Stop reason: ${workInfo.stopReason.toStopReasonString()}}") } } } diff --git a/toolkit/offline/src/main/res/values/strings.xml b/toolkit/offline/src/main/res/values/strings.xml index 19e59e7d1..45e6a0340 100644 --- a/toolkit/offline/src/main/res/values/strings.xml +++ b/toolkit/offline/src/main/res/values/strings.xml @@ -33,8 +33,19 @@ Description Download Map Area Remove Download + Delete Download No Image Available Refresh + Cancelled + Cancel + OK + Rename + Enter a name + Select area + Pan and zoom to define the area + The name for the map area must be unique + Area %1$s + Size: %1$s No Internet Connection No internet connection. Showing downloaded areas only. Could not retrieve map areas for this map. From 14485fd89d618c8aa8464714dfb7c496dd9bf525 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 17:03:13 -0700 Subject: [PATCH 106/132] resolve update from feature branch --- .../com/arcgismaps/toolkit/offline/OfflineMapAreas.kt | 3 +-- .../com/arcgismaps/toolkit/offline/OfflineMapState.kt | 11 ++++++----- .../toolkit/offline/internal/utils/Utils.kt | 11 +++++------ .../toolkit/offline/ondemand/OnDemandMapAreasState.kt | 1 + .../offline/preplanned/PreplannedMapAreasState.kt | 1 + 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 6b7a54c06..423190ca3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -200,12 +200,11 @@ internal fun OnDemandLayoutContainer( EmptyOnDemandOfflineAreas { isOnDemandMapAreaSelectorVisible = true } } } - val context = LocalContext.current // Map area selection bottom sheet OnDemandMapAreaSelector( localMap = localMap, showSheet = isOnDemandMapAreaSelectorVisible, - uniqueMapAreaTitle = getDefaultMapAreaTitle(context, onDemandMapAreaStates), + uniqueMapAreaTitle = getDefaultMapAreaTitle(onDemandMapAreaStates), isProposedTitleChangeUnique = isProposedTitleChangeUnique, onDismiss = { isOnDemandMapAreaSelectorVisible = false }, onProposedTitleChange = { mapAreaTitle -> diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 604533b7c..e0cc7250c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -280,12 +280,13 @@ public class OfflineMapState( context: Context, areaItemId: String ): PreplannedMapAreaState? { - val preplannedDirPath = OfflineURLs.prePlannedDirectoryPath( - context = context, - portalItemID = portalItem.itemId, - preplannedMapAreaID = areaItemId + val areaDir = File( + OfflineURLs.prePlannedDirectoryPath( + context = context, + portalItemID = portalItem.itemId, + preplannedMapAreaID = areaItemId + ) ) - val areaDir = File(preplannedDirPath) if (!areaDir.exists() || !areaDir.isDirectory) return null val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt index 122caadbd..676a63e8d 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Utils.kt @@ -18,8 +18,9 @@ package com.arcgismaps.toolkit.offline.internal.utils -import android.content.Context import android.text.Html +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize import com.arcgismaps.geometry.Envelope import com.arcgismaps.mapping.view.ScreenCoordinate @@ -70,12 +71,10 @@ internal fun htmlToPlainText(html: String): String { * * @since 200.8.0 */ -internal fun getDefaultMapAreaTitle( - context: Context, - onDemandMapAreaStates: List -): String { +@Composable +internal fun getDefaultMapAreaTitle(onDemandMapAreaStates: List): String { for (i in 1..Int.MAX_VALUE) { - val title = context.getString(R.string.next_on_demand_area_title, i.toString()) + val title = stringResource(R.string.next_on_demand_area_title, i.toString()) if (onDemandMapAreaStates.none { it.title == title }) { return title } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 937c7979b..5791a71c1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -120,6 +120,7 @@ internal class OnDemandMapAreasState( val portalItem = item as? PortalItem ?: return@runCatchingCancellable val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable val downloadMapArea = configuration.areaOfInterest + if (!scope.isActive) scope = CoroutineScope(Dispatchers.IO) scope.launch { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 0fbb2b1c9..0892bc05a 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -144,6 +144,7 @@ internal class PreplannedMapAreaState( val area = preplannedMapArea ?: return@runCatchingCancellable val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable + if (!scope.isActive) scope = CoroutineScope(Dispatchers.IO) scope.launch { From d897669871f314927e2449eed52355e1b8b75b26 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 30 Jun 2025 17:07:13 -0700 Subject: [PATCH 107/132] cleanup imports --- .../toolkit/offline/preplanned/PreplannedMapAreasState.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 0892bc05a..0dc04d242 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -45,7 +45,6 @@ import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File From cf7bb94c6a661b040ff27823a31e48d2fc667805 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 2 Jul 2025 11:32:22 -0700 Subject: [PATCH 108/132] update preplanned restored state --- .../arcgismaps/toolkit/offline/OfflineMapState.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index e0cc7250c..b1b45fd83 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -436,17 +436,10 @@ public class OfflineMapState( portalItemId = portalItem.itemId ) ?: return@forEach if (mode == OfflineMapMode.Preplanned) { - val restoredState = PreplannedMapAreaState( - context = context, - item = portalItem, - onSelectionChanged = onSelectionChanged - ).apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } - val duplicateMapAreaStateIndex = _preplannedMapAreaStates.indexOfFirst { + // update the loaded preplanned area, to restore with the in-progress job state + _preplannedMapAreaStates.first { it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaMetadata.areaId) - } - // replace the loaded duplicate preplanned area, with the in-progress job state - _preplannedMapAreaStates[duplicateMapAreaStateIndex] = restoredState - + }.apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } } else { val restoredState = OnDemandMapAreasState( context = context, From bc454207bb76be9cf4645345cb685273ebb23c9a Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 2 Jul 2025 11:55:57 -0700 Subject: [PATCH 109/132] PR feedback --- .../toolkit/offline/OfflineMapAreaMetadata.kt | 8 ++++---- .../toolkit/offline/OfflineMapState.kt | 10 +++++----- .../toolkit/offline/OfflineRepository.kt | 19 +++++++++---------- .../ondemand/OnDemandMapAreaSelector.kt | 2 +- .../offline/ondemand/OnDemandMapAreasState.kt | 6 +++--- .../preplanned/PreplannedMapAreasState.kt | 2 +- .../offline/workmanager/OfflineURLs.kt | 4 ++-- 7 files changed, 25 insertions(+), 26 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt index 6529a3111..ce63c9209 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt @@ -37,7 +37,7 @@ import java.io.FileOutputStream */ @Serializable internal data class OfflineMapAreaMetadata( - val areaId: String, + val itemId: String, val title: String, val description: String, @Transient val thumbnailImage: Bitmap? = null @@ -51,7 +51,7 @@ internal data class OfflineMapAreaMetadata( */ internal fun createPreplannedMetadata(preplannedMapArea: PreplannedMapArea): OfflineMapAreaMetadata { return OfflineMapAreaMetadata( - areaId = preplannedMapArea.portalItem.itemId, + itemId = preplannedMapArea.portalItem.itemId, title = preplannedMapArea.portalItem.title, thumbnailImage = preplannedMapArea.portalItem.thumbnail?.image?.bitmap, description = preplannedMapArea.portalItem.description @@ -65,7 +65,7 @@ internal data class OfflineMapAreaMetadata( */ internal fun createOnDemandMetadata(onDemandMapAreaConfiguration: OnDemandMapAreaConfiguration): OfflineMapAreaMetadata { return OfflineMapAreaMetadata( - areaId = onDemandMapAreaConfiguration.areaID, + itemId = onDemandMapAreaConfiguration.itemId, title = onDemandMapAreaConfiguration.title, thumbnailImage = onDemandMapAreaConfiguration.thumbnail, description = "" @@ -94,7 +94,7 @@ internal data class OfflineMapAreaMetadata( null } return OfflineMapAreaMetadata( - areaId = baseMetadata.areaId, + itemId = baseMetadata.itemId, thumbnailImage = thumbnail, title = baseMetadata.title, description = baseMetadata.description diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index b1b45fd83..4b49d6827 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -217,7 +217,7 @@ public class OfflineMapState( _preplannedMapAreaStates.add(preplannedMapAreaState) } // restore any running download job state - restoreJobsAndUpdateState(context) + restoreActiveJobsAndUpdateStates(context) } } } @@ -266,7 +266,7 @@ public class OfflineMapState( } } // restore any running download job state - restoreJobsAndUpdateState(context) + restoreActiveJobsAndUpdateStates(context) } /** @@ -423,11 +423,11 @@ public class OfflineMapState( } /** - * Restores the current preplanned & on-demand job state from preferences. + * Restores the current preplanned & on-demand job state from cached metadata. * * @since 200.8.0 */ - private suspend fun restoreJobsAndUpdateState(context: Context) { + private suspend fun restoreActiveJobsAndUpdateStates(context: Context) { OfflineRepository.getActiveOfflineJobs(context, portalItem.itemId) .forEach { workerUuid -> val mapAreaMetadata = OfflineRepository.getMapAreaMetadataForOfflineJob( @@ -438,7 +438,7 @@ public class OfflineMapState( if (mode == OfflineMapMode.Preplanned) { // update the loaded preplanned area, to restore with the in-progress job state _preplannedMapAreaStates.first { - it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaMetadata.areaId) + it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaMetadata.itemId) }.apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } } else { val restoredState = OnDemandMapAreasState( diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index 344dd4ef8..a8bc89fe3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -40,7 +40,6 @@ import com.arcgismaps.toolkit.offline.workmanager.jobAreaTitleKey import com.arcgismaps.toolkit.offline.workmanager.jobWorkerUuidKey import com.arcgismaps.toolkit.offline.workmanager.jsonJobPathKey import com.arcgismaps.toolkit.offline.workmanager.mobileMapPackagePathKey -import com.arcgismaps.toolkit.offline.workmanager.offlineAreaMetadataJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile import com.arcgismaps.toolkit.offline.workmanager.onDemandAreas @@ -137,15 +136,15 @@ public object OfflineRepository { private fun savePendingMapAreaMetadata( context: Context, portalItem: PortalItem, - areaMetadata: OfflineMapAreaMetadata + mapAreaMetadata: OfflineMapAreaMetadata ) { val pendingAreaMetadataDir = File( OfflineURLs.pendingAreaMetadataDirectoryPath( - context, portalItem.itemId, areaMetadata.areaId + context, portalItem.itemId, mapAreaMetadata.itemId ) ) if (!OfflineMapAreaMetadata.isSerializedFilePresent(pendingAreaMetadataDir)) { - areaMetadata.saveToDirectory(pendingAreaMetadataDir) + mapAreaMetadata.saveToDirectory(pendingAreaMetadataDir) } } /** @@ -461,7 +460,7 @@ public object OfflineRepository { internal fun createPreplannedMapAreaRequestAndQueueDownload( context: Context, portalItemId: String, - mapAreaId: String, + mapAreaItemId: String, jsonJobPath: String, preplannedMapAreaTitle: String ): UUID { @@ -471,7 +470,7 @@ public object OfflineRepository { .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) // add the worker tags .addTag(portalItemId) - .addTag(mapAreaId) + .addTag(mapAreaItemId) // add the input data .setInputData( // add the notificationId and the json file path as a key/value pair @@ -507,7 +506,7 @@ public object OfflineRepository { internal fun createOnDemandMapAreaRequestAndQueueDownload( context: Context, portalItemId: String, - mapAreaId: String, + mapAreaItemId: String, jsonJobPath: String, onDemandMapAreaTitle: String ): UUID { @@ -517,7 +516,7 @@ public object OfflineRepository { .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) // add the worker tags .addTag(portalItemId) - .addTag(mapAreaId) + .addTag(mapAreaItemId) // add the input data .setInputData( // add the notificationId and the json file path as a key/value pair @@ -563,7 +562,7 @@ public object OfflineRepository { savePendingMapAreaMetadata( context = context, portalItem = portalItem, - areaMetadata = OfflineMapAreaMetadata.createPreplannedMetadata(mapArea) + mapAreaMetadata = OfflineMapAreaMetadata.createPreplannedMetadata(mapArea) ) } val workManager = WorkManager.getInstance(context) @@ -658,7 +657,7 @@ public object OfflineRepository { savePendingMapAreaMetadata( context = context, portalItem = portalItem, - areaMetadata = OfflineMapAreaMetadata.createOnDemandMetadata(mapArea) + mapAreaMetadata = OfflineMapAreaMetadata.createOnDemandMetadata(mapArea) ) } val workManager = WorkManager.getInstance(context) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 32d6b15e3..d4913c167 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -241,7 +241,7 @@ private fun OnDemandMapAreaSelectorOptions( calculateEnvelope(mapViewSize, mapViewProxy)?.let { downloadArea -> onDownloadMapAreaSelected.invoke( OnDemandMapAreaConfiguration( - areaID = UUID.randomUUID().toString(), + itemId = UUID.randomUUID().toString(), title = mapAreaName, minScale = 0.0, maxScale = zoomLevel.scale, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 5791a71c1..b60609ca9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -56,7 +56,7 @@ import java.util.UUID * @since 200.8.0 */ internal data class OnDemandMapAreaConfiguration( - internal val areaID: String, + internal val itemId: String, internal val title: String, internal val minScale: Double, internal val maxScale: Double, @@ -118,7 +118,7 @@ internal class OnDemandMapAreasState( internal fun downloadOnDemandMapArea() = runCatchingCancellable { val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable - val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable + val onDemandMapAreaID = configuration?.itemId ?: return@runCatchingCancellable val downloadMapArea = configuration.areaOfInterest if (!scope.isActive) @@ -230,7 +230,7 @@ internal class OnDemandMapAreasState( workerUUID = OfflineRepository.createOnDemandMapAreaRequestAndQueueDownload( context = context, portalItemId = item.itemId, - mapAreaId = onDemandMapAreaId, + mapAreaItemId = onDemandMapAreaId, jsonJobPath = jsonJobFile.path, onDemandMapAreaTitle = configuration?.title ?: item.title ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 0dc04d242..914eff97c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -242,7 +242,7 @@ internal class PreplannedMapAreaState( workerUUID = OfflineRepository.createPreplannedMapAreaRequestAndQueueDownload( context = context, portalItemId = item.itemId, - mapAreaId = preplannedMapAreaId, + mapAreaItemId = preplannedMapAreaId, jsonJobPath = jsonJobFile.path, preplannedMapAreaTitle = item.title ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index e83b95f45..18bb56aef 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -160,12 +160,12 @@ internal object OfflineURLs { internal fun pendingAreaMetadataDirectoryPath( context: Context, portalItemID: String, - mapAreaID: String + mapAreaItemID: String ): String { val caches = getOfflineCacheDirPath(context) val pendingBase = File(caches, pendingMapInfoDir).makeDirectoryIfItDoesNotExist() val itemPendingDir = File(pendingBase, portalItemID).makeDirectoryIfItDoesNotExist() - val areaPendingDir = File(itemPendingDir, mapAreaID).makeDirectoryIfItDoesNotExist() + val areaPendingDir = File(itemPendingDir, mapAreaItemID).makeDirectoryIfItDoesNotExist() return areaPendingDir.absolutePath } From 3fde16a0e1a03afd9affd5603f40cbe850b44cd2 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 2 Jul 2025 15:04:13 -0700 Subject: [PATCH 110/132] Support in-progress download restoration (#907) --- .../toolkit/offline/OfflineMapAreaMetadata.kt | 133 ++++++++++++++++++ .../toolkit/offline/OfflineMapState.kt | 42 +++++- .../toolkit/offline/OfflineRepository.kt | 127 +++++++++++++++-- .../ondemand/OnDemandMapAreaSelector.kt | 2 +- .../offline/ondemand/OnDemandMapAreasState.kt | 60 ++++++-- .../preplanned/PreplannedMapAreasState.kt | 56 +++++++- .../toolkit/offline/workmanager/Constants.kt | 1 + .../offline/workmanager/OfflineURLs.kt | 20 +++ .../workmanager/OnDemandMapAreaJobWorker.kt | 10 +- .../workmanager/PreplannedMapAreaJobWorker.kt | 9 +- 10 files changed, 422 insertions(+), 38 deletions(-) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt new file mode 100644 index 000000000..ce63c9209 --- /dev/null +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreaMetadata.kt @@ -0,0 +1,133 @@ +/* + * + * Copyright 2025 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.offline + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea +import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration +import com.arcgismaps.toolkit.offline.workmanager.offlineAreaMetadataJsonFile +import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileOutputStream + +/** + * Represents the metadata for an area of an offline map. + * + * @since 200.8.0 + */ +@Serializable +internal data class OfflineMapAreaMetadata( + val itemId: String, + val title: String, + val description: String, + @Transient val thumbnailImage: Bitmap? = null +) { + + companion object { + /** + * Creates an [OfflineMapAreaMetadata] from a [PreplannedMapArea]. + * + * @since 200.8.0 + */ + internal fun createPreplannedMetadata(preplannedMapArea: PreplannedMapArea): OfflineMapAreaMetadata { + return OfflineMapAreaMetadata( + itemId = preplannedMapArea.portalItem.itemId, + title = preplannedMapArea.portalItem.title, + thumbnailImage = preplannedMapArea.portalItem.thumbnail?.image?.bitmap, + description = preplannedMapArea.portalItem.description + ) + } + + /** + * Creates an [OfflineMapAreaMetadata] from an [OnDemandMapAreaConfiguration]. + * + * @since 200.8.0 + */ + internal fun createOnDemandMetadata(onDemandMapAreaConfiguration: OnDemandMapAreaConfiguration): OfflineMapAreaMetadata { + return OfflineMapAreaMetadata( + itemId = onDemandMapAreaConfiguration.itemId, + title = onDemandMapAreaConfiguration.title, + thumbnailImage = onDemandMapAreaConfiguration.thumbnail, + description = "" + ) + } + + /** + * Creates an [OfflineMapAreaMetadata] from a [directory] on disk, if “metadata.json” exists. + * + * @since 200.8.0 + */ + internal fun createFromDirectory(directory: File): OfflineMapAreaMetadata? { + val metadataFile = File(directory, offlineAreaMetadataJsonFile) + if (!metadataFile.exists()) { + return null + } + val jsonString = runCatching { metadataFile.readText(Charsets.UTF_8) }.getOrNull() + ?: return null + val baseMetadata = runCatching { + Json.decodeFromString(serializer(), jsonString) + }.getOrNull() ?: return null + val thumbnailFile = File(directory, offlineMapInfoThumbnailFile) + val thumbnail: Bitmap? = if (thumbnailFile.exists()) { + BitmapFactory.decodeFile(thumbnailFile.absolutePath) + } else { + null + } + return OfflineMapAreaMetadata( + itemId = baseMetadata.itemId, + thumbnailImage = thumbnail, + title = baseMetadata.title, + description = baseMetadata.description + ) + } + + /** + * Returns true if metadata.json is found in the [directory]. + */ + internal fun isSerializedFilePresent(directory: File): Boolean { + return File(directory, offlineAreaMetadataJsonFile).exists() + } + } + + /** + * Save this OfflineMapAreaMetadata into a [directory] on disk. + * + * @since 200.8.0 + */ + internal fun saveToDirectory(directory: File) { + if (!directory.exists()) { + directory.mkdirs() + } + val metadataFile = File(directory, offlineAreaMetadataJsonFile) + val jsonString = Json.encodeToString(serializer(), this) + runCatching { metadataFile.writeText(jsonString, Charsets.UTF_8) } + thumbnailImage?.let { bmp -> + val thumbFile = File(directory, offlineMapInfoThumbnailFile) + runCatching { + FileOutputStream(thumbFile).use { out -> + bmp.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } + } + } +} diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 6660af938..4b49d6827 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -65,7 +65,7 @@ public class OfflineMapState( onSelectionChanged = onSelectionChanged ) - private var _mode: OfflineMapMode = OfflineMapMode.Unknown + private var _mode: OfflineMapMode by mutableStateOf(OfflineMapMode.Unknown) internal val mode: OfflineMapMode get() = _mode @@ -216,6 +216,8 @@ public class OfflineMapState( } _preplannedMapAreaStates.add(preplannedMapAreaState) } + // restore any running download job state + restoreActiveJobsAndUpdateStates(context) } } } @@ -263,6 +265,8 @@ public class OfflineMapState( _onDemandMapAreaStates.add(it) } } + // restore any running download job state + restoreActiveJobsAndUpdateStates(context) } /** @@ -305,8 +309,9 @@ public class OfflineMapState( mobileMapPackagePath = preplannedPath ) return preplannedMapAreaState - } else + } else { return null + } } /** @@ -395,6 +400,8 @@ public class OfflineMapState( /** * Removes a specific [PreplannedMapAreaState] from the list of preplanned map areas. + * + * @since 200.8.0 */ internal fun removePreplannedMapArea(state: PreplannedMapAreaState) { if (state.isSelectedToOpen) { @@ -405,6 +412,7 @@ public class OfflineMapState( /** * Removes a specific [OnDemandMapAreasState] from the list of on-demand map areas. + * * @since 200.8.0 */ internal fun removeOnDemandMapArea(state: OnDemandMapAreasState) { @@ -413,6 +421,36 @@ public class OfflineMapState( } _onDemandMapAreaStates.remove(state) } + + /** + * Restores the current preplanned & on-demand job state from cached metadata. + * + * @since 200.8.0 + */ + private suspend fun restoreActiveJobsAndUpdateStates(context: Context) { + OfflineRepository.getActiveOfflineJobs(context, portalItem.itemId) + .forEach { workerUuid -> + val mapAreaMetadata = OfflineRepository.getMapAreaMetadataForOfflineJob( + context = context, + uuid = workerUuid, + portalItemId = portalItem.itemId + ) ?: return@forEach + if (mode == OfflineMapMode.Preplanned) { + // update the loaded preplanned area, to restore with the in-progress job state + _preplannedMapAreaStates.first { + it.preplannedMapArea?.portalItem?.itemId.equals(mapAreaMetadata.itemId) + }.apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } + } else { + val restoredState = OnDemandMapAreasState( + context = context, + item = portalItem, + onSelectionChanged = onSelectionChanged + ).apply { restoreOfflineMapJobState(workerUuid, mapAreaMetadata) } + // add the in-progress job states after loading on-demand map areas + _onDemandMapAreaStates.add(restoredState) + } + } + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index 97cb9b0d8..a8bc89fe3 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -25,6 +25,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkInfo import androidx.work.WorkManager +import androidx.work.WorkQuery import androidx.work.workDataOf import com.arcgismaps.mapping.PortalItem import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreasState @@ -43,6 +44,8 @@ import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoJsonFile import com.arcgismaps.toolkit.offline.workmanager.offlineMapInfoThumbnailFile import com.arcgismaps.toolkit.offline.workmanager.onDemandAreas import com.arcgismaps.toolkit.offline.workmanager.preplannedMapAreas +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.util.UUID @@ -107,6 +110,10 @@ public object OfflineRepository { /** * Saves the [OfflineMapInfo] to the pending folder for a particular web map's portal item. * The info will stay in that folder until the job completes. + * + * - `/OfflineMapAreasCache/PendingMapInfo//info.json` + * + * @since 200.8.0 */ private fun savePendingMapInfo(context: Context, portalItem: PortalItem) { val pendingMapInfoDir = File( @@ -118,6 +125,84 @@ public object OfflineRepository { } } + /** + * Saves the [OfflineMapAreaMetadata] to the pending folder for map area of a web map's portal item. + * The info will stay in this folder until the job completes. + * + * - `/OfflineMapAreasCache/PendingMapInfo///metadata.json` + * + * @since 200.8.0 + */ + private fun savePendingMapAreaMetadata( + context: Context, + portalItem: PortalItem, + mapAreaMetadata: OfflineMapAreaMetadata + ) { + val pendingAreaMetadataDir = File( + OfflineURLs.pendingAreaMetadataDirectoryPath( + context, portalItem.itemId, mapAreaMetadata.itemId + ) + ) + if (!OfflineMapAreaMetadata.isSerializedFilePresent(pendingAreaMetadataDir)) { + mapAreaMetadata.saveToDirectory(pendingAreaMetadataDir) + } + } + /** + * Returns preplanned/on-demand map area [OfflineMapAreaMetadata] using the corresponding job [UUID]. + * + * @since 200.8.0 + */ + internal suspend fun getMapAreaMetadataForOfflineJob( + context: Context, + uuid: UUID, + portalItemId: String + ): OfflineMapAreaMetadata? { + val workManager = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder + .fromIds(listOf(uuid)) + .build() + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfos(workQuery).get() + } + val workerTags = workInfos.firstOrNull()?.tags ?: return null + workerTags.forEach { tag -> + // Skip non relevant tags, like: com.arcgismaps.toolkit.offline.workmanager. + if (tag != portalItemId && tag.length < 42) { + val areaMetadataDir = File( + OfflineURLs.pendingAreaMetadataDirectoryPath( + context, portalItemId, tag + ) + ) + if (OfflineMapAreaMetadata.isSerializedFilePresent(areaMetadataDir)) { + return OfflineMapAreaMetadata.createFromDirectory(areaMetadataDir) + } + } + } + return null + } + + /** + * Returns the list of [UUID] for active running/enqueued jobs for the given [portalItemId]. + * + * @since 200.8.0 + */ + internal suspend fun getActiveOfflineJobs( + context: Context, + portalItemId: String + ): List { + val workManager = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder + .fromTags(listOf(portalItemId)) + .build() + val workInfos = withContext(Dispatchers.IO) { + workManager.getWorkInfos(workQuery).get() + } + val activePortalItemWorkers = workInfos.filter { workInfo -> + (workInfo.state == WorkInfo.State.ENQUEUED || workInfo.state == WorkInfo.State.RUNNING) + } + return activePortalItemWorkers.map { it.id } + } + /** * Creates and returns the directory file for a pending preplanned job. * @@ -184,7 +269,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun loadOfflineMapInfos(context: Context): List { + internal fun loadOfflineMapInfos(context: Context): List { val baseDir = File(OfflineURLs.offlineRepositoryDirectoryPath(context)) val offlineMapInfos = mutableListOf() if (!baseDir.exists() || !baseDir.isDirectory) { @@ -209,7 +294,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun movePreplannedJobResultToDestination( + internal fun movePreplannedJobResultToDestination( context: Context, offlineMapCacheDownloadPath: String ): File { @@ -240,7 +325,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - private fun moveOnDemandJobResultToDestination( + internal fun moveOnDemandJobResultToDestination( context: Context, offlineMapCacheDownloadPath: String ): File { @@ -374,6 +459,8 @@ public object OfflineRepository { */ internal fun createPreplannedMapAreaRequestAndQueueDownload( context: Context, + portalItemId: String, + mapAreaItemId: String, jsonJobPath: String, preplannedMapAreaTitle: String ): UUID { @@ -381,6 +468,9 @@ public object OfflineRepository { val workRequest = OneTimeWorkRequestBuilder() // run it as an expedited work .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + // add the worker tags + .addTag(portalItemId) + .addTag(mapAreaItemId) // add the input data .setInputData( // add the notificationId and the json file path as a key/value pair @@ -415,6 +505,8 @@ public object OfflineRepository { */ internal fun createOnDemandMapAreaRequestAndQueueDownload( context: Context, + portalItemId: String, + mapAreaItemId: String, jsonJobPath: String, onDemandMapAreaTitle: String ): UUID { @@ -422,6 +514,9 @@ public object OfflineRepository { val workRequest = OneTimeWorkRequestBuilder() // run it as an expedited work .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + // add the worker tags + .addTag(portalItemId) + .addTag(mapAreaItemId) // add the input data .setInputData( // add the notificationId and the json file path as a key/value pair @@ -462,8 +557,15 @@ public object OfflineRepository { portalItem: PortalItem, onWorkInfoStateChanged: (WorkInfo) -> Unit, ) { - val workManager = WorkManager.getInstance(context) savePendingMapInfo(context, portalItem) + preplannedMapAreaState.preplannedMapArea?.let { mapArea -> + savePendingMapAreaMetadata( + context = context, + portalItem = portalItem, + mapAreaMetadata = OfflineMapAreaMetadata.createPreplannedMetadata(mapArea) + ) + } + val workManager = WorkManager.getInstance(context) // collect the flow to get the latest work info list workManager.getWorkInfoByIdFlow(offlineWorkerUUID) .collect { workInfo -> @@ -480,11 +582,9 @@ public object OfflineRepository { WorkInfo.State.SUCCEEDED -> { preplannedMapAreaState.updateStatus(PreplannedStatus.Downloaded) workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> - // using the pending path, move the result to final destination path - val destDir = movePreplannedJobResultToDestination(context, path) // create & load the downloaded map preplannedMapAreaState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = destDir.absolutePath + mobileMapPackagePath = path ) // skip adding map info if it already exists in the list if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { @@ -552,8 +652,15 @@ public object OfflineRepository { portalItem: PortalItem, onWorkInfoStateChanged: (WorkInfo) -> Unit, ) { - val workManager = WorkManager.getInstance(context) savePendingMapInfo(context, portalItem) + onDemandMapAreasState.configuration?.let { mapArea -> + savePendingMapAreaMetadata( + context = context, + portalItem = portalItem, + mapAreaMetadata = OfflineMapAreaMetadata.createOnDemandMetadata(mapArea) + ) + } + val workManager = WorkManager.getInstance(context) // collect the flow to get the latest work info list workManager.getWorkInfoByIdFlow(offlineWorkerUUID) .collect { workInfo -> @@ -570,11 +677,9 @@ public object OfflineRepository { WorkInfo.State.SUCCEEDED -> { onDemandMapAreasState.updateStatus(OnDemandStatus.Downloaded) workInfo.outputData.getString(mobileMapPackagePathKey)?.let { path -> - // using the pending path, move the result to final destination path - val destDir = moveOnDemandJobResultToDestination(context, path) // create & load the downloaded map onDemandMapAreasState.createAndLoadMMPKAndOfflineMap( - mobileMapPackagePath = destDir.absolutePath + mobileMapPackagePath = path ) // skip adding map info if it already exists in the list if (_offlineMapInfos.find { it.id == portalItem.itemId } == null) { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index 32d6b15e3..d4913c167 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -241,7 +241,7 @@ private fun OnDemandMapAreaSelectorOptions( calculateEnvelope(mapViewSize, mapViewProxy)?.let { downloadArea -> onDownloadMapAreaSelected.invoke( OnDemandMapAreaConfiguration( - areaID = UUID.randomUUID().toString(), + itemId = UUID.randomUUID().toString(), title = mapAreaName, minScale = 0.0, maxScale = zoomLevel.scale, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index 542caa5b1..b60609ca9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -35,6 +35,7 @@ import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapUpdateMode import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask +import com.arcgismaps.toolkit.offline.OfflineMapAreaMetadata import com.arcgismaps.toolkit.offline.OfflineRepository import com.arcgismaps.toolkit.offline.internal.utils.ZoomLevel import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize @@ -44,6 +45,7 @@ import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File import java.util.UUID @@ -54,7 +56,7 @@ import java.util.UUID * @since 200.8.0 */ internal data class OnDemandMapAreaConfiguration( - internal val areaID: String, + internal val itemId: String, internal val title: String, internal val minScale: Double, internal val maxScale: Double, @@ -70,7 +72,7 @@ internal data class OnDemandMapAreaConfiguration( internal class OnDemandMapAreasState( private val context: Context, private val item: Item, - private val configuration: OnDemandMapAreaConfiguration? = null, + internal val configuration: OnDemandMapAreaConfiguration? = null, private val offlineMapTask: OfflineMapTask? = null, private val onSelectionChanged: (ArcGISMap) -> Unit ) { @@ -98,12 +100,15 @@ internal class OnDemandMapAreasState( internal val directorySize: Int get() = _directorySize - private lateinit var scope: CoroutineScope + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - internal val title = configuration?.title ?: item.title + private var _title by mutableStateOf(configuration?.title ?: item.title) + internal val title get() = _title - internal val thumbnail: Bitmap? - get() = configuration?.thumbnail ?: item.thumbnail?.image?.bitmap + private var _thumbnail by mutableStateOf( + configuration?.thumbnail ?: item.thumbnail?.image?.bitmap + ) + internal val thumbnail: Bitmap? get() = _thumbnail /** * Initiates downloading of the associated on-demand map area for offline use. @@ -111,12 +116,13 @@ internal class OnDemandMapAreasState( * @since 200.8.0 */ internal fun downloadOnDemandMapArea() = runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable - val onDemandMapAreaID = configuration?.areaID ?: return@runCatchingCancellable + val onDemandMapAreaID = configuration?.itemId ?: return@runCatchingCancellable val downloadMapArea = configuration.areaOfInterest + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) scope.launch { _status = OnDemandStatus.Downloading val offlineWorkerUUID = startOfflineMapJob( @@ -124,7 +130,7 @@ internal class OnDemandMapAreasState( onDemandMapAreaID = onDemandMapAreaID, downloadMapArea = downloadMapArea, offlineMapTask = task - ) + ), onDemandMapAreaId = onDemandMapAreaID ) OfflineRepository.observeStatusForOnDemandWork( context = context, @@ -206,12 +212,16 @@ internal class OnDemandMapAreasState( * in WorkManager. * * @param downloadOnDemandOfflineMapJob The on-demand offline map job to execute using WorkManager. + * @param onDemandMapAreaId The map area ID of used to track the job state. * * @return A unique identifier ([UUID]) associated with this task within WorkManager's queue system. * * @since 200.8.0 */ - private fun startOfflineMapJob(downloadOnDemandOfflineMapJob: GenerateOfflineMapJob): UUID { + private fun startOfflineMapJob( + downloadOnDemandOfflineMapJob: GenerateOfflineMapJob, + onDemandMapAreaId: String + ): UUID { val jsonJobFile = OfflineRepository.saveJobToDisk( jobPath = downloadOnDemandOfflineMapJob.downloadDirectoryPath, jobJson = downloadOnDemandOfflineMapJob.toJson() @@ -219,6 +229,8 @@ internal class OnDemandMapAreasState( workerUUID = OfflineRepository.createOnDemandMapAreaRequestAndQueueDownload( context = context, + portalItemId = item.itemId, + mapAreaItemId = onDemandMapAreaId, jsonJobPath = jsonJobFile.path, onDemandMapAreaTitle = configuration?.title ?: item.title ) @@ -318,6 +330,34 @@ internal class OnDemandMapAreasState( onSelectionChanged(map) } } + + /** + * Restores and observes the state of a given offline map download job. + * + * @since 200.8.0 + */ + fun restoreOfflineMapJobState( + offlineWorkerUUID: UUID, + offlineMapAreaMetadata: OfflineMapAreaMetadata + ) { + // restore the UI state + _title = offlineMapAreaMetadata.title + _thumbnail = offlineMapAreaMetadata.thumbnailImage + // observe the active job + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) + scope.launch { + workerUUID = offlineWorkerUUID + _status = OnDemandStatus.Downloading + OfflineRepository.observeStatusForOnDemandWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + onDemandMapAreasState = this@OnDemandMapAreasState, + portalItem = item as PortalItem, + offlineWorkerUUID = offlineWorkerUUID + ) + } + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 5acd9a7c5..914eff97c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -36,6 +36,7 @@ import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask import com.arcgismaps.tasks.offlinemaptask.PreplannedMapArea import com.arcgismaps.tasks.offlinemaptask.PreplannedPackagingStatus import com.arcgismaps.tasks.offlinemaptask.PreplannedUpdateMode +import com.arcgismaps.toolkit.offline.OfflineMapAreaMetadata import com.arcgismaps.toolkit.offline.OfflineRepository import com.arcgismaps.toolkit.offline.internal.utils.getDirectorySize import com.arcgismaps.toolkit.offline.runCatchingCancellable @@ -44,6 +45,7 @@ import com.arcgismaps.toolkit.offline.workmanager.logWorkInfo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File import java.util.UUID @@ -84,10 +86,15 @@ internal class PreplannedMapAreaState( internal val directorySize: Int get() = _directorySize - private lateinit var scope: CoroutineScope + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO) - internal val title = item.title - internal val description = item.description + private var _title by mutableStateOf(preplannedMapArea?.portalItem?.title ?: item.title) + internal val title get() = _title + + private var _description by mutableStateOf( + preplannedMapArea?.portalItem?.description ?: item.description + ) + internal val description get() = _description private var _thumbnail by mutableStateOf(null) internal val thumbnail: Bitmap? get() = _thumbnail ?: item.thumbnail?.image?.bitmap @@ -133,18 +140,19 @@ internal class PreplannedMapAreaState( */ internal fun downloadPreplannedMapArea() = runCatchingCancellable { - scope = CoroutineScope(Dispatchers.IO) val area = preplannedMapArea ?: return@runCatchingCancellable val task = offlineMapTask ?: return@runCatchingCancellable val portalItem = item as? PortalItem ?: return@runCatchingCancellable + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) scope.launch { _status = PreplannedStatus.Downloading val offlineWorkerUUID = startOfflineMapJob( downloadPreplannedOfflineMapJob = createOfflineMapJob( preplannedMapArea = area, offlineMapTask = task - ) + ), preplannedMapAreaId = area.portalItem.itemId ) OfflineRepository.observeStatusForPreplannedWork( context = context, @@ -217,19 +225,24 @@ internal class PreplannedMapAreaState( * in WorkManager. * * @param downloadPreplannedOfflineMapJob The prepared offline map job to execute using WorkManager. + * @param preplannedMapAreaId The map area ID of used to track the job state. * * @return A unique identifier ([UUID]) associated with this task within WorkManager's queue system. * * @since 200.8.0 */ - private fun startOfflineMapJob(downloadPreplannedOfflineMapJob: DownloadPreplannedOfflineMapJob): UUID { + private fun startOfflineMapJob( + downloadPreplannedOfflineMapJob: DownloadPreplannedOfflineMapJob, + preplannedMapAreaId: String + ): UUID { val jsonJobFile = OfflineRepository.saveJobToDisk( jobPath = downloadPreplannedOfflineMapJob.downloadDirectoryPath, jobJson = downloadPreplannedOfflineMapJob.toJson() ) - workerUUID = OfflineRepository.createPreplannedMapAreaRequestAndQueueDownload( context = context, + portalItemId = item.itemId, + mapAreaItemId = preplannedMapAreaId, jsonJobPath = jsonJobFile.path, preplannedMapAreaTitle = item.title ) @@ -315,6 +328,35 @@ internal class PreplannedMapAreaState( onSelectionChanged(map) } } + + /** + * Restores and observes the state of a given offline map download job. + * + * @since 200.8.0 + */ + fun restoreOfflineMapJobState( + offlineWorkerUUID: UUID, + offlineMapAreaMetadata: OfflineMapAreaMetadata + ) { + // restore the UI state + _title = offlineMapAreaMetadata.title + _description = offlineMapAreaMetadata.description + _thumbnail = offlineMapAreaMetadata.thumbnailImage + // observe the active job + if (!scope.isActive) + scope = CoroutineScope(Dispatchers.IO) + scope.launch { + workerUUID = offlineWorkerUUID + _status = PreplannedStatus.Downloading + OfflineRepository.observeStatusForPreplannedWork( + context = context, + onWorkInfoStateChanged = ::logWorkInfo, + preplannedMapAreaState = this@PreplannedMapAreaState, + portalItem = item as PortalItem, + offlineWorkerUUID = offlineWorkerUUID + ) + } + } } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt index 673b4efc5..e70d57c58 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/Constants.kt @@ -37,6 +37,7 @@ internal const val onDemandAreas = "OnDemand" // Offline Map Info constants internal const val offlineMapInfoJsonFile = "info.json" +internal const val offlineAreaMetadataJsonFile = "metadata.json" internal const val offlineMapInfoThumbnailFile = "thumbnail.png" // Notification constants diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 58dd1d5f4..18bb56aef 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -149,6 +149,26 @@ internal object OfflineURLs { return itemPendingDir.absolutePath } + /** + * Returns the path to the map area's metadata directory from the external cache, + * creates the directory if it doesn’t already exist: + * + * - `/OfflineMapAreasCache/PendingMapInfo///` + * + * @since 200.8.0 + */ + internal fun pendingAreaMetadataDirectoryPath( + context: Context, + portalItemID: String, + mapAreaItemID: String + ): String { + val caches = getOfflineCacheDirPath(context) + val pendingBase = File(caches, pendingMapInfoDir).makeDirectoryIfItDoesNotExist() + val itemPendingDir = File(pendingBase, portalItemID).makeDirectoryIfItDoesNotExist() + val areaPendingDir = File(itemPendingDir, mapAreaItemID).makeDirectoryIfItDoesNotExist() + return areaPendingDir.absolutePath + } + /** * Returns the path to the “PendingMapInfo” directory from the external cache, * creates the directory if it doesn’t already exist: diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt index 6b6d97299..279b8b504 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OnDemandMapAreaJobWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import com.arcgismaps.tasks.JobStatus import com.arcgismaps.tasks.offlinemaptask.GenerateOfflineMapJob +import com.arcgismaps.toolkit.offline.OfflineRepository.moveOnDemandJobResultToDestination import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin @@ -160,11 +161,12 @@ internal class OnDemandMapAreaJobWorker( if (jobResult.isSuccess) { workerNotification.showStatusNotification("The download for $jobAreaTitle has completed successfully.") val downloadOnDemandOfflineMapResult = jobResult.getOrNull() - val outputData = workDataOf( - mobileMapPackagePathKey to (downloadOnDemandOfflineMapResult?.mobileMapPackage?.path - ?: ""), + // using the pending path, move the result to final destination path + val mmpkDestDir = moveOnDemandJobResultToDestination( + context = context, + offlineMapCacheDownloadPath = downloadOnDemandOfflineMapResult?.mobileMapPackage?.path.toString() ) - Result.success(outputData) + Result.success(workDataOf(mobileMapPackagePathKey to mmpkDestDir.absolutePath)) } else { val errorMessage = jobResult.exceptionOrNull()?.message ?: "Unknown error during job execution" diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt index ec13a0eb3..26286daac 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/PreplannedMapAreaJobWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import androidx.work.workDataOf import com.arcgismaps.tasks.JobStatus import com.arcgismaps.tasks.offlinemaptask.DownloadPreplannedOfflineMapJob +import com.arcgismaps.toolkit.offline.OfflineRepository.movePreplannedJobResultToDestination import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -159,10 +160,12 @@ internal class PreplannedMapAreaJobWorker( if (jobResult.isSuccess) { workerNotification.showStatusNotification("The download for $jobAreaTitle has completed successfully.") val downloadPreplannedOfflineMapResult = jobResult.getOrNull() - val outputData = workDataOf( - mobileMapPackagePathKey to (downloadPreplannedOfflineMapResult?.mobileMapPackage?.path ?: ""), + // using the pending path, move the result to final destination path + val mmpkDestDir = movePreplannedJobResultToDestination( + context = context, + offlineMapCacheDownloadPath = downloadPreplannedOfflineMapResult?.mobileMapPackage?.path.toString() ) - Result.success(outputData) + Result.success(workDataOf(mobileMapPackagePathKey to mmpkDestDir.absolutePath)) } else { val errorMessage = jobResult.exceptionOrNull()?.message ?: "Unknown error during job execution" From a5b08267780661ed69b7b9f5016432834cf8a339 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 2 Jul 2025 16:07:05 -0700 Subject: [PATCH 111/132] updated icon, cleanup states, remove todos --- .../offlinemapareasapp/screens/MainScreen.kt | 4 --- .../screens/OfflineViewModel.kt | 10 +++---- .../toolkit/offline/OfflineMapState.kt | 3 +- .../toolkit/offline/internal/utils/Buttons.kt | 10 +++---- .../offline/ondemand/OnDemandMapAreas.kt | 3 -- .../offline/ondemand/OnDemandMapAreasState.kt | 30 ++----------------- .../preplanned/PreplannedMapAreasState.kt | 2 +- .../src/main/res/drawable/download_24px.xml | 9 ++++++ 8 files changed, 23 insertions(+), 48 deletions(-) create mode 100644 toolkit/offline/src/main/res/drawable/download_24px.xml diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt index 31f60cd83..2733fbb0d 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt @@ -19,8 +19,6 @@ package com.arcgismaps.toolkit.offlinemapareasapp.screens import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring.StiffnessMediumLow -import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -64,7 +62,6 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { // Radio options val options = listOf("Go Online", "Offline Maps") - var selectedOption by remember { mutableStateOf(options[0]) } var expanded by remember { mutableStateOf(false) } BottomSheetScaffold( @@ -97,7 +94,6 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { DropdownMenuItem( text = { Text(option) }, onClick = { - selectedOption = option expanded = false if (option == "Go Online") { viewModel.selectedMap.value = null diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt index f20185aab..030f90bf8 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt @@ -29,14 +29,12 @@ import kotlinx.coroutines.launch class OfflineViewModel : ViewModel() { - // TODO: Update to use napervilleWaterNetwork and remove USBreweriesforOfflineTesting - // private val napervilleWaterNetwork = "acc027394bc84c2fb04d1ed317aac674" - private val USBreweriesforOfflineTesting = "3da658f2492f4cfd8494970ef489d2c5" + private val napervilleWaterNetwork = "acc027394bc84c2fb04d1ed317aac674" private val onlineMap = ArcGISMap( - PortalItem( - Portal.arcGISOnline(connection = Portal.Connection.Anonymous), - USBreweriesforOfflineTesting + item = PortalItem( + portal = Portal.arcGISOnline(connection = Portal.Connection.Anonymous), + itemId = napervilleWaterNetwork ) ) val selectedMap = mutableStateOf(null) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 4b49d6827..1c593899b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -184,7 +184,8 @@ public class OfflineMapState( preplannedMapAreas.addAll( elements = offlineMapTask.getPreplannedMapAreas().getOrNull() ?: emptyList() ) - } catch (e: Exception) { + } catch (_: Exception) { + // an exception will be thrown in offline mode preplannedMapAreas.clear() } if (isShowingOnlyOfflineModels || preplannedMapAreas.isEmpty()) { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt index 86dd0df7c..4c007ceba 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -33,16 +32,17 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.DownloadForOffline +import androidx.compose.material.icons.rounded.FileDownload import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -50,6 +50,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -62,8 +63,7 @@ internal fun DownloadButton(onClick: () -> Unit) { modifier = Modifier.size(30.dp), onClick = onClick ) { - Icon( - imageVector = Icons.Filled.Download, + Icon(painter = painterResource(R.drawable.download_24px), contentDescription = stringResource(R.string.download), tint = MaterialTheme.colorScheme.primary, ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 87b412c6c..9e368c92e 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -247,8 +247,5 @@ private fun getOnDemandMapAreaStatusString(context: Context, status: OnDemandSta OnDemandStatus.Downloaded -> getString(context, R.string.downloaded) OnDemandStatus.Downloading -> getString(context, R.string.downloading) OnDemandStatus.DownloadCancelled -> getString(context, R.string.cancelled) - OnDemandStatus.PackageFailure -> getString(context, R.string.packaging_failed) - OnDemandStatus.Packaged -> getString(context, R.string.ready_to_download) - OnDemandStatus.Packaging -> getString(context, R.string.packaging) } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index b60609ca9..aaefe803d 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -365,8 +365,6 @@ internal class OnDemandMapAreasState( * * @since 200.8.0 */ -// TODO: Refine status as not all the status values are being used in onDemand -// compared to Preplanned in Swift implementation. internal sealed class OnDemandStatus { /** @@ -384,21 +382,6 @@ internal sealed class OnDemandStatus { */ data class LoadFailure(val error: Throwable) : OnDemandStatus() - /** - * On-Demand map area is packaging. - */ - data object Packaging : OnDemandStatus() - - /** - * On-Demand map area is packaged and ready for download. - */ - data object Packaged : OnDemandStatus() - - /** - * On-Demand map area packaging failed. - */ - data object PackageFailure : OnDemandStatus() - /** * On-Demand map area is being downloaded. */ @@ -424,22 +407,13 @@ internal sealed class OnDemandStatus { */ data class MmpkLoadFailure(val error: Throwable) : OnDemandStatus() - /** - * Indicates whether the model can load the on-demand map area. - */ - val canLoadOnDemandMapArea: Boolean - get() = when (this) { - is NotLoaded, is LoadFailure, is PackageFailure -> true - is Loading, is Packaging, is Packaged, is Downloading, is DownloadCancelled, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false - } - /** * Indicates if download is allowed for this status. */ val allowsDownload: Boolean get() = when (this) { - is Packaged -> true - is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is DownloadCancelled, is DownloadFailure, is Downloaded, is MmpkLoadFailure -> false + is NotLoaded -> true + is Loading, is LoadFailure, is Downloading, is DownloadCancelled, is DownloadFailure, is Downloaded, is MmpkLoadFailure -> false } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 914eff97c..0ec20bf75 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -116,7 +116,7 @@ internal class PreplannedMapAreaState( ?.onSuccess { _status = try { PreplannedStatus.fromPackagingStatus(preplannedMapArea.packagingStatus) - } catch (illegalStateException: IllegalStateException) { + } catch (_: IllegalStateException) { // Note: Packaging status is `Unknown` for compatibility with legacy webmaps // that have incomplete metadata. We throw an illegalStateException when Package // Status is unknown. We can safely assume that the preplanned map area is packaged. diff --git a/toolkit/offline/src/main/res/drawable/download_24px.xml b/toolkit/offline/src/main/res/drawable/download_24px.xml new file mode 100644 index 000000000..c6a92a8a4 --- /dev/null +++ b/toolkit/offline/src/main/res/drawable/download_24px.xml @@ -0,0 +1,9 @@ + + + From f304d13de887a0e32c352642d658a833f49afbb2 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 2 Jul 2025 16:10:15 -0700 Subject: [PATCH 112/132] Demo app enhancements --- .../navigation/Navigator.kt | 15 ++- .../screens/browse/OnDeviceMapInfo.kt | 107 ++++++++++-------- 2 files changed, 69 insertions(+), 53 deletions(-) diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt index a75fd09db..9ca15f1bf 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/navigation/Navigator.kt @@ -20,6 +20,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.navigation.NavHostController @@ -85,12 +86,14 @@ fun AppNavigation( enterTransition = { fadeIn() }, exitTransition = { fadeOut() } ) { - MapListScreen { uri -> - // encode the uri since it is equivalent to a navigation route - val encodedUri = URLEncoder.encode(uri, StandardCharsets.UTF_8.toString()) - val route = "mapview/$encodedUri" - // navigate to the mapview - navController.navigate(route) + Surface { + MapListScreen { uri -> + // encode the uri since it is equivalent to a navigation route + val encodedUri = URLEncoder.encode(uri, StandardCharsets.UTF_8.toString()) + val route = "mapview/$encodedUri" + // navigate to the mapview + navController.navigate(route) + } } } diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt index ffc803176..3368e5e28 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt @@ -2,8 +2,12 @@ package com.arcgismaps.toolkit.offlinemapareasapp.screens.browse import android.graphics.Bitmap import android.graphics.BitmapFactory +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,8 +22,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -32,6 +34,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.arcgismaps.toolkit.offline.OfflineMapInfo @@ -40,34 +43,31 @@ import com.arcgismaps.toolkit.offline.OfflineRepository @Composable fun OnDeviceMapInfo( - onClick: (String) -> Unit, - offlineMapInfos: List + onClick: (String) -> Unit, offlineMapInfos: List ) { val context = LocalContext.current - LazyColumn { + LazyColumn( + modifier = Modifier.animateContentSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { item { Button( modifier = Modifier .fillMaxWidth() - .padding(24.dp), + .padding(horizontal = 32.dp), enabled = offlineMapInfos.isNotEmpty(), - onClick = { - OfflineRepository.removeAllDownloads(context) - }) { + onClick = { OfflineRepository.removeAllDownloads(context) }) { Text("Remove all downloads") } } offlineMapInfos.forEach { offlineMapInfo -> item { - OfflineMapInfoCard( - info = offlineMapInfo, - onOpen = { - onClick.invoke(offlineMapInfo.id) - }, - onDelete = { - OfflineRepository.removeDownloadsForWebmap(context,offlineMapInfo) - }) + OfflineMapInfoCard(info = offlineMapInfo, onOpen = { + onClick.invoke(offlineMapInfo.id) + }, onDelete = { + OfflineRepository.removeDownloadsForWebmap(context, offlineMapInfo) + }) } } } @@ -76,25 +76,23 @@ fun OnDeviceMapInfo( @Composable fun OfflineMapInfoCard( info: OfflineMapInfo, - modifier: Modifier = Modifier, placeholder: Bitmap = rememberInfoPlaceholderBitmap(), onOpen: () -> Unit, onDelete: () -> Unit ) { - Card( - modifier = modifier + Box( + modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 8.dp) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) .clickable { onOpen.invoke() }, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - shape = RoundedCornerShape(8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + contentAlignment = Alignment.Center ) { Row( modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + .padding(4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { val bmp = info.thumbnail ?: placeholder Image( @@ -102,40 +100,56 @@ fun OfflineMapInfoCard( contentDescription = "Map thumbnail", contentScale = ContentScale.Crop, modifier = Modifier - .size(64.dp) + .size(75.dp) .clip(RoundedCornerShape(4.dp)) ) - Spacer(Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { + Spacer(Modifier.width(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { Text( text = info.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(4.dp)) - Text( - text = info.description, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, + style = MaterialTheme.typography.bodyLarge, overflow = TextOverflow.Ellipsis ) + if (info.description.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = info.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } } - Spacer(Modifier.width(8.dp)) - - IconButton(onClick = onDelete) { + IconButton( + onClick = onDelete, + modifier = Modifier + .size(24.dp) + ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete map" + contentDescription = "Delete map", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(24.dp) ) } - IconButton(onClick = {}) { + IconButton( + onClick = {}, + modifier = Modifier + .size(24.dp) + ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Open map" + contentDescription = "Open map", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) ) } } @@ -148,8 +162,7 @@ fun rememberInfoPlaceholderBitmap(): Bitmap { val resources = LocalContext.current.resources return remember { BitmapFactory.decodeResource( - resources, - android.R.drawable.ic_dialog_info + resources, android.R.drawable.ic_dialog_info ) } } From 855acaf8120800b4f0364f58ad644dbcb10a7e73 Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Thu, 3 Jul 2025 09:15:19 -0700 Subject: [PATCH 113/132] WIP --- toolkit/offline/src/main/AndroidManifest.xml | 1 + .../toolkit/offline/OfflineMapAreas.kt | 8 ++ .../toolkit/offline/internal/utils/Network.kt | 101 ++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt diff --git a/toolkit/offline/src/main/AndroidManifest.xml b/toolkit/offline/src/main/AndroidManifest.xml index 5a136f25e..dfe8ef42e 100644 --- a/toolkit/offline/src/main/AndroidManifest.xml +++ b/toolkit/offline/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ --> + { + val context = LocalContext.current + + // Creates a State with current connectivity state as initial value + return produceState(initialValue = context.currentConnectivityState) { + // In a coroutine, can make suspend calls + context.observeConnectivityAsFlow().collect { value = it } + } +} + +internal fun Context.observeConnectivityAsFlow() = callbackFlow { + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val callback = NetworkCallback { connectionState -> trySend(connectionState) } + + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(networkRequest, callback) + + // Set current state + val currentState = getCurrentConnectivityState(connectivityManager) + trySend(currentState) + + // Remove callback when not used + awaitClose { + // Remove listeners + connectivityManager.unregisterNetworkCallback(callback) + } +} + +internal fun NetworkCallback(callback: (ConnectionState) -> Unit): ConnectivityManager.NetworkCallback { + return object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + callback(ConnectionState.Available) + } + + override fun onLost(network: Network) { + callback(ConnectionState.Unavailable) + } + } +} +/** + * Network utility to get current state of internet connection + */ +internal val Context.currentConnectivityState: ConnectionState + get() { + val connectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return getCurrentConnectivityState(connectivityManager) + } + +private fun getCurrentConnectivityState( + connectivityManager: ConnectivityManager +): ConnectionState { + val connected = connectivityManager.allNetworks.any { network -> + connectivityManager.getNetworkCapabilities(network) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + ?: false + } + + return if (connected) ConnectionState.Available else ConnectionState.Unavailable +} + +internal sealed class ConnectionState { + object Available : ConnectionState() + object Unavailable : ConnectionState() +} From 7f063d16cc25b3db250892851d0e700ef8eb53e1 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 3 Jul 2025 14:18:42 -0700 Subject: [PATCH 114/132] Dismiss preplanned sheet and update the map areas list only in offline mode --- .../com/arcgismaps/toolkit/offline/OfflineMapAreas.kt | 3 ++- .../toolkit/offline/preplanned/PreplannedMapAreas.kt | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 423190ca3..1d19c7e9b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -143,7 +143,8 @@ internal fun PreplannedLayoutContainer( if (preplannedMapAreaStates.isNotEmpty()) { PreplannedMapAreas( preplannedMapAreaStates = preplannedMapAreaStates, - onDownloadDeleted = { if (isShowingOnlyOfflineModels) onDownloadDeleted(it) }, + isShowingOnlyOfflineModels = isShowingOnlyOfflineModels, + onDownloadDeleted = onDownloadDeleted, modifier = modifier ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index baa077b25..5ce46e399 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -73,6 +73,7 @@ import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState @Composable internal fun PreplannedMapAreas( preplannedMapAreaStates: List, + isShowingOnlyOfflineModels: Boolean, onDownloadDeleted: (PreplannedMapAreaState) -> Unit, modifier: Modifier ) { @@ -112,8 +113,11 @@ internal fun PreplannedMapAreas( isDeletable = selectedPreplannedMapAreaState.status.isDownloaded && !selectedPreplannedMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedPreplannedMapAreaState.removeDownloadedMapArea { !preplannedMapAreaStates.any { it.status.isDownloaded } } - onDownloadDeleted(selectedPreplannedMapAreaState) - onHideSheet = true + // Dismiss the sheet and update the map areas list only in offline mode + if (isShowingOnlyOfflineModels) { + onDownloadDeleted(selectedPreplannedMapAreaState) + onHideSheet = true + } } ) } From 6a89fd7151f9963eadcaea6891867b1f5e110d08 Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Thu, 3 Jul 2025 14:22:25 -0700 Subject: [PATCH 115/132] WIP --- .../toolkit/offline/OfflineMapAreas.kt | 15 +++--- .../toolkit/offline/OfflineMapState.kt | 27 +++------- .../toolkit/offline/internal/utils/Network.kt | 51 ++++++++++++++----- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 013a03f3d..ce04a745b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -35,7 +35,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.toolkit.offline.internal.utils.AddMapAreaButton -import com.arcgismaps.toolkit.offline.internal.utils.connectivityState +import com.arcgismaps.toolkit.offline.internal.utils.NetworkConnectionState +import com.arcgismaps.toolkit.offline.internal.utils.networkConnectivityState import com.arcgismaps.toolkit.offline.internal.utils.getDefaultMapAreaTitle import com.arcgismaps.toolkit.offline.internal.utils.isValidMapAreaTitle import com.arcgismaps.toolkit.offline.ondemand.OnDemandMapAreaConfiguration @@ -64,17 +65,19 @@ public fun OfflineMapAreas( val initializationStatus by offlineMapState.initializationStatus var isRefreshEnabled by rememberSaveable { mutableStateOf(false) } - val internetConnectionState by connectivityState() + val internetConnectionState by networkConnectivityState() LaunchedEffect(internetConnectionState) { isRefreshEnabled = true - Log.e("CONNECTION ***","Current connection: ${internetConnectionState.javaClass.simpleName}") } LaunchedEffect(offlineMapState, isRefreshEnabled) { if (isRefreshEnabled) { offlineMapState.resetInitialize() } - offlineMapState.initialize(context) + offlineMapState.initialize( + context, + isDeviceOffline = internetConnectionState == NetworkConnectionState.Unavailable + ) isRefreshEnabled = false } @@ -108,7 +111,7 @@ public fun OfflineMapAreas( PreplannedLayoutContainer( modifier = modifier, preplannedMapAreaStates = offlineMapState.preplannedMapAreaStates, - isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, + isShowingOnlyOfflineModels = internetConnectionState == NetworkConnectionState.Unavailable, onDownloadDeleted = offlineMapState::removePreplannedMapArea, onRefresh = { isRefreshEnabled = true } ) @@ -118,7 +121,7 @@ public fun OfflineMapAreas( OnDemandLayoutContainer( modifier = modifier, onDemandMapAreaStates = offlineMapState.onDemandMapAreaStates, - isShowingOnlyOfflineModels = offlineMapState.isShowingOnlyOfflineModels, + isShowingOnlyOfflineModels = internetConnectionState == NetworkConnectionState.Unavailable, localMap = offlineMapState.localMap, onRefresh = { isRefreshEnabled = true }, onDownloadDeleted = offlineMapState::removeOnDemandMapArea, diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 4b49d6827..7bf865da4 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -96,14 +96,6 @@ public class OfflineMapState( */ public val initializationStatus: State = _initializationStatus - /** - * A Boolean value indicating if only offline models are being shown. - * - * @since 200.8.0 - */ - internal var isShowingOnlyOfflineModels by mutableStateOf(false) - private set - /** * A Boolean value indicating whether the web map is offline disabled. * @@ -118,22 +110,19 @@ public class OfflineMapState( * @return the [Result] indicating if the initialization was successful or not * @since 200.8.0 */ - internal suspend fun initialize(context: Context): Result = runCatchingCancellable { + internal suspend fun initialize(context: Context, isDeviceOffline: Boolean): Result = runCatchingCancellable { if (_initializationStatus.value is InitializationStatus.Initialized) { return Result.success(Unit) } _initializationStatus.value = InitializationStatus.Initializing // initialize the offline repository OfflineRepository.refreshOfflineMapInfos(context) - // reset to check if map has offline enabled - isShowingOnlyOfflineModels = false +// // reset to check if map has offline enabled +// isShowingOnlyOfflineModels = false // load the map, and ignore network error if device is offline arcGISMap.retryLoad().getOrElse { error -> // check if the error is due to network connection - if (error.message?.contains("Unable to resolve host") == true) { - // enable offline only mode - isShowingOnlyOfflineModels = true - } else { + if (!isDeviceOffline || error.message?.contains("Unable to resolve host") == false) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error @@ -147,7 +136,7 @@ public class OfflineMapState( // load the task, and ignore network error if device is offline offlineMapTask.retryLoad().getOrElse { error -> // check if the error is not due to network connection - if (error.message?.contains("Unable to resolve host") == false) { + if (!isDeviceOffline || error.message?.contains("Unable to resolve host") == false) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error @@ -159,7 +148,7 @@ public class OfflineMapState( (arcGISMap.loadStatus.value == LoadStatus.Loaded) && (arcGISMap.offlineSettings == null) // load the preplanned map area states - loadPreplannedMapAreas(context) + loadPreplannedMapAreas(context, isDeviceOffline) // check if preplanned for loaded if (_mode != OfflineMapMode.Preplanned || _mode == OfflineMapMode.Unknown) { @@ -177,7 +166,7 @@ public class OfflineMapState( * * @since 200.8.0 */ - private suspend fun loadPreplannedMapAreas(context: Context) { + private suspend fun loadPreplannedMapAreas(context: Context, isDeviceOffline: Boolean) { _preplannedMapAreaStates.clear() val preplannedMapAreas = mutableListOf() try { @@ -187,7 +176,7 @@ public class OfflineMapState( } catch (e: Exception) { preplannedMapAreas.clear() } - if (isShowingOnlyOfflineModels || preplannedMapAreas.isEmpty()) { + if (isDeviceOffline || preplannedMapAreas.isEmpty()) { loadOfflinePreplannedMapAreas(context = context) } else { _mode = OfflineMapMode.Preplanned diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt index 78a195cfd..6ea76a13b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt @@ -30,10 +30,10 @@ import kotlinx.coroutines.flow.callbackFlow import androidx.compose.runtime.State @Composable -internal fun connectivityState(): State { +internal fun networkConnectivityState(): State { val context = LocalContext.current - // Creates a State with current connectivity state as initial value + // Creates a State with current connectivity state as initial value return produceState(initialValue = context.currentConnectivityState) { // In a coroutine, can make suspend calls context.observeConnectivityAsFlow().collect { value = it } @@ -43,7 +43,7 @@ internal fun connectivityState(): State { internal fun Context.observeConnectivityAsFlow() = callbackFlow { val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val callback = NetworkCallback { connectionState -> trySend(connectionState) } + val callback = NetworkCallback(connectivityManager) { networkConnectionState -> trySend(networkConnectionState) } val networkRequest = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) @@ -62,40 +62,67 @@ internal fun Context.observeConnectivityAsFlow() = callbackFlow { } } -internal fun NetworkCallback(callback: (ConnectionState) -> Unit): ConnectivityManager.NetworkCallback { +internal fun NetworkCallback( + connectivityManager: ConnectivityManager, + callback: (NetworkConnectionState) -> Unit +): ConnectivityManager.NetworkCallback { return object : ConnectivityManager.NetworkCallback() { + /** + * This callback is triggered when a network is available. + * It indicates that the device has internet connectivity. + */ override fun onAvailable(network: Network) { - callback(ConnectionState.Available) + callback(NetworkConnectionState.Available) } + /** + * This callback is triggered when a network is temporarily unavailable. + * It does not necessarily mean that there is no internet connectivity, + * but rather that the network is in a transient state (e.g., switching networks). + */ override fun onLost(network: Network) { - callback(ConnectionState.Unavailable) + // This callback is triggered when any previously available network is lost, + // not just when all internet connectivity is lost. If the device switches + // between networks (e.g., from Wi-Fi to mobile data), onLost is called for + // the old network, even if another network is still available. + val state = getCurrentConnectivityState(connectivityManager) + callback(state) } } } + /** * Network utility to get current state of internet connection */ -internal val Context.currentConnectivityState: ConnectionState +internal val Context.currentConnectivityState: NetworkConnectionState get() { val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return getCurrentConnectivityState(connectivityManager) } +/** + * Helper function to determine the current connectivity state. + */ private fun getCurrentConnectivityState( connectivityManager: ConnectivityManager -): ConnectionState { +): NetworkConnectionState { val connected = connectivityManager.allNetworks.any { network -> connectivityManager.getNetworkCapabilities(network) ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false } - return if (connected) ConnectionState.Available else ConnectionState.Unavailable + return if (connected) NetworkConnectionState.Available else NetworkConnectionState.Unavailable } -internal sealed class ConnectionState { - object Available : ConnectionState() - object Unavailable : ConnectionState() +/** + * Represents the state of network connectivity. + * + * - [Available]: Indicates that the device has internet connectivity. + * - [Unavailable]: Indicates that the device does not have internet connectivity. + */ +internal sealed class NetworkConnectionState { + object Available : NetworkConnectionState() + object Unavailable : NetworkConnectionState() } From d9fe6f7517c2877d0f698df536005059add60597 Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Thu, 3 Jul 2025 14:42:11 -0700 Subject: [PATCH 116/132] remove unused import --- .../main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt | 1 - .../main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt | 2 -- 2 files changed, 3 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index ce04a745b..0014c661b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -18,7 +18,6 @@ package com.arcgismaps.toolkit.offline -import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 7bf865da4..74dea9e53 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -117,8 +117,6 @@ public class OfflineMapState( _initializationStatus.value = InitializationStatus.Initializing // initialize the offline repository OfflineRepository.refreshOfflineMapInfos(context) -// // reset to check if map has offline enabled -// isShowingOnlyOfflineModels = false // load the map, and ignore network error if device is offline arcGISMap.retryLoad().getOrElse { error -> // check if the error is due to network connection From 43720128fa6b629039235bbd3a266a26bc407830 Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Thu, 3 Jul 2025 14:47:46 -0700 Subject: [PATCH 117/132] rename Network --- .../offline/internal/utils/{Network.kt => NetworkUtils.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/{Network.kt => NetworkUtils.kt} (100%) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt similarity index 100% rename from toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Network.kt rename to toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt From 6f21a6f3fb81e68743b5d2d86d8041ccabf6e0c8 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 3 Jul 2025 14:59:00 -0700 Subject: [PATCH 118/132] directory and downloads cleanup --- .../com/arcgismaps/toolkit/offline/OfflineMapState.kt | 2 +- .../toolkit/offline/ondemand/OnDemandMapAreas.kt | 4 ---- .../toolkit/offline/ui/MapAreaDetailsScreen.kt | 2 +- .../toolkit/offline/workmanager/OfflineURLs.kt | 11 ++++++++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 1c593899b..c4d9a83b0 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -231,7 +231,7 @@ public class OfflineMapState( */ private suspend fun loadOfflinePreplannedMapAreas(context: Context) { val preplannedDirectory = File( - OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId) + OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId,null,false) ) val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } ?: emptyList() diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 9e368c92e..b5f9b9848 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -105,10 +105,6 @@ internal fun OnDemandMapAreas( description = null, size = selectedOnDemandMapAreaState.directorySize, isAvailableToDownload = selectedOnDemandMapAreaState.status.allowsDownload, - onStartDownload = { - selectedOnDemandMapAreaState.downloadOnDemandMapArea() - onHideSheet = true - }, isDeletable = selectedOnDemandMapAreaState.status.isDownloaded && !selectedOnDemandMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedOnDemandMapAreaState.removeDownloadedMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index 6d818bcd9..72563bb38 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -72,7 +72,7 @@ internal fun MapAreaDetailsBottomSheet( size: Int, isAvailableToDownload: Boolean, isDeletable: Boolean, - onStartDownload: () -> Unit, + onStartDownload: () -> Unit = {}, onDeleteDownload: () -> Unit ) { if (showSheet) { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 18bb56aef..ca56e51b9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -94,12 +94,17 @@ internal object OfflineURLs { internal fun prePlannedDirectoryPath( context: Context, portalItemID: String, - preplannedMapAreaID: String? = null + preplannedMapAreaID: String? = null, + makeDirsEnabled: Boolean = true ): String { val itemDir = File(portalItemDirectoryPath(context, portalItemID)) - val preplannedDir = File(itemDir, preplannedMapAreas).makeDirectoryIfItDoesNotExist() + val preplannedDir = File(itemDir, preplannedMapAreas).apply { + if (makeDirsEnabled) makeDirectoryIfItDoesNotExist() + } return if (preplannedMapAreaID != null) { - val areaDir = File(preplannedDir, preplannedMapAreaID).makeDirectoryIfItDoesNotExist() + val areaDir = File(preplannedDir, preplannedMapAreaID).apply { + if (makeDirsEnabled) makeDirectoryIfItDoesNotExist() + } areaDir.absolutePath } else { preplannedDir.absolutePath From 9b1ee10fbd908fc8dd725680c63c9973d5669597 Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Thu, 3 Jul 2025 15:36:04 -0700 Subject: [PATCH 119/132] remove unnecessary check --- .../java/com/arcgismaps/toolkit/offline/OfflineMapState.kt | 6 ++---- .../toolkit/offline/internal/utils/NetworkUtils.kt | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 74dea9e53..e508ac8a0 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -119,8 +119,7 @@ public class OfflineMapState( OfflineRepository.refreshOfflineMapInfos(context) // load the map, and ignore network error if device is offline arcGISMap.retryLoad().getOrElse { error -> - // check if the error is due to network connection - if (!isDeviceOffline || error.message?.contains("Unable to resolve host") == false) { + if (!isDeviceOffline) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error @@ -133,8 +132,7 @@ public class OfflineMapState( // load the task, and ignore network error if device is offline offlineMapTask.retryLoad().getOrElse { error -> - // check if the error is not due to network connection - if (!isDeviceOffline || error.message?.contains("Unable to resolve host") == false) { + if (!isDeviceOffline) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt index 6ea76a13b..6e61125ee 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt @@ -109,8 +109,7 @@ private fun getCurrentConnectivityState( ): NetworkConnectionState { val connected = connectivityManager.allNetworks.any { network -> connectivityManager.getNetworkCapabilities(network) - ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - ?: false + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true } return if (connected) NetworkConnectionState.Available else NetworkConnectionState.Unavailable From 45fcd5994d344eda677a5284c822577bd43e1432 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 3 Jul 2025 15:50:20 -0700 Subject: [PATCH 120/132] Added tonal button --- .../offline/ondemand/OnDemandMapAreaSelector.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index d4913c167..cade578fe 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Create import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -179,11 +180,16 @@ private fun OnDemandMapAreaSelectorOptions( style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Center) ) - IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.CenterEnd)) { + FilledTonalIconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp) + ) { Icon(imageVector = Icons.Default.Close, contentDescription = "Close icon") } + HorizontalDivider(Modifier.align(Alignment.BottomCenter)) } - HorizontalDivider() Text(text = stringResource(R.string.pan_and_zoom_text), style = MaterialTheme.typography.labelSmall) MapViewWithAreaSelector( modifier = Modifier.weight(1f), From 0737b5c80b85ff18c634c5fa48afac525ddb87b7 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 3 Jul 2025 15:51:49 -0700 Subject: [PATCH 121/132] Add refresh button to experience app --- .../screens/browse/OnDeviceMapInfo.kt | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt index 3368e5e28..937224013 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt @@ -21,10 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -34,7 +36,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.arcgismaps.toolkit.offline.OfflineMapInfo @@ -51,6 +53,34 @@ fun OnDeviceMapInfo( modifier = Modifier.animateContentSize(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + offlineMapInfos.forEach { offlineMapInfo -> + item { + OfflineMapInfoCard(info = offlineMapInfo, onOpen = { + onClick.invoke(offlineMapInfo.id) + }, onDelete = { + OfflineRepository.removeDownloadsForWebmap(context, offlineMapInfo) + }) + } + } + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Offline map infos will be displayed here when downloads have completed.", + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + } + item { + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + onClick = { OfflineRepository.refreshOfflineMapInfos(context) }) { + Icon(Icons.Default.Refresh, null) + Spacer(Modifier.width(4.dp)) + Text("Refresh: Offline map infos") + } + } item { Button( modifier = Modifier @@ -58,18 +88,11 @@ fun OnDeviceMapInfo( .padding(horizontal = 32.dp), enabled = offlineMapInfos.isNotEmpty(), onClick = { OfflineRepository.removeAllDownloads(context) }) { + Icon(Icons.Default.Delete, null) + Spacer(Modifier.width(4.dp)) Text("Remove all downloads") } } - offlineMapInfos.forEach { offlineMapInfo -> - item { - OfflineMapInfoCard(info = offlineMapInfo, onOpen = { - onClick.invoke(offlineMapInfo.id) - }, onDelete = { - OfflineRepository.removeDownloadsForWebmap(context, offlineMapInfo) - }) - } - } } } @@ -100,8 +123,8 @@ fun OfflineMapInfoCard( contentDescription = "Map thumbnail", contentScale = ContentScale.Crop, modifier = Modifier - .size(75.dp) - .clip(RoundedCornerShape(4.dp)) + .width(100.dp) + .clip(RoundedCornerShape(16.dp)) ) Spacer(Modifier.width(8.dp)) From 8923393c89584b5756c259ee816914782367deb8 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 3 Jul 2025 15:58:36 -0700 Subject: [PATCH 122/132] optimize imports --- .../java/com/arcgismaps/toolkit/offline/OfflineRepository.kt | 5 +---- .../com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt | 4 ---- .../toolkit/offline/ondemand/OnDemandMapAreaSelector.kt | 1 - .../toolkit/offline/ondemand/OnDemandMapAreasState.kt | 2 +- .../toolkit/offline/preplanned/PreplannedMapAreasState.kt | 2 +- 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index a8bc89fe3..c6a6d526c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -353,10 +353,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - internal fun deleteContentsForDirectory( - context: Context, - offlineMapDirectoryPath: String - ): Boolean { + internal fun deleteContentsForDirectory(offlineMapDirectoryPath: String): Boolean { return File(offlineMapDirectoryPath).deleteRecursively() } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt index 4c007ceba..1788e1acf 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -33,10 +33,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.DownloadForOffline -import androidx.compose.material.icons.rounded.FileDownload import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index cade578fe..e97af84fa 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -45,7 +45,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index aaefe803d..fbc396383 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -250,7 +250,7 @@ internal class OnDemandMapAreasState( * @since 200.8.0 */ internal fun removeDownloadedMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { - if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { + if (OfflineRepository.deleteContentsForDirectory(mobileMapPackage.path)) { Log.d(TAG, "Deleted on-demand map area: ${mobileMapPackage.path}") // Reset the status to reflect the deletion _status = OnDemandStatus.NotLoaded diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 0ec20bf75..2d5f8c8cd 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -262,7 +262,7 @@ internal class PreplannedMapAreaState( * @since 200.8.0 */ internal fun removeDownloadedMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { - if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { + if (OfflineRepository.deleteContentsForDirectory(mobileMapPackage.path)) { Log.d(TAG, "Deleted preplanned map area: ${mobileMapPackage.path}") // Reset the status to reflect the deletion _status = PreplannedStatus.NotLoaded From 8f4859054dacb8694d3dcda9d78a61bcbf759a07 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Thu, 3 Jul 2025 16:21:22 -0700 Subject: [PATCH 123/132] Update preplanned to convert description htmlToPlainText --- .../toolkit/offline/preplanned/PreplannedMapAreas.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index 5ce46e399..9323819f1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -62,6 +62,7 @@ import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton +import com.arcgismaps.toolkit.offline.internal.utils.htmlToPlainText import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState @@ -179,7 +180,7 @@ internal fun PreplannedMapAreas( ) // Display the description with a maximum of two lines Text( - text = state.description, + text = htmlToPlainText(state.description), style = MaterialTheme.typography.bodySmall, maxLines = 2, // Restrict to two lines overflow = TextOverflow.Ellipsis // Add ellipses if the text overflows From e7db8035d68e0334b40a2ab7f64a0ac10801874e Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Mon, 7 Jul 2025 15:11:36 -0700 Subject: [PATCH 124/132] update check to determine if the device is offline --- .../java/com/arcgismaps/toolkit/offline/OfflineMapState.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 4b49d6827..c25d7c7c5 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -41,6 +41,7 @@ import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException import java.io.File +import java.io.IOException /** * Represents the state of the offline map. @@ -130,7 +131,7 @@ public class OfflineMapState( // load the map, and ignore network error if device is offline arcGISMap.retryLoad().getOrElse { error -> // check if the error is due to network connection - if (error.message?.contains("Unable to resolve host") == true) { + if (error is IOException) { // enable offline only mode isShowingOnlyOfflineModels = true } else { @@ -147,7 +148,7 @@ public class OfflineMapState( // load the task, and ignore network error if device is offline offlineMapTask.retryLoad().getOrElse { error -> // check if the error is not due to network connection - if (error.message?.contains("Unable to resolve host") == false) { + if (error is IOException) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error From 495724a8cf336f079b2c9f7942f6bef25318624b Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 7 Jul 2025 15:11:54 -0700 Subject: [PATCH 125/132] Add bug fix --- .../com/arcgismaps/toolkit/offline/OfflineMapState.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 4b49d6827..dd9c0f862 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.LocalItem import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask @@ -291,7 +292,9 @@ public class OfflineMapState( val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } } - val item = mmpk.item ?: return null + val item = (mmpk.item as? LocalItem)?.apply { + itemId = portalItem.itemId + } ?: return null val preplannedMapAreaState = PreplannedMapAreaState( context = context, @@ -336,7 +339,9 @@ public class OfflineMapState( val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } } - val item = mmpk.item ?: return null + val item = (mmpk.item as? LocalItem)?.apply { + itemId = portalItem.itemId + } ?: return null val onDemandMapAreasState = OnDemandMapAreasState( context = context, From 8f8cab16e38f4fe1a9f5142474d168e26b1d37d5 Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Mon, 7 Jul 2025 15:53:18 -0700 Subject: [PATCH 126/132] update logic --- .../main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index c25d7c7c5..9ab0d665e 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -148,7 +148,7 @@ public class OfflineMapState( // load the task, and ignore network error if device is offline offlineMapTask.retryLoad().getOrElse { error -> // check if the error is not due to network connection - if (error is IOException) { + if (error !is IOException) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error From 4586d3bc164e4dc5cd1cfe69f6ea1a1a1e404e7c Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Mon, 7 Jul 2025 16:11:10 -0700 Subject: [PATCH 127/132] Revert "Merge branch 'puneet/6029_ConnectivityManager' into shubham/full-oma-experience" This reverts commit 34ea5dba1ca9a46557da1f2bada37c5ff7134d1f, reversing changes made to f462d98d35fa3f8b89d41c01ee9f9b5c6ce8916f. --- toolkit/offline/src/main/AndroidManifest.xml | 1 - .../toolkit/offline/OfflineMapAreas.kt | 16 +-- .../toolkit/offline/OfflineMapState.kt | 27 +++- .../offline/internal/utils/NetworkUtils.kt | 127 ------------------ 4 files changed, 24 insertions(+), 147 deletions(-) delete mode 100644 toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt diff --git a/toolkit/offline/src/main/AndroidManifest.xml b/toolkit/offline/src/main/AndroidManifest.xml index dfe8ef42e..5a136f25e 100644 --- a/toolkit/offline/src/main/AndroidManifest.xml +++ b/toolkit/offline/src/main/AndroidManifest.xml @@ -17,7 +17,6 @@ --> - = _initializationStatus + /** + * A Boolean value indicating if only offline models are being shown. + * + * @since 200.8.0 + */ + internal var isShowingOnlyOfflineModels by mutableStateOf(false) + private set + /** * A Boolean value indicating whether the web map is offline disabled. * @@ -111,16 +119,22 @@ public class OfflineMapState( * @return the [Result] indicating if the initialization was successful or not * @since 200.8.0 */ - internal suspend fun initialize(context: Context, isDeviceOffline: Boolean): Result = runCatchingCancellable { + internal suspend fun initialize(context: Context): Result = runCatchingCancellable { if (_initializationStatus.value is InitializationStatus.Initialized) { return Result.success(Unit) } _initializationStatus.value = InitializationStatus.Initializing // initialize the offline repository OfflineRepository.refreshOfflineMapInfos(context) + // reset to check if map has offline enabled + isShowingOnlyOfflineModels = false // load the map, and ignore network error if device is offline arcGISMap.retryLoad().getOrElse { error -> - if (!isDeviceOffline) { + // check if the error is due to network connection + if (error.message?.contains("Unable to resolve host") == true) { + // enable offline only mode + isShowingOnlyOfflineModels = true + } else { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error @@ -133,7 +147,8 @@ public class OfflineMapState( // load the task, and ignore network error if device is offline offlineMapTask.retryLoad().getOrElse { error -> - if (!isDeviceOffline) { + // check if the error is not due to network connection + if (error.message?.contains("Unable to resolve host") == false) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error @@ -145,7 +160,7 @@ public class OfflineMapState( (arcGISMap.loadStatus.value == LoadStatus.Loaded) && (arcGISMap.offlineSettings == null) // load the preplanned map area states - loadPreplannedMapAreas(context, isDeviceOffline) + loadPreplannedMapAreas(context) // check if preplanned for loaded if (_mode != OfflineMapMode.Preplanned || _mode == OfflineMapMode.Unknown) { @@ -163,7 +178,7 @@ public class OfflineMapState( * * @since 200.8.0 */ - private suspend fun loadPreplannedMapAreas(context: Context, isDeviceOffline: Boolean) { + private suspend fun loadPreplannedMapAreas(context: Context) { _preplannedMapAreaStates.clear() val preplannedMapAreas = mutableListOf() try { @@ -174,7 +189,7 @@ public class OfflineMapState( // an exception will be thrown in offline mode preplannedMapAreas.clear() } - if (isDeviceOffline || preplannedMapAreas.isEmpty()) { + if (isShowingOnlyOfflineModels || preplannedMapAreas.isEmpty()) { loadOfflinePreplannedMapAreas(context = context) } else { _mode = OfflineMapMode.Preplanned diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt deleted file mode 100644 index 6e61125ee..000000000 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/NetworkUtils.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * - * Copyright 2025 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.offline.internal.utils - -import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.produceState -import androidx.compose.ui.platform.LocalContext -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import androidx.compose.runtime.State - -@Composable -internal fun networkConnectivityState(): State { - val context = LocalContext.current - - // Creates a State with current connectivity state as initial value - return produceState(initialValue = context.currentConnectivityState) { - // In a coroutine, can make suspend calls - context.observeConnectivityAsFlow().collect { value = it } - } -} - -internal fun Context.observeConnectivityAsFlow() = callbackFlow { - val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val callback = NetworkCallback(connectivityManager) { networkConnectionState -> trySend(networkConnectionState) } - - val networkRequest = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - - connectivityManager.registerNetworkCallback(networkRequest, callback) - - // Set current state - val currentState = getCurrentConnectivityState(connectivityManager) - trySend(currentState) - - // Remove callback when not used - awaitClose { - // Remove listeners - connectivityManager.unregisterNetworkCallback(callback) - } -} - -internal fun NetworkCallback( - connectivityManager: ConnectivityManager, - callback: (NetworkConnectionState) -> Unit -): ConnectivityManager.NetworkCallback { - return object : ConnectivityManager.NetworkCallback() { - /** - * This callback is triggered when a network is available. - * It indicates that the device has internet connectivity. - */ - override fun onAvailable(network: Network) { - callback(NetworkConnectionState.Available) - } - - /** - * This callback is triggered when a network is temporarily unavailable. - * It does not necessarily mean that there is no internet connectivity, - * but rather that the network is in a transient state (e.g., switching networks). - */ - override fun onLost(network: Network) { - // This callback is triggered when any previously available network is lost, - // not just when all internet connectivity is lost. If the device switches - // between networks (e.g., from Wi-Fi to mobile data), onLost is called for - // the old network, even if another network is still available. - val state = getCurrentConnectivityState(connectivityManager) - callback(state) - } - } -} - -/** - * Network utility to get current state of internet connection - */ -internal val Context.currentConnectivityState: NetworkConnectionState - get() { - val connectivityManager = - getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return getCurrentConnectivityState(connectivityManager) - } - -/** - * Helper function to determine the current connectivity state. - */ -private fun getCurrentConnectivityState( - connectivityManager: ConnectivityManager -): NetworkConnectionState { - val connected = connectivityManager.allNetworks.any { network -> - connectivityManager.getNetworkCapabilities(network) - ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - } - - return if (connected) NetworkConnectionState.Available else NetworkConnectionState.Unavailable -} - -/** - * Represents the state of network connectivity. - * - * - [Available]: Indicates that the device has internet connectivity. - * - [Unavailable]: Indicates that the device does not have internet connectivity. - */ -internal sealed class NetworkConnectionState { - object Available : NetworkConnectionState() - object Unavailable : NetworkConnectionState() -} From d6f2411812e30c7a82e914050d1ec3da6127300f Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Tue, 8 Jul 2025 11:46:08 -0700 Subject: [PATCH 128/132] Offline Map Areas: Cleanup changes. (#913) --- .../offlinemapareasapp/screens/MainScreen.kt | 4 --- .../screens/OfflineViewModel.kt | 10 +++--- .../toolkit/offline/OfflineMapAreas.kt | 3 +- .../toolkit/offline/OfflineMapState.kt | 5 +-- .../toolkit/offline/OfflineRepository.kt | 5 +-- .../toolkit/offline/internal/utils/Buttons.kt | 8 ++--- .../ondemand/OnDemandMapAreaSelector.kt | 11 +++++-- .../offline/ondemand/OnDemandMapAreas.kt | 7 ---- .../offline/ondemand/OnDemandMapAreasState.kt | 32 ++----------------- .../offline/preplanned/PreplannedMapAreas.kt | 11 +++++-- .../preplanned/PreplannedMapAreasState.kt | 4 +-- .../offline/ui/MapAreaDetailsScreen.kt | 2 +- .../offline/workmanager/OfflineURLs.kt | 11 +++++-- .../src/main/res/drawable/download_24px.xml | 9 ++++++ 14 files changed, 51 insertions(+), 71 deletions(-) create mode 100644 toolkit/offline/src/main/res/drawable/download_24px.xml diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt index 31f60cd83..2733fbb0d 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt @@ -19,8 +19,6 @@ package com.arcgismaps.toolkit.offlinemapareasapp.screens import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring.StiffnessMediumLow -import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -64,7 +62,6 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { // Radio options val options = listOf("Go Online", "Offline Maps") - var selectedOption by remember { mutableStateOf(options[0]) } var expanded by remember { mutableStateOf(false) } BottomSheetScaffold( @@ -97,7 +94,6 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { DropdownMenuItem( text = { Text(option) }, onClick = { - selectedOption = option expanded = false if (option == "Go Online") { viewModel.selectedMap.value = null diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt index f20185aab..030f90bf8 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/OfflineViewModel.kt @@ -29,14 +29,12 @@ import kotlinx.coroutines.launch class OfflineViewModel : ViewModel() { - // TODO: Update to use napervilleWaterNetwork and remove USBreweriesforOfflineTesting - // private val napervilleWaterNetwork = "acc027394bc84c2fb04d1ed317aac674" - private val USBreweriesforOfflineTesting = "3da658f2492f4cfd8494970ef489d2c5" + private val napervilleWaterNetwork = "acc027394bc84c2fb04d1ed317aac674" private val onlineMap = ArcGISMap( - PortalItem( - Portal.arcGISOnline(connection = Portal.Connection.Anonymous), - USBreweriesforOfflineTesting + item = PortalItem( + portal = Portal.arcGISOnline(connection = Portal.Connection.Anonymous), + itemId = napervilleWaterNetwork ) ) val selectedMap = mutableStateOf(null) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt index 423190ca3..1d19c7e9b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapAreas.kt @@ -143,7 +143,8 @@ internal fun PreplannedLayoutContainer( if (preplannedMapAreaStates.isNotEmpty()) { PreplannedMapAreas( preplannedMapAreaStates = preplannedMapAreaStates, - onDownloadDeleted = { if (isShowingOnlyOfflineModels) onDownloadDeleted(it) }, + isShowingOnlyOfflineModels = isShowingOnlyOfflineModels, + onDownloadDeleted = onDownloadDeleted, modifier = modifier ) } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 4b49d6827..c4d9a83b0 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -184,7 +184,8 @@ public class OfflineMapState( preplannedMapAreas.addAll( elements = offlineMapTask.getPreplannedMapAreas().getOrNull() ?: emptyList() ) - } catch (e: Exception) { + } catch (_: Exception) { + // an exception will be thrown in offline mode preplannedMapAreas.clear() } if (isShowingOnlyOfflineModels || preplannedMapAreas.isEmpty()) { @@ -230,7 +231,7 @@ public class OfflineMapState( */ private suspend fun loadOfflinePreplannedMapAreas(context: Context) { val preplannedDirectory = File( - OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId) + OfflineURLs.prePlannedDirectoryPath(context, portalItem.itemId,null,false) ) val preplannedMapAreaItemIds = preplannedDirectory.listFiles()?.map { it.name.toString() } ?: emptyList() diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt index a8bc89fe3..c6a6d526c 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineRepository.kt @@ -353,10 +353,7 @@ public object OfflineRepository { * * @since 200.8.0 */ - internal fun deleteContentsForDirectory( - context: Context, - offlineMapDirectoryPath: String - ): Boolean { + internal fun deleteContentsForDirectory(offlineMapDirectoryPath: String): Boolean { return File(offlineMapDirectoryPath).deleteRecursively() } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt index 86dd0df7c..1788e1acf 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/internal/utils/Buttons.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -33,16 +32,13 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -50,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -62,8 +59,7 @@ internal fun DownloadButton(onClick: () -> Unit) { modifier = Modifier.size(30.dp), onClick = onClick ) { - Icon( - imageVector = Icons.Filled.Download, + Icon(painter = painterResource(R.drawable.download_24px), contentDescription = stringResource(R.string.download), tint = MaterialTheme.colorScheme.primary, ) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt index d4913c167..e97af84fa 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreaSelector.kt @@ -42,9 +42,9 @@ import androidx.compose.material.icons.filled.Create import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -179,11 +179,16 @@ private fun OnDemandMapAreaSelectorOptions( style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Center) ) - IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.CenterEnd)) { + FilledTonalIconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp) + ) { Icon(imageVector = Icons.Default.Close, contentDescription = "Close icon") } + HorizontalDivider(Modifier.align(Alignment.BottomCenter)) } - HorizontalDivider() Text(text = stringResource(R.string.pan_and_zoom_text), style = MaterialTheme.typography.labelSmall) MapViewWithAreaSelector( modifier = Modifier.weight(1f), diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt index 87b412c6c..b5f9b9848 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreas.kt @@ -105,10 +105,6 @@ internal fun OnDemandMapAreas( description = null, size = selectedOnDemandMapAreaState.directorySize, isAvailableToDownload = selectedOnDemandMapAreaState.status.allowsDownload, - onStartDownload = { - selectedOnDemandMapAreaState.downloadOnDemandMapArea() - onHideSheet = true - }, isDeletable = selectedOnDemandMapAreaState.status.isDownloaded && !selectedOnDemandMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedOnDemandMapAreaState.removeDownloadedMapArea { !onDemandMapAreasStates.any { it.status.isDownloaded } } @@ -247,8 +243,5 @@ private fun getOnDemandMapAreaStatusString(context: Context, status: OnDemandSta OnDemandStatus.Downloaded -> getString(context, R.string.downloaded) OnDemandStatus.Downloading -> getString(context, R.string.downloading) OnDemandStatus.DownloadCancelled -> getString(context, R.string.cancelled) - OnDemandStatus.PackageFailure -> getString(context, R.string.packaging_failed) - OnDemandStatus.Packaged -> getString(context, R.string.ready_to_download) - OnDemandStatus.Packaging -> getString(context, R.string.packaging) } } diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt index b60609ca9..fbc396383 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ondemand/OnDemandMapAreasState.kt @@ -250,7 +250,7 @@ internal class OnDemandMapAreasState( * @since 200.8.0 */ internal fun removeDownloadedMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { - if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { + if (OfflineRepository.deleteContentsForDirectory(mobileMapPackage.path)) { Log.d(TAG, "Deleted on-demand map area: ${mobileMapPackage.path}") // Reset the status to reflect the deletion _status = OnDemandStatus.NotLoaded @@ -365,8 +365,6 @@ internal class OnDemandMapAreasState( * * @since 200.8.0 */ -// TODO: Refine status as not all the status values are being used in onDemand -// compared to Preplanned in Swift implementation. internal sealed class OnDemandStatus { /** @@ -384,21 +382,6 @@ internal sealed class OnDemandStatus { */ data class LoadFailure(val error: Throwable) : OnDemandStatus() - /** - * On-Demand map area is packaging. - */ - data object Packaging : OnDemandStatus() - - /** - * On-Demand map area is packaged and ready for download. - */ - data object Packaged : OnDemandStatus() - - /** - * On-Demand map area packaging failed. - */ - data object PackageFailure : OnDemandStatus() - /** * On-Demand map area is being downloaded. */ @@ -424,22 +407,13 @@ internal sealed class OnDemandStatus { */ data class MmpkLoadFailure(val error: Throwable) : OnDemandStatus() - /** - * Indicates whether the model can load the on-demand map area. - */ - val canLoadOnDemandMapArea: Boolean - get() = when (this) { - is NotLoaded, is LoadFailure, is PackageFailure -> true - is Loading, is Packaging, is Packaged, is Downloading, is DownloadCancelled, is Downloaded, is MmpkLoadFailure, is DownloadFailure -> false - } - /** * Indicates if download is allowed for this status. */ val allowsDownload: Boolean get() = when (this) { - is Packaged -> true - is NotLoaded, is Loading, is LoadFailure, is Packaging, is PackageFailure, is Downloading, is DownloadCancelled, is DownloadFailure, is Downloaded, is MmpkLoadFailure -> false + is NotLoaded -> true + is Loading, is LoadFailure, is Downloading, is DownloadCancelled, is DownloadFailure, is Downloaded, is MmpkLoadFailure -> false } /** diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt index baa077b25..9323819f1 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreas.kt @@ -62,6 +62,7 @@ import com.arcgismaps.toolkit.offline.R import com.arcgismaps.toolkit.offline.internal.utils.CancelDownloadButtonWithProgressIndicator import com.arcgismaps.toolkit.offline.internal.utils.DownloadButton import com.arcgismaps.toolkit.offline.internal.utils.OpenButton +import com.arcgismaps.toolkit.offline.internal.utils.htmlToPlainText import com.arcgismaps.toolkit.offline.ui.MapAreaDetailsBottomSheet import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState @@ -73,6 +74,7 @@ import com.arcgismaps.toolkit.offline.ui.material3.rememberModalBottomSheetState @Composable internal fun PreplannedMapAreas( preplannedMapAreaStates: List, + isShowingOnlyOfflineModels: Boolean, onDownloadDeleted: (PreplannedMapAreaState) -> Unit, modifier: Modifier ) { @@ -112,8 +114,11 @@ internal fun PreplannedMapAreas( isDeletable = selectedPreplannedMapAreaState.status.isDownloaded && !selectedPreplannedMapAreaState.isSelectedToOpen, onDeleteDownload = { selectedPreplannedMapAreaState.removeDownloadedMapArea { !preplannedMapAreaStates.any { it.status.isDownloaded } } - onDownloadDeleted(selectedPreplannedMapAreaState) - onHideSheet = true + // Dismiss the sheet and update the map areas list only in offline mode + if (isShowingOnlyOfflineModels) { + onDownloadDeleted(selectedPreplannedMapAreaState) + onHideSheet = true + } } ) } @@ -175,7 +180,7 @@ internal fun PreplannedMapAreas( ) // Display the description with a maximum of two lines Text( - text = state.description, + text = htmlToPlainText(state.description), style = MaterialTheme.typography.bodySmall, maxLines = 2, // Restrict to two lines overflow = TextOverflow.Ellipsis // Add ellipses if the text overflows diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt index 914eff97c..2d5f8c8cd 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/preplanned/PreplannedMapAreasState.kt @@ -116,7 +116,7 @@ internal class PreplannedMapAreaState( ?.onSuccess { _status = try { PreplannedStatus.fromPackagingStatus(preplannedMapArea.packagingStatus) - } catch (illegalStateException: IllegalStateException) { + } catch (_: IllegalStateException) { // Note: Packaging status is `Unknown` for compatibility with legacy webmaps // that have incomplete metadata. We throw an illegalStateException when Package // Status is unknown. We can safely assume that the preplanned map area is packaged. @@ -262,7 +262,7 @@ internal class PreplannedMapAreaState( * @since 200.8.0 */ internal fun removeDownloadedMapArea(shouldRemoveOfflineMapInfo: () -> Boolean) { - if (OfflineRepository.deleteContentsForDirectory(context, mobileMapPackage.path)) { + if (OfflineRepository.deleteContentsForDirectory(mobileMapPackage.path)) { Log.d(TAG, "Deleted preplanned map area: ${mobileMapPackage.path}") // Reset the status to reflect the deletion _status = PreplannedStatus.NotLoaded diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt index 6d818bcd9..72563bb38 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/ui/MapAreaDetailsScreen.kt @@ -72,7 +72,7 @@ internal fun MapAreaDetailsBottomSheet( size: Int, isAvailableToDownload: Boolean, isDeletable: Boolean, - onStartDownload: () -> Unit, + onStartDownload: () -> Unit = {}, onDeleteDownload: () -> Unit ) { if (showSheet) { diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt index 18bb56aef..ca56e51b9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/workmanager/OfflineURLs.kt @@ -94,12 +94,17 @@ internal object OfflineURLs { internal fun prePlannedDirectoryPath( context: Context, portalItemID: String, - preplannedMapAreaID: String? = null + preplannedMapAreaID: String? = null, + makeDirsEnabled: Boolean = true ): String { val itemDir = File(portalItemDirectoryPath(context, portalItemID)) - val preplannedDir = File(itemDir, preplannedMapAreas).makeDirectoryIfItDoesNotExist() + val preplannedDir = File(itemDir, preplannedMapAreas).apply { + if (makeDirsEnabled) makeDirectoryIfItDoesNotExist() + } return if (preplannedMapAreaID != null) { - val areaDir = File(preplannedDir, preplannedMapAreaID).makeDirectoryIfItDoesNotExist() + val areaDir = File(preplannedDir, preplannedMapAreaID).apply { + if (makeDirsEnabled) makeDirectoryIfItDoesNotExist() + } areaDir.absolutePath } else { preplannedDir.absolutePath diff --git a/toolkit/offline/src/main/res/drawable/download_24px.xml b/toolkit/offline/src/main/res/drawable/download_24px.xml new file mode 100644 index 000000000..c6a92a8a4 --- /dev/null +++ b/toolkit/offline/src/main/res/drawable/download_24px.xml @@ -0,0 +1,9 @@ + + + From 41ed5709b0276f02211918648fa581207e2550aa Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Tue, 8 Jul 2025 11:46:39 -0700 Subject: [PATCH 129/132] Add bug fix (#924) --- .../com/arcgismaps/toolkit/offline/OfflineMapState.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index c4d9a83b0..7e2f8d90b 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.LocalItem import com.arcgismaps.mapping.MobileMapPackage import com.arcgismaps.mapping.PortalItem import com.arcgismaps.tasks.offlinemaptask.OfflineMapTask @@ -292,7 +293,9 @@ public class OfflineMapState( val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } } - val item = mmpk.item ?: return null + val item = (mmpk.item as? LocalItem)?.apply { + itemId = portalItem.itemId + } ?: return null val preplannedMapAreaState = PreplannedMapAreaState( context = context, @@ -337,7 +340,9 @@ public class OfflineMapState( val mmpk = MobileMapPackage(areaDir.absolutePath).apply { load().getOrElse { return null } } - val item = mmpk.item ?: return null + val item = (mmpk.item as? LocalItem)?.apply { + itemId = portalItem.itemId + } ?: return null val onDemandMapAreasState = OnDemandMapAreasState( context = context, From 39310815c9e3a17cc9b79b1bc069403abc5e3aca Mon Sep 17 00:00:00 2001 From: Puneet Prakash Date: Tue, 8 Jul 2025 15:30:34 -0700 Subject: [PATCH 130/132] update check to determine if the device is offline (#925) * update check to determine if the device is offline * update logic * address code review feedback * update micro app upate offlineMapState --- .../offlinemapareasapp/screens/MainScreen.kt | 1 - .../toolkit/offline/OfflineMapState.kt | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt index 2733fbb0d..d5ca12224 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/MainScreen.kt @@ -96,7 +96,6 @@ fun MainScreen(viewModel: OfflineViewModel = viewModel()) { onClick = { expanded = false if (option == "Go Online") { - viewModel.selectedMap.value = null viewModel.offlineMapState.resetSelectedMapArea() coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() diff --git a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt index 7e2f8d90b..4279e63e9 100644 --- a/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt +++ b/toolkit/offline/src/main/java/com/arcgismaps/toolkit/offline/OfflineMapState.kt @@ -42,6 +42,7 @@ import com.arcgismaps.toolkit.offline.preplanned.PreplannedStatus import com.arcgismaps.toolkit.offline.workmanager.OfflineURLs import kotlinx.coroutines.CancellationException import java.io.File +import java.io.IOException /** * Represents the state of the offline map. @@ -51,7 +52,7 @@ import java.io.File @Stable public class OfflineMapState( private val arcGISMap: ArcGISMap, - private val onSelectionChanged: (ArcGISMap) -> Unit = { } + private val onSelectionChanged: (ArcGISMap?) -> Unit = { } ) { /** * Represents the state of the offline map with a given [OfflineMapInfo]. @@ -60,7 +61,7 @@ public class OfflineMapState( */ public constructor( offlineMapInfo: OfflineMapInfo, - onSelectionChanged: (ArcGISMap) -> Unit = { } + onSelectionChanged: (ArcGISMap?) -> Unit = { } ) : this( arcGISMap = ArcGISMap(offlineMapInfo.portalItemUrl), onSelectionChanged = onSelectionChanged @@ -131,7 +132,7 @@ public class OfflineMapState( // load the map, and ignore network error if device is offline arcGISMap.retryLoad().getOrElse { error -> // check if the error is due to network connection - if (error.message?.contains("Unable to resolve host") == true) { + if (error is IOException) { // enable offline only mode isShowingOnlyOfflineModels = true } else { @@ -148,7 +149,7 @@ public class OfflineMapState( // load the task, and ignore network error if device is offline offlineMapTask.retryLoad().getOrElse { error -> // check if the error is not due to network connection - if (error.message?.contains("Unable to resolve host") == false) { + if (error !is IOException) { // unexpected error, report failed status _initializationStatus.value = InitializationStatus.FailedToInitialize(error) throw error @@ -168,6 +169,8 @@ public class OfflineMapState( if (_mode == OfflineMapMode.Unknown) _mode = OfflineMapMode.OnDemand } + // reset the selected map on initialize + onSelectionChanged(null) _initializationStatus.value = InitializationStatus.Initialized } @@ -181,18 +184,16 @@ public class OfflineMapState( private suspend fun loadPreplannedMapAreas(context: Context) { _preplannedMapAreaStates.clear() val preplannedMapAreas = mutableListOf() - try { - preplannedMapAreas.addAll( - elements = offlineMapTask.getPreplannedMapAreas().getOrNull() ?: emptyList() - ) - } catch (_: Exception) { - // an exception will be thrown in offline mode - preplannedMapAreas.clear() - } - if (isShowingOnlyOfflineModels || preplannedMapAreas.isEmpty()) { + if (isShowingOnlyOfflineModels) { loadOfflinePreplannedMapAreas(context = context) } else { - _mode = OfflineMapMode.Preplanned + try { + preplannedMapAreas.addAll( + elements = offlineMapTask.getPreplannedMapAreas().getOrNull() ?: emptyList() + ) + } catch (e: Exception) { + preplannedMapAreas.clear() + } preplannedMapAreas.let { preplannedMapArea -> preplannedMapArea .sortedBy { it.portalItem.title } @@ -216,6 +217,7 @@ public class OfflineMapState( mobileMapPackagePath = preplannedPath ) } + _mode = OfflineMapMode.Preplanned _preplannedMapAreaStates.add(preplannedMapAreaState) } // restore any running download job state @@ -393,6 +395,7 @@ public class OfflineMapState( public fun resetSelectedMapArea() { _preplannedMapAreaStates.forEach { it.setSelectedToOpen(false) } _onDemandMapAreaStates.forEach { it.setSelectedToOpen(false) } + onSelectionChanged(null) } /** From 17f23945eb463e2dfcd80cf98aa98b67ba0c1c63 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Tue, 8 Jul 2025 18:22:12 -0700 Subject: [PATCH 131/132] demo app ui changes --- .../screens/browse/OnDeviceMapInfo.kt | 5 +- .../screens/map/MapScreen.kt | 100 +++++++----------- .../screens/map/MapViewModel.kt | 1 - 3 files changed, 43 insertions(+), 63 deletions(-) diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt index 937224013..f244c0548 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt @@ -2,8 +2,10 @@ package com.arcgismaps.toolkit.offlinemapareasapp.screens.browse import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.text.Html import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -108,6 +110,7 @@ fun OfflineMapInfoCard( .fillMaxWidth() .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) .clickable { onOpen.invoke() }, contentAlignment = Alignment.Center ) { @@ -141,7 +144,7 @@ fun OfflineMapInfoCard( if (info.description.isNotBlank()) { Spacer(Modifier.height(8.dp)) Text( - text = info.description, + text = Html.fromHtml(info.description, Html.FROM_HTML_MODE_LEGACY).toString(), style = MaterialTheme.typography.bodySmall, maxLines = 4, overflow = TextOverflow.Ellipsis diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt index fd7b633ac..6dd49b063 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapScreen.kt @@ -21,51 +21,73 @@ package com.arcgismaps.toolkit.offlinemapareasapp.screens.map import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.arcgismaps.toolkit.geoviewcompose.MapView import com.arcgismaps.toolkit.offline.OfflineMapAreas -import com.arcgismaps.toolkit.offline.OfflineMapState -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> Unit = {}) { - val scope = rememberCoroutineScope() val options = listOf("Go Online", "Offline Maps") - var selectedOption by remember { mutableStateOf(options[0]) } var isDropdownExpanded by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var isBottomSheetVisible by remember { mutableStateOf(true) } + val state = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.Expanded + ) + ) + var onHideSheet by remember { mutableStateOf(false) } + var onShowSheet by remember { mutableStateOf(true) } - Scaffold( + LaunchedEffect(onHideSheet) { + state.bottomSheetState.partialExpand() + onHideSheet = false + } + LaunchedEffect(onShowSheet) { + state.bottomSheetState.expand() + onShowSheet = false + } + + BottomSheetScaffold( modifier = Modifier.fillMaxSize(), + scaffoldState = state, + sheetContainerColor = MaterialTheme.colorScheme.surface, + sheetPeekHeight = 150.dp, + sheetContent = { + OfflineMapAreas( + offlineMapState = mapViewModel.offlineMapState, + modifier = Modifier + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .animateContentSize() + ) + }, topBar = { TopAppBar( title = { @@ -96,14 +118,13 @@ fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> DropdownMenuItem( text = { Text(option) }, onClick = { - selectedOption = option isDropdownExpanded = false if (option == "Go Online") { mapViewModel.selectedMap.value = null mapViewModel.offlineMapState.resetSelectedMapArea() - isBottomSheetVisible = true + onShowSheet = true } else if (option == "Offline Maps") { - isBottomSheetVisible = true + onShowSheet = true } }, enabled = option == "Offline Maps" || mapViewModel.selectedMap.value != null @@ -119,53 +140,10 @@ fun MapScreen(mapViewModel: MapViewModel = hiltViewModel(), onBackPressed: () -> MapView( arcGISMap = mapViewModel.arcGISMap, mapViewProxy = mapViewModel.proxy, - onDown = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) isBottomSheetVisible = false - } - }, + onDown = { onHideSheet = true }, modifier = Modifier.fillMaxSize() ) - OfflineMapAreasSheet( - modifier = Modifier, - offlineMapState = mapViewModel.offlineMapState, - sheetState = sheetState, - isBottomSheetVisible = isBottomSheetVisible, - onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) isBottomSheetVisible = false - } - } - ) } } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OfflineMapAreasSheet( - modifier: Modifier = Modifier, - offlineMapState: OfflineMapState, - sheetState: SheetState, - isBottomSheetVisible: Boolean, - onDismiss: () -> Unit -) { - if (isBottomSheetVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - scrimColor = Color.Transparent, - containerColor = MaterialTheme.colorScheme.surface - ) { - Box { - OfflineMapAreas( - offlineMapState = offlineMapState, - modifier = modifier - .padding(horizontal = 16.dp) - .animateContentSize() - .align(Alignment.BottomCenter) - ) - } - } - } -} diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt index 46671452d..5ef187147 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/map/MapViewModel.kt @@ -24,7 +24,6 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.SavedStateHandle import com.arcgismaps.mapping.ArcGISMap import com.arcgismaps.mapping.PortalItem -import com.arcgismaps.portal.Portal import com.arcgismaps.toolkit.geoviewcompose.MapViewProxy import com.arcgismaps.toolkit.offline.OfflineMapState import com.arcgismaps.toolkit.offlinemapareasapp.data.PortalItemRepository From ad6314eb05a071aa62cb304fa6434d1ba7adf841 Mon Sep 17 00:00:00 2001 From: Shubham Sharma Date: Wed, 30 Jul 2025 08:49:16 -0700 Subject: [PATCH 132/132] update for 200.8 auth changes --- microapps/OfflineMapAreasApp/app/build.gradle.kts | 1 - .../app/src/main/AndroidManifest.xml | 2 +- .../screens/browse/OnDeviceMapInfo.kt | 2 +- .../screens/login/LoginViewModel.kt | 14 ++++++++------ 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/microapps/OfflineMapAreasApp/app/build.gradle.kts b/microapps/OfflineMapAreasApp/app/build.gradle.kts index 791490e08..d77c65404 100644 --- a/microapps/OfflineMapAreasApp/app/build.gradle.kts +++ b/microapps/OfflineMapAreasApp/app/build.gradle.kts @@ -101,7 +101,6 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.composeCore) implementation(libs.bundles.core) - implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.navigation) implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml b/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml index 7e19c2068..df8305fd5 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml +++ b/microapps/OfflineMapAreasApp/app/src/main/AndroidManifest.xml @@ -46,7 +46,7 @@ diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt index f244c0548..acad59c72 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/browse/OnDeviceMapInfo.kt @@ -60,7 +60,7 @@ fun OnDeviceMapInfo( OfflineMapInfoCard(info = offlineMapInfo, onOpen = { onClick.invoke(offlineMapInfo.id) }, onDelete = { - OfflineRepository.removeDownloadsForWebmap(context, offlineMapInfo) + OfflineRepository.removeDownloads(context, offlineMapInfo) }) } } diff --git a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt index 0ba7e0144..b9faa4ddb 100644 --- a/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt +++ b/microapps/OfflineMapAreasApp/app/src/main/java/com/arcgismaps/toolkit/offlinemapareasapp/screens/login/LoginViewModel.kt @@ -75,14 +75,16 @@ class LoginViewModel @Inject constructor( fun login(url: String = portalSettings.defaultPortalUrl, useOAuth: Boolean) { _loginState.value = LoginState.Loading viewModelScope.launch(Dispatchers.IO) { - authenticatorState.oAuthUserConfiguration = + authenticatorState.oAuthUserConfigurations = if (useOAuth) - OAuthUserConfiguration( - portalUrl = url, - clientId = clientId, - redirectUrl = oAuthRedirectUri, + listOf( + OAuthUserConfiguration( + portalUrl = url, + clientId = clientId, + redirectUrl = oAuthRedirectUri, + ) ) - else null + else listOf() portalSettings.setPortalUrl(url) portalSettings.setPortalConnection(Portal.Connection.Authenticated) val portal = Portal(url, Portal.Connection.Authenticated)