Skip to content

Commit 1c03a40

Browse files
feat(#93): show Android notifications for tasks (#414)
Co-authored-by: Joshua Kuestersteffen <[email protected]>
1 parent 032b571 commit 1c03a40

File tree

21 files changed

+682
-34
lines changed

21 files changed

+682
-34
lines changed

build.gradle

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ buildscript {
99
google()
1010
}
1111
dependencies {
12-
classpath 'com.android.tools.build:gradle:8.4.0'
13-
classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:6.0.8'
12+
classpath 'com.android.tools.build:gradle:8.10.1'
13+
classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:6.1.13'
1414
}
1515
}
1616

@@ -383,7 +383,7 @@ android {
383383
dimension = 'brand'
384384
applicationId = 'org.medicmobile.webapp.mobile.chis_ne'
385385
}
386-
386+
387387
cht_rci {
388388
dimension = 'brand'
389389
applicationId = 'org.medicmobile.webapp.mobile.cht_rci'
@@ -404,7 +404,7 @@ android {
404404
applicationId = 'org.medicmobile.webapp.mobile.moh_mali_chw_training_2'
405405
buildConfigField "boolean", "IS_TRAINING_APP", 'true'
406406
}
407-
407+
408408
moh_kenya_echis {
409409
dimension = 'brand'
410410
applicationId = 'org.medicmobile.webapp.mobile.moh_kenya_echis'
@@ -439,7 +439,7 @@ android {
439439
dimension = 'brand'
440440
applicationId = 'org.medicmobile.webapp.mobile.moh_civ'
441441
}
442-
442+
443443
moh_civ_uat {
444444
dimension = 'brand'
445445
applicationId = 'org.medicmobile.webapp.mobile.moh_civ_uat'
@@ -487,22 +487,26 @@ dependencies {
487487
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.24')
488488
implementation 'androidx.browser:browser:1.8.0'
489489
implementation 'androidx.appcompat:appcompat:1.7.1'
490-
implementation 'androidx.core:core:1.13.1'
491-
implementation 'androidx.activity:activity:1.9.0'
492-
implementation 'androidx.fragment:fragment:1.7.1'
493-
compileOnly 'com.github.spotbugs:spotbugs-annotations:4.8.5'
494-
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
490+
implementation 'androidx.work:work-runtime:2.10.5'
491+
implementation 'androidx.core:core:1.16.0'
492+
implementation 'androidx.activity:activity:1.10.1'
493+
implementation 'androidx.fragment:fragment:1.8.8'
494+
implementation "androidx.datastore:datastore-preferences:1.1.7"
495+
implementation "androidx.datastore:datastore-preferences-rxjava3:1.1.7"
496+
compileOnly 'com.github.spotbugs:spotbugs-annotations:4.9.3'
497+
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
495498
testImplementation 'junit:junit:4.13.2'
496499
testImplementation 'org.mockito:mockito-inline:5.2.0'
497500
testImplementation 'com.google.android:android-test:4.1.1.4'
498501
testImplementation 'org.robolectric:robolectric:4.15.1'
499-
testImplementation 'androidx.test.espresso:espresso-core:3.5.1'
500-
testImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
501-
testImplementation 'androidx.test.ext:junit:1.2.1'
502-
androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
503-
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
504-
androidTestImplementation 'androidx.test:runner:1.5.2'
505-
androidTestImplementation 'androidx.test:rules:1.5.0'
506-
androidTestImplementation 'androidx.test:core:1.5.0'
502+
testImplementation 'androidx.test.espresso:espresso-core:3.7.0'
503+
testImplementation 'androidx.test.espresso:espresso-intents:3.7.0'
504+
testImplementation 'androidx.test.ext:junit:1.3.0'
505+
testImplementation 'androidx.work:work-testing:2.10.3'
506+
androidTestImplementation 'androidx.test.espresso:espresso-web:3.7.0'
507+
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
508+
androidTestImplementation 'androidx.test:runner:1.7.0'
509+
androidTestImplementation 'androidx.test:rules:1.7.0'
510+
androidTestImplementation 'androidx.test:core:1.7.0'
507511
androidTestImplementation 'org.hamcrest:hamcrest-library:2.2'
508512
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
#Wed Oct 08 13:21:42 EAT 2025
12
distributionBase=GRADLE_USER_HOME
23
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
4+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
45
zipStoreBase=GRADLE_USER_HOME
56
zipStorePath=wrapper/dists

src/androidTestMedicmobilegammaDebug/java/org/medicmobile/webapp/mobile/LoginTests.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import static org.hamcrest.Matchers.anything;
2222
import static org.hamcrest.Matchers.containsString;
2323

24+
import android.Manifest;
25+
2426
import android.view.View;
2527
import android.view.ViewGroup;
2628
import android.view.ViewParent;
@@ -31,6 +33,7 @@
3133
import androidx.test.ext.junit.rules.ActivityScenarioRule;
3234
import androidx.test.ext.junit.runners.AndroidJUnit4;
3335
import androidx.test.filters.LargeTest;
36+
import androidx.test.rule.GrantPermissionRule;
3437

3538
import org.hamcrest.Description;
3639
import org.hamcrest.Matcher;
@@ -52,6 +55,11 @@ public class LoginTests {
5255
@Rule
5356
public ActivityScenarioRule<SettingsDialogActivity> mActivityTestRule =
5457
new ActivityScenarioRule<>(SettingsDialogActivity.class);
58+
@Rule
59+
public GrantPermissionRule permissionRule =
60+
GrantPermissionRule.grant(
61+
Manifest.permission.POST_NOTIFICATIONS
62+
);
5563

5664
@Test
5765
public void testLoginScreen() throws Exception {

src/androidTestUnbrandedDebug/java/org/medicmobile/webapp/mobile/SettingsDialogActivityTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@
2727
import static org.hamcrest.Matchers.anything;
2828
import static org.hamcrest.Matchers.containsString;
2929

30+
import android.Manifest;
31+
3032
import androidx.test.espresso.ViewInteraction;
3133
import androidx.test.espresso.web.webdriver.DriverAtoms;
3234
import androidx.test.espresso.web.webdriver.Locator;
3335
import androidx.test.ext.junit.rules.ActivityScenarioRule;
3436
import androidx.test.ext.junit.runners.AndroidJUnit4;
3537
import androidx.test.filters.LargeTest;
38+
import androidx.test.rule.GrantPermissionRule;
3639

3740
import org.junit.FixMethodOrder;
3841
import org.junit.Rule;
@@ -57,6 +60,11 @@ public class SettingsDialogActivityTest {
5760
@Rule
5861
public ActivityScenarioRule<SettingsDialogActivity> mActivityTestRule =
5962
new ActivityScenarioRule<>(SettingsDialogActivity.class);
63+
@Rule
64+
public GrantPermissionRule permissionRule =
65+
GrantPermissionRule.grant(
66+
Manifest.permission.POST_NOTIFICATIONS
67+
);
6068

6169
@Test
6270
public void serverSelectionScreenIsDisplayed() {

src/main/AndroidManifest.xml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<uses-permission android:name="android.permission.VIBRATE"/>
1818
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
1919
<uses-permission android:name="com.android.launcher.permission.UNINSTALL_SHORTCUT"/>
20+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
2021

2122
<!-- READ_EXTERNAL_STORAGE is required if users want to include photos from their phone -->
2223
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
@@ -50,7 +51,8 @@
5051
<action android:name="android.intent.action.VIEW" />
5152
<category android:name="android.intent.category.DEFAULT" />
5253
<category android:name="android.intent.category.BROWSABLE" />
53-
<data android:scheme="@string/scheme" android:host="@string/app_host" android:pathPattern=".*"/>
54+
<data android:scheme="@string/scheme" android:host="@string/app_host" android:pathPattern=".*"
55+
tools:ignore="AppLinkUrlError" />
5456
</intent-filter>
5557
</activity>
5658
<activity android:name="ConnectionErrorActivity"
@@ -92,6 +94,10 @@
9294
android:resource="@xml/file_paths">
9395
</meta-data>
9496
</provider>
97+
<service android:name="androidx.work.impl.background.systemjob.SystemJobService"
98+
android:permission="android.permission.BIND_JOB_SERVICE"
99+
android:exported="true"
100+
tools:targetApi="23" />
95101
</application>
96102

97103
<queries>
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package org.medicmobile.webapp.mobile;
2+
3+
import static org.medicmobile.webapp.mobile.MedicLog.log;
4+
5+
import android.Manifest;
6+
import android.app.Activity;
7+
import android.app.NotificationChannel;
8+
import android.app.NotificationManager;
9+
import android.app.PendingIntent;
10+
import android.content.Context;
11+
import android.content.Intent;
12+
import android.content.pm.PackageManager;
13+
import android.net.Uri;
14+
import android.os.Build;
15+
import android.text.TextUtils;
16+
17+
import androidx.core.app.ActivityCompat;
18+
import androidx.core.app.NotificationCompat;
19+
import androidx.core.content.ContextCompat;
20+
import androidx.work.ExistingPeriodicWorkPolicy;
21+
import androidx.work.PeriodicWorkRequest;
22+
import androidx.work.WorkManager;
23+
24+
import org.json.JSONArray;
25+
import org.json.JSONException;
26+
import org.json.JSONObject;
27+
import org.medicmobile.webapp.mobile.util.AppDataStore;
28+
29+
import java.time.LocalDate;
30+
import java.time.ZoneId;
31+
import java.util.concurrent.TimeUnit;
32+
33+
public class AppNotificationManager {
34+
private static final String CHANNEL_ID = "cht_android_notifications";
35+
private static final String CHANNEL_NAME = "CHT Android Notifications";
36+
public static final int REQUEST_NOTIFICATION_PERMISSION = 1001;
37+
public static final String TASK_NOTIFICATIONS_KEY = "task_notifications";
38+
public static final String TASK_NOTIFICATION_DAY_KEY = "cht_task_notification_day";
39+
public static final String LATEST_NOTIFICATION_TIMESTAMP_KEY = "cht_task_notification_timestamp";
40+
public static final String MAX_NOTIFICATIONS_TO_SHOW_KEY = "cht_max_task_notifications";
41+
42+
private final Context context;
43+
private final NotificationManager manager;
44+
private final String appUrl;
45+
private final AppDataStore appDataStore;
46+
47+
public AppNotificationManager(Context context) {
48+
this.context = context.getApplicationContext();
49+
SettingsStore settings = SettingsStore.in(context);
50+
this.appUrl = settings.getAppUrl();
51+
manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
52+
appDataStore = AppDataStore.getInstance(this.context);
53+
createNotificationChannel();
54+
}
55+
56+
public boolean hasNotificationPermission() {
57+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
58+
return ContextCompat.checkSelfPermission(context,
59+
Manifest.permission.POST_NOTIFICATIONS)
60+
== PackageManager.PERMISSION_GRANTED;
61+
}
62+
//versions below 13
63+
return true;
64+
}
65+
66+
public void requestNotificationPermission(Activity activity) {
67+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNotificationPermission()) {
68+
ActivityCompat.requestPermissions(activity,
69+
new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_NOTIFICATION_PERMISSION);
70+
}
71+
}
72+
73+
public void startNotificationWorker() {
74+
if (hasNotificationPermission()) {
75+
PeriodicWorkRequest request = new PeriodicWorkRequest.Builder(
76+
NotificationWorker.class,
77+
NotificationWorker.WORKER_REPEAT_INTERVAL_MINS,
78+
TimeUnit.MINUTES
79+
)
80+
.addTag(NotificationWorker.NOTIFICATION_WORK_REQUEST_TAG)
81+
.build();
82+
83+
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
84+
NotificationWorker.NOTIFICATION_WORK_NAME,
85+
ExistingPeriodicWorkPolicy.KEEP,
86+
request
87+
);
88+
log(context, "startNotificationWorker() :: Started Notification Work Manager...");
89+
}
90+
}
91+
92+
public void stopNotificationWorker() {
93+
WorkManager.getInstance(context).cancelAllWorkByTag(NotificationWorker.NOTIFICATION_WORK_REQUEST_TAG);
94+
log(context, "stopNotificationWorker() :: Stopped notification work manager");
95+
}
96+
97+
void cancelAllNotifications() {
98+
manager.cancelAll();
99+
}
100+
101+
/**
102+
* @param jsArrayString string notifications sorted by readyAt in descending order
103+
* @throws JSONException throws JSONException
104+
* Method is blocking don't run on UI thread
105+
*/
106+
public void showNotificationsFromJsArray(String jsArrayString) throws JSONException {
107+
JSONArray dataArray = Utils.parseJSArrayData(jsArrayString);
108+
if (dataArray.length() == 0) {
109+
return;
110+
}
111+
showMultipleTaskNotifications(dataArray);
112+
}
113+
114+
private void showMultipleTaskNotifications(JSONArray dataArray) throws JSONException {
115+
Intent intent = new Intent(context, EmbeddedBrowserActivity.class);
116+
intent.setData(Uri.parse(TextUtils.concat(appUrl, "/#/tasks").toString()));
117+
long maxNotifications = appDataStore.getLongBlocking(MAX_NOTIFICATIONS_TO_SHOW_KEY, 8L);
118+
long latestStoredTimestamp = getLatestStoredTimestamp(getStartOfDay());
119+
int counter = 0;
120+
long latestReadyAt = 0;
121+
for (int i = 0; i < dataArray.length(); i++) {
122+
JSONObject notification = dataArray.getJSONObject(i);
123+
long readyAt = notification.getLong("readyAt");
124+
latestReadyAt = Math.max(latestReadyAt, readyAt);
125+
if (counter >= maxNotifications) {
126+
continue;
127+
}
128+
129+
long endDate = notification.getLong("endDate");
130+
long dueDate = notification.getLong("dueDate");
131+
if (isValidNotification(latestStoredTimestamp, readyAt, dueDate, endDate)) {
132+
int notificationId = notification.getString("_id").hashCode();
133+
String contentText = notification.getString("contentText");
134+
String title = notification.getString("title");
135+
showNotification(intent, notificationId, title, contentText);
136+
counter++;
137+
}
138+
}
139+
saveLatestNotificationTimestamp(latestReadyAt);
140+
}
141+
142+
private boolean isValidNotification(long latestStoredTimestamp, long readyAt, long dueDate, long endDate) {
143+
long startOfDay = getStartOfDay();
144+
return readyAt > latestStoredTimestamp && dueDate <= startOfDay && endDate >= startOfDay;
145+
}
146+
147+
public void saveLatestNotificationTimestamp(long value) {
148+
appDataStore.saveLong(LATEST_NOTIFICATION_TIMESTAMP_KEY, value);
149+
}
150+
151+
public long getStartOfDay() {
152+
return LocalDate.now()
153+
.atStartOfDay(ZoneId.systemDefault())
154+
.toInstant().toEpochMilli();
155+
}
156+
157+
private long getLatestStoredTimestamp(long startOfDay) {
158+
if (isNewDay(startOfDay)) {
159+
return 0;
160+
}
161+
return appDataStore.getLongBlocking(LATEST_NOTIFICATION_TIMESTAMP_KEY, 0L);
162+
}
163+
164+
private boolean isNewDay(long startOfDay) {
165+
long storedNotificationDay = appDataStore.getLongBlocking(TASK_NOTIFICATION_DAY_KEY, 0L);
166+
if (getStartOfDay() != storedNotificationDay) {
167+
appDataStore.saveLong(TASK_NOTIFICATION_DAY_KEY, startOfDay);
168+
return true;
169+
}
170+
return false;
171+
}
172+
173+
void showNotification(Intent intent, int id, String title, String contentText) {
174+
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
175+
PendingIntent pendingIntent = PendingIntent.getActivity(
176+
context,
177+
0,
178+
intent,
179+
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
180+
);
181+
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
182+
.setSmallIcon(R.drawable.ic_notification)
183+
.setContentTitle(title)
184+
.setContentText(contentText)
185+
.setAutoCancel(true)
186+
.setContentIntent(pendingIntent)
187+
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
188+
manager.notify(id, builder.build());
189+
}
190+
191+
private void createNotificationChannel() {
192+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
193+
NotificationChannel notificationChannel = new NotificationChannel(
194+
CHANNEL_ID,
195+
CHANNEL_NAME,
196+
NotificationManager.IMPORTANCE_DEFAULT
197+
);
198+
manager.createNotificationChannel(notificationChannel);
199+
}
200+
}
201+
202+
}

0 commit comments

Comments
 (0)