Skip to content

Commit f7449d6

Browse files
authored
Merge develop to master for 7.2.5 release
[mage-1436] Fix "location services" and "data fetching" options not launching their respective activities [mage-1434] 1) Fix "auto" theme not reflecting system settings for light/dark mode 2) Fix app crash when switching between themes [mage-1435] 1) Add logic to request POST_NOTIFICATIONS permission to fix defect with notifications not being shown for Android 13+. 2) Fix defect with notifications not being shown on Android versions below 13. 3) Create first-time launch permissions handling to request both location and notifications permissions. Modify logic to not repeatedly request location permission on each app launch. [mage-1437] 1) Refactor observation and attachment sync worker classes to make the sync logic clearer 2) Remove bitwise operators for the retry determination logic [mage-1438] 1) Fix non-point observations not showing the associated form icon in the observations list 2) Refactor MapAnnotation's factory methods and associated style classes to make the logic clearer
1 parent 909b614 commit f7449d6

36 files changed

+872
-775
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ MAGE client for Android devices.
44

55
## About
66

7-
The **M**obile **A**wareness **G**EOINT **E**nvironment, or MAGE, provides mobile situational awareness capabilities. The MAGE app on your mobile device allows you to create geotagged field reports that contain media such as photos, videos, and voice recordings and share them instantly with who you want. Using the GPS in your mobile device, MAGE can also track users locations in real time. Your locations can be automatically shared with the other members of your team.
7+
MAGE provides mobile situational awareness capabilities. The MAGE app on your mobile device allows you to create geotagged field reports that contain media such as photos, videos, and voice recordings and share them instantly with who you want. Using the GPS in your mobile device, MAGE can also track users locations in real time. Your locations can be automatically shared with the other members of your team.
88

99
The app remains functional if your mobile device loses its network connection, and will upload its local content when a connection is re-established. When disconnected from the network, MAGE will use local data layers to continue to provide relevant GEOINT. Data layers, including map tiles and vector data, can be stored on your mobile device and are available at all times.
1010

mage/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ android {
8989

9090
defaultConfig {
9191
applicationId "mil.nga.giat.mage"
92-
versionCode 1952
93-
versionName '7.2.4'
92+
versionCode 1953
93+
versionName '7.2.5'
9494
minSdkVersion 27
9595
targetSdkVersion 34
9696
multiDexEnabled true

mage/src/main/java/mil/nga/giat/mage/LandingActivity.kt

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
package mil.nga.giat.mage
22

3+
import android.Manifest
34
import android.content.Intent
45
import android.content.pm.PackageManager
56
import android.content.res.Configuration
67
import android.net.Uri
8+
import android.os.Build
79
import android.os.Bundle
810
import android.view.Menu
911
import android.view.MenuItem
1012
import android.view.View
1113
import android.widget.ImageView
1214
import android.widget.TextView
1315
import androidx.activity.result.ActivityResult
14-
import androidx.activity.result.ActivityResultLauncher
1516
import androidx.activity.result.contract.ActivityResultContracts
1617
import androidx.appcompat.app.AppCompatActivity
18+
import androidx.core.app.NotificationManagerCompat
1719
import androidx.core.view.GravityCompat
1820
import androidx.fragment.app.Fragment
1921
import androidx.lifecycle.ViewModelProvider
@@ -41,8 +43,6 @@ import mil.nga.giat.mage.glide.GlideApp
4143
import mil.nga.giat.mage.glide.model.Avatar.Companion.forUser
4244
import mil.nga.giat.mage.help.HelpActivity
4345
import mil.nga.giat.mage.location.LocationAccess
44-
import mil.nga.giat.mage.location.LocationContractResult
45-
import mil.nga.giat.mage.location.LocationPermission
4646
import mil.nga.giat.mage.login.LoginActivity
4747
import mil.nga.giat.mage.map.MapFragment
4848
import mil.nga.giat.mage.map.cache.CacheProvider
@@ -53,6 +53,7 @@ import mil.nga.giat.mage.profile.ProfileActivity
5353
import org.apache.commons.lang3.StringUtils
5454
import java.io.File
5555
import javax.inject.Inject
56+
import androidx.core.content.edit
5657

5758

5859
/**
@@ -82,13 +83,84 @@ class LandingActivity : AppCompatActivity(), NavigationView.OnNavigationItemSele
8283
}
8384
}
8485

85-
private var reportLocationIntent: ActivityResultLauncher<*> = registerForActivityResult(
86-
LocationPermission()
87-
) { (coarseGranted, preciseGranted): LocationContractResult ->
88-
if (preciseGranted || coarseGranted) {
89-
application.startLocationService()
90-
} else {
91-
application.stopLocationService()
86+
//request location and notifications permissions when the app is launched from a fresh install
87+
private fun requestPermissionsOnFirstLaunch() {
88+
if (!PreferenceManager.getDefaultSharedPreferences(this).getBoolean(getString(R.string.havePermissionsBeenPrompted), false)) {
89+
val activityResultLauncher =
90+
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
91+
var coarseOrPreciseLocationGranted = false
92+
var notificationsGranted = false
93+
94+
permissions.entries.forEach {
95+
val permissionName = it.key
96+
val isGranted = it.value
97+
98+
if (isGranted) {
99+
if (permissionName.contentEquals(Manifest.permission.ACCESS_COARSE_LOCATION)) {
100+
coarseOrPreciseLocationGranted = true;
101+
} else if (permissionName.contentEquals(Manifest.permission.ACCESS_FINE_LOCATION)) {
102+
coarseOrPreciseLocationGranted = true;
103+
} else if (permissionName.contentEquals(Manifest.permission.POST_NOTIFICATIONS)) {
104+
notificationsGranted = true;
105+
}
106+
}
107+
}
108+
109+
if (coarseOrPreciseLocationGranted) {
110+
PreferenceManager.getDefaultSharedPreferences(this).edit {
111+
putBoolean(resources.getString(R.string.reportLocationKey), true)
112+
}
113+
}
114+
115+
//update notification preferences for Android versions 13 or above
116+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
117+
if (notificationsGranted) {
118+
//permission granted, update the notifications state to enabled
119+
PreferenceManager.getDefaultSharedPreferences(this).edit {
120+
putBoolean(resources.getString(R.string.notificationsEnabledKey), true)
121+
}
122+
} else {
123+
//permission denied, update the notifications state to disabled
124+
PreferenceManager.getDefaultSharedPreferences(this).edit {
125+
putBoolean(resources.getString(R.string.notificationsEnabledKey), false)
126+
}
127+
}
128+
} else {
129+
//check if notifications are enabled for Android versions below 13
130+
val notificationManager = NotificationManagerCompat.from(this)
131+
if (notificationManager.areNotificationsEnabled()) {
132+
//notifications enabled, update the notifications state to enabled
133+
PreferenceManager.getDefaultSharedPreferences(this).edit {
134+
putBoolean(resources.getString(R.string.notificationsEnabledKey), true)
135+
}
136+
} else {
137+
//notifications not enabled, update the notifications state to disabled
138+
PreferenceManager.getDefaultSharedPreferences(this).edit {
139+
putBoolean(resources.getString(R.string.notificationsEnabledKey), false)
140+
}
141+
}
142+
}
143+
}
144+
145+
//only attempt to request POST_NOTIFICATIONS permission for Android versions 13 or above
146+
val permissionsToRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
147+
arrayOf(
148+
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION,
149+
Manifest.permission.POST_NOTIFICATIONS
150+
)
151+
} else {
152+
arrayOf(
153+
Manifest.permission.ACCESS_FINE_LOCATION,
154+
Manifest.permission.ACCESS_COARSE_LOCATION
155+
)
156+
}
157+
158+
activityResultLauncher.launch(permissionsToRequest)
159+
160+
//update flag so that these permissions are requested once and not on every app launch
161+
PreferenceManager.getDefaultSharedPreferences(this).edit() {
162+
putBoolean(getString(R.string.havePermissionsBeenPrompted), true)
163+
}
92164
}
93165
}
94166

@@ -120,7 +192,9 @@ class LandingActivity : AppCompatActivity(), NavigationView.OnNavigationItemSele
120192
setRecentEvents(event)
121193
}
122194
setSupportActionBar(binding!!.toolbar)
123-
reportLocationIntent.launch(null)
195+
196+
requestPermissionsOnFirstLaunch()
197+
124198
binding!!.toolbar.setNavigationIcon(R.drawable.ic_menu_white_24dp)
125199
binding!!.toolbar.setNavigationOnClickListener {
126200
binding!!.drawerLayout.openDrawer(

mage/src/main/java/mil/nga/giat/mage/LandingViewModel.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class LandingViewModel @Inject constructor(
7474
eventLocalDataSource.getForm(it)
7575
}
7676

77-
val icon = MapAnnotation.fromObservation(
77+
val icon = MapAnnotation.getAnnotationWithStyleFromObservation(
7878
event = event,
7979
formDefinition = formDefinition,
8080
observationForm = observationForm,
@@ -107,7 +107,7 @@ class LandingViewModel @Inject constructor(
107107
id.toString(),
108108
NavigableType.USER,
109109
location.geometry,
110-
MapAnnotation.fromUser(user, location)
110+
MapAnnotation.getAnnotationWithBaseStyleFromUser(user, location)
111111
)
112112
)
113113
}
@@ -117,7 +117,7 @@ class LandingViewModel @Inject constructor(
117117
fun startFeedNavigation(feedId: String, itemId: String) {
118118
viewModelScope.launch {
119119
val itemWithFeed = feedItemDao.item(feedId, itemId).first()
120-
val icon = MapAnnotation.fromFeedItem(itemWithFeed, application)
120+
val icon = MapAnnotation.getAnnotationWithBaseStyleFromFeedItem(itemWithFeed, application)
121121
_navigateTo.postValue(
122122
Navigable(
123123
FeedItemId(itemWithFeed.feed.id, itemWithFeed.item.id),

mage/src/main/java/mil/nga/giat/mage/MageApplication.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import android.content.Intent
88
import android.content.SharedPreferences
99
import android.os.Bundle
1010
import android.util.Log
11-
import androidx.appcompat.app.AppCompatDelegate
1211
import androidx.core.content.ContextCompat
1312
import androidx.hilt.work.HiltWorkerFactory
1413
import androidx.lifecycle.Lifecycle
@@ -45,6 +44,7 @@ import mil.nga.giat.mage.data.datasource.observation.ObservationLocalDataSource
4544
import mil.nga.giat.mage.data.datasource.user.UserLocalDataSource
4645
import mil.nga.giat.mage.di.TokenStatus
4746
import mil.nga.giat.mage.login.ServerUrlActivity
47+
import mil.nga.giat.mage.utils.ThemeUtils
4848
import javax.inject.Inject
4949

5050
@HiltAndroidApp
@@ -103,7 +103,8 @@ class MageApplication : Application(),
103103
resources.getString(R.string.dayNightThemeKey),
104104
resources.getInteger(R.integer.dayNightThemeDefaultValue)
105105
)
106-
AppCompatDelegate.setDefaultNightMode(dayNightTheme)
106+
ThemeUtils.updateUiWithDayNightTheme(dayNightTheme)
107+
107108
val notificationManager: NotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
108109
val channel = NotificationChannel(
109110
MAGE_NOTIFICATION_CHANNEL_ID,

mage/src/main/java/mil/nga/giat/mage/data/repository/observation/ObservationRepository.kt

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
package mil.nga.giat.mage.data.repository.observation
22

33
import android.Manifest
4+
import android.annotation.SuppressLint
45
import android.app.PendingIntent
56
import android.app.PendingIntent.FLAG_IMMUTABLE
67
import android.content.Context
78
import android.content.Intent
89
import android.content.SharedPreferences
910
import android.content.pm.PackageManager
11+
import android.os.Build
1012
import android.util.Log
11-
import androidx.core.app.ActivityCompat
1213
import androidx.core.app.NotificationCompat
1314
import androidx.core.app.NotificationManagerCompat
15+
import androidx.core.content.ContextCompat
1416
import androidx.preference.PreferenceManager
1517
import com.google.gson.JsonObject
1618
import com.google.gson.JsonParser
@@ -46,6 +48,7 @@ import mil.nga.giat.mage.data.datasource.user.UserLocalDataSource
4648
import mil.nga.giat.mage.database.model.observation.ObservationImportant
4749
import mil.nga.giat.mage.sdk.event.IObservationEventListener
4850
import mil.nga.giat.mage.sdk.utils.ISO8601DateFormatFactory
51+
import mil.nga.giat.mage.utils.NotificationUtils
4952
import okhttp3.ResponseBody
5053
import retrofit2.Response
5154
import java.io.IOException
@@ -404,18 +407,20 @@ class ObservationRepository @Inject constructor(
404407
}
405408

406409
if (notify) {
407-
createNotifications(fetched)
410+
createNotificationsIfEligible(fetched)
408411
}
409412
}
410413

411-
private fun createNotifications(observations: Collection<Observation>) {
414+
@SuppressLint("MissingPermission")
415+
private fun createNotificationsIfEligible(observations: Collection<Observation>) {
412416
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
413-
val notificationsEnabled = preferences.getBoolean(context.getString(R.string.notificationsEnabledKey), context.resources.getBoolean(R.bool.notificationsEnabledDefaultValue))
414-
if (observations.isEmpty() || !notificationsEnabled) {
417+
val canSendNotifications = NotificationUtils.canSendNotifications(preferences, context)
418+
419+
//do not create a notification if observations list is empty, the user has disabled notifications within Mage, or the required permission is not granted
420+
if (observations.isEmpty() || !canSendNotifications) {
415421
return
416422
}
417423

418-
val notificationManager = NotificationManagerCompat.from(context)
419424
val groupNotification = NotificationCompat.Builder(context, MageApplication.MAGE_OBSERVATION_NOTIFICATION_CHANNEL_ID)
420425
.setGroupSummary(true)
421426
.setContentTitle("New MAGE Observations")
@@ -424,22 +429,8 @@ class ObservationRepository @Inject constructor(
424429
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
425430
.setGroup(MageApplication.MAGE_OBSERVATION_NOTIFICATION_GROUP)
426431

427-
if (ActivityCompat.checkSelfPermission(
428-
context,
429-
Manifest.permission.POST_NOTIFICATIONS
430-
) != PackageManager.PERMISSION_GRANTED
431-
) {
432-
// TODO: Consider calling
433-
// ActivityCompat#requestPermissions
434-
// here to request the missing permissions, and then overriding
435-
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
436-
// int[] grantResults)
437-
// to handle the case where the user grants the permission. See the documentation
438-
// for ActivityCompat#requestPermissions for more details.
439-
return
440-
} else {
441-
notificationManager.notify(MageApplication.MAGE_OBSERVATION_NOTIFICATION_PREFIX, groupNotification.build())
442-
}
432+
val notificationManager = NotificationManagerCompat.from(context)
433+
notificationManager.notify(MageApplication.MAGE_OBSERVATION_NOTIFICATION_PREFIX, groupNotification.build())
443434

444435
observations.forEach { observation ->
445436
val intent = Intent(context, LandingActivity::class.java)

mage/src/main/java/mil/nga/giat/mage/data/repository/user/UserRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import android.content.pm.PackageManager
66
import android.graphics.Bitmap
77
import android.graphics.BitmapFactory
88
import android.util.Log
9-
import androidx.appcompat.app.AppCompatDelegate
109
import com.google.gson.Gson
1110
import com.google.gson.JsonObject
1211
import com.google.gson.stream.JsonReader
@@ -30,6 +29,7 @@ import mil.nga.giat.mage.sdk.utils.DeviceUuidFactory
3029
import mil.nga.giat.mage.sdk.utils.ISO8601DateFormatFactory
3130
import mil.nga.giat.mage.sdk.utils.MediaUtility
3231
import mil.nga.giat.mage.sdk.utils.PasswordUtility
32+
import mil.nga.giat.mage.utils.ThemeUtils
3333
import okhttp3.MediaType.Companion.toMediaTypeOrNull
3434
import okhttp3.RequestBody
3535
import okhttp3.ResponseBody
@@ -157,7 +157,7 @@ class UserRepository @Inject constructor(
157157
preferenceHelper.initialize(true, R.xml::class.java)
158158

159159
val dayNightTheme = preferences.getInt(application.resources.getString(R.string.dayNightThemeKey), application.resources.getInteger(R.integer.dayNightThemeDefaultValue))
160-
AppCompatDelegate.setDefaultNightMode(dayNightTheme)
160+
ThemeUtils.updateUiWithDayNightTheme(dayNightTheme)
161161
}
162162

163163
roleLocalDataSource.read(userWithRole.role.remoteId)?.let { userWithRole.role.id = it.id }

mage/src/main/java/mil/nga/giat/mage/form/edit/dialog/GeometryFieldDialog.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import mil.nga.giat.mage.coordinate.CoordinateType
5555
import mil.nga.giat.mage.coordinate.DMS
5656
import mil.nga.giat.mage.coordinate.DMSLocation
5757
import mil.nga.giat.mage.databinding.DialogGeometryFieldBinding
58-
import mil.nga.giat.mage.map.annotation.ShapeStyle
58+
import mil.nga.giat.mage.map.annotation.ShapeObservationStyle
5959
import mil.nga.giat.mage.map.hasKinks
6060
import mil.nga.giat.mage.observation.InputFilterDecimal
6161
import mil.nga.giat.mage.observation.ObservationLocation
@@ -257,7 +257,7 @@ class GeometryFieldDialog : DialogFragment(),
257257
mapCoordinateSystem = CoordinateSystem.GARS
258258
}
259259

260-
val style = ShapeStyle(requireContext())
260+
val style = ShapeObservationStyle(null, requireContext())
261261
editMarkerOptions = getEditMarkerOptions()
262262
editPolylineOptions = getEditPolylineOptions(style)
263263
editPolygonOptions = getEditPolygonOptions(style)
@@ -1268,7 +1268,7 @@ class GeometryFieldDialog : DialogFragment(),
12681268
* @param style observation shape style
12691269
* @return edit polyline options
12701270
*/
1271-
private fun getEditPolylineOptions(style: ShapeStyle): PolylineOptions {
1271+
private fun getEditPolylineOptions(style: ShapeObservationStyle): PolylineOptions {
12721272
val polylineOptions = PolylineOptions()
12731273
polylineOptions.width(style.strokeWidth)
12741274
polylineOptions.color(style.strokeColor)
@@ -1281,7 +1281,7 @@ class GeometryFieldDialog : DialogFragment(),
12811281
* @param style observation shape style
12821282
* @return edit polygon options
12831283
*/
1284-
private fun getEditPolygonOptions(style: ShapeStyle): PolygonOptions {
1284+
private fun getEditPolygonOptions(style: ShapeObservationStyle): PolygonOptions {
12851285
val polygonOptions = PolygonOptions()
12861286
polygonOptions.strokeWidth(style.strokeWidth)
12871287
polygonOptions.strokeColor(style.strokeColor)

mage/src/main/java/mil/nga/giat/mage/form/view/MapViewContent.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import mil.nga.giat.mage.form.FormState
2424
import mil.nga.giat.mage.form.field.FieldValue
2525
import mil.nga.giat.mage.glide.target.MarkerTarget
2626
import mil.nga.giat.mage.map.annotation.MapAnnotation
27-
import mil.nga.giat.mage.map.annotation.ShapeStyle
27+
import mil.nga.giat.mage.map.annotation.ShapeObservationStyle
2828
import mil.nga.giat.mage.observation.ObservationLocation
2929
import mil.nga.sf.GeometryType
3030
import mil.nga.sf.util.GeometryUtils
@@ -88,7 +88,7 @@ fun MapViewContent(
8888
if (formState != null) {
8989
Glide.with(context)
9090
.asBitmap()
91-
.load(MapAnnotation.fromObservationProperties(formState.id ?: 0, location.geometry, location.time, location.accuracy, formState.eventId, formState.definition.id, primary, secondary, context))
91+
.load(MapAnnotation.getAnnotationWithBaseStyleFromObservationProperties(formState.id ?: 0, location.geometry, location.time, location.accuracy, formState.eventId, formState.definition.id, primary, secondary, context))
9292
.error(R.drawable.default_marker)
9393
.into(MarkerTarget(context, marker, 32, 32))
9494
}
@@ -104,7 +104,7 @@ fun MapViewContent(
104104
}
105105
} else {
106106
val shape = GoogleMapShapeConverter().toShape(location.geometry).shape
107-
val style = ShapeStyle.fromForm(event, formState, context)
107+
val style = ShapeObservationStyle.getStyleFromForm(event, formState, null, context)
108108

109109
if (shape is PolylineOptions) {
110110
googleMap.addPolyline {

0 commit comments

Comments
 (0)