Skip to content

Commit a656eee

Browse files
authored
Feat/notify on new version (#78)
* recieved broadcast in java file * notifed outside app and completed update process from local signed 1.0.8 to online 1.0.9.1 * refactor: better naming and clear update watcher * working scheduler * production back to 7 days * refactor: moved reuasable methods and provided release notes- worked when tested with ConnectivityReceiver * don't fetch release notes twice * revert: open fix in new pr * feat: enhance notification eligibility checks and handle posting failures * remove tests * fix: right way to check for permission
1 parent 36dd2a0 commit a656eee

11 files changed

Lines changed: 491 additions & 11 deletions

File tree

app_src/android/DEV.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ So if new assets added it doesn't add new files
88
```shell
99
adb shell
1010
run-as org.wally.waller
11-
ls -l files
1211
cd files
12+
ls -l
1313
```
1414
How to see WAKE INTENT
1515
```shell
@@ -129,11 +129,15 @@ python3 -m http.server 8000
129129
keytool -genkey -v -keystore my-release-key.jks -alias key-stuff -keyalg RSA -keysize 2048 -validity 10000 -storepass 12345 -keypass 12345 -dname "CN=Fabian, OU=Mobile, O=FabianCorp, L=New York, ST=NY, C=US"
130130

131131

132+
133+
```shell
134+
sudo apt install zipalign apksigner
132135
buildozer -v android release
133136
zipalign -v -p 4 bin/waller-1.0.6-arm64-v8a_armeabi-v7a-release-unsigned.apk bin/waller-aligned.apk
134137
apksigner sign --ks my-release-key.jks --ks-key-alias key-stuff --ks-pass pass:123456789 --key-pass pass:123456789 --out bin/waller-signed.apk bin/waller-aligned.apk
135138
apksigner verify --verbose bin/waller-signed.apk
136139
adb install -r bin/waller-signed.apk
140+
```
137141

138142
## On GitHub Actions
139143
Create my-release-key.jks Locally by running

app_src/android/p4a/hook.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ def generate_receivers(package_: str = None) -> str:
5050
Receiver(
5151
name="BootReceiver",
5252
actions=["android.intent.action.BOOT_COMPLETED"]
53-
)
53+
),
54+
Receiver(
55+
name="ConnectivityReceiver",
56+
actions=[
57+
"android.net.conn.CONNECTIVITY_CHANGE",
58+
"android.net.wifi.WIFI_STATE_CHANGED",
59+
],
60+
),
5461

5562
]
5663

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.wally.waller;
2+
3+
import android.content.BroadcastReceiver;
4+
import android.content.Context;
5+
import android.content.Intent;
6+
import android.util.Log;
7+
8+
public class ConnectivityReceiver extends BroadcastReceiver {
9+
10+
private static final String TAG = "ConnectivityReceiver";
11+
12+
@Override
13+
public void onReceive(Context context, Intent intent) {
14+
Log.d(TAG, "onReceive called, action=" + intent.getAction());
15+
16+
if (UpdateNotifier.isCooldownActive(context)) {
17+
Log.d(TAG, "Cooldown active, skipping");
18+
return;
19+
}
20+
21+
Log.d(TAG, "Cooldown passed, checking for update in background");
22+
new Thread(() -> UpdateNotifier.checkAndNotify(context)).start();
23+
}
24+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.wally.waller;
2+
3+
import android.content.Context;
4+
import android.util.Log;
5+
6+
import androidx.annotation.NonNull;
7+
import androidx.work.Worker;
8+
import androidx.work.WorkerParameters;
9+
10+
public class UpdateCheckWorker extends Worker {
11+
12+
private static final String TAG = "UpdateCheckWorker";
13+
14+
public UpdateCheckWorker(@NonNull Context context, @NonNull WorkerParameters params) {
15+
super(context, params);
16+
}
17+
18+
@NonNull
19+
@Override
20+
public Result doWork() {
21+
Log.d(TAG, "doWork called");
22+
23+
Context ctx = getApplicationContext();
24+
25+
if (!UpdateNotifier.isNetworkAvailable(ctx)) {
26+
Log.d(TAG, "No network, retrying later");
27+
return Result.retry();
28+
}
29+
30+
if (UpdateNotifier.isCooldownActive(ctx)) {
31+
Log.d(TAG, "Cooldown active, skipping");
32+
return Result.success();
33+
}
34+
35+
return UpdateNotifier.checkAndNotify(ctx) ? Result.retry() : Result.success();
36+
}
37+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package org.wally.waller;
2+
3+
import android.app.NotificationChannel;
4+
import android.app.NotificationManager;
5+
import android.app.PendingIntent;
6+
import android.content.Context;
7+
import android.content.Intent;
8+
import android.content.SharedPreferences;
9+
import android.content.pm.PackageInfo;
10+
import android.content.pm.PackageManager;
11+
import android.net.ConnectivityManager;
12+
import android.net.NetworkInfo;
13+
import android.os.Build;
14+
import android.util.Log;
15+
16+
import androidx.core.app.NotificationCompat;
17+
18+
import org.json.JSONObject;
19+
20+
import java.io.BufferedReader;
21+
import java.io.InputStreamReader;
22+
import java.net.HttpURLConnection;
23+
import java.net.URL;
24+
25+
public class UpdateNotifier {
26+
27+
private static final String TAG = "UpdateNotifier";
28+
private static final String PREFS_NAME = "update_checker_prefs";
29+
private static final String KEY_LAST_NOTIFIED = "last_notified_timestamp";
30+
private static final long SEVEN_DAYS_MS = 7L * 24 * 60 * 60 * 1000;
31+
private static final String CHANNEL_ID = "update_channel";
32+
private static final String CHANNEL_NAME = "App Updates";
33+
private static final int NOTIFICATION_ID = 999;
34+
private static final String API_URL = "https://api.github.com/repos/Fector101/wallpaper-carousel/releases/latest";
35+
36+
public static boolean isCooldownActive(Context context) {
37+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
38+
long lastNotified = prefs.getLong(KEY_LAST_NOTIFIED, 0);
39+
long now = System.currentTimeMillis();
40+
Log.d(TAG, "elapsed=" + (now - lastNotified) + "ms, cooldown=" + SEVEN_DAYS_MS + "ms");
41+
return now - lastNotified < SEVEN_DAYS_MS;
42+
}
43+
44+
public static boolean isNetworkAvailable(Context context) {
45+
try {
46+
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
47+
if (cm == null) return false;
48+
NetworkInfo info = cm.getActiveNetworkInfo();
49+
return info != null && info.isConnected();
50+
} catch (Exception e) {
51+
Log.e(TAG, "Error checking network", e);
52+
return false;
53+
}
54+
}
55+
56+
/**
57+
* Full check: version compare + notes fetch + notification.
58+
* Returns true if the check should be retried later (fetch failed), false otherwise.
59+
*/
60+
public static boolean checkAndNotify(Context context) {
61+
try {
62+
String currentVersion = getCurrentVersion(context);
63+
Log.d(TAG, "Current version: " + currentVersion);
64+
65+
String latestVersion = fetchLatestVersion();
66+
if (latestVersion == null) {
67+
Log.e(TAG, "Failed to fetch latest version");
68+
return true;
69+
}
70+
Log.d(TAG, "Latest version: " + latestVersion);
71+
72+
if (latestVersion.equals(currentVersion)) {
73+
Log.d(TAG, "Already on latest version");
74+
return false;
75+
}
76+
77+
Log.d(TAG, "New version available: " + latestVersion);
78+
String releaseNotes = fetchReleaseNotes(latestVersion);
79+
boolean posted = sendNotification(context, latestVersion, releaseNotes);
80+
if (!posted) {
81+
Log.w(TAG, "Notification not eligible to post, cooldown timestamp NOT saved");
82+
return true;
83+
}
84+
85+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
86+
.edit().putLong(KEY_LAST_NOTIFIED, System.currentTimeMillis()).apply();
87+
Log.d(TAG, "Timestamp saved, notification sent");
88+
89+
} catch (Exception e) {
90+
Log.e(TAG, "Update check failed", e);
91+
return true;
92+
}
93+
return false;
94+
}
95+
96+
public static String getCurrentVersion(Context context) {
97+
try {
98+
PackageInfo pInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
99+
return pInfo.versionName;
100+
} catch (PackageManager.NameNotFoundException e) {
101+
Log.e(TAG, "Could not get package version", e);
102+
return "";
103+
}
104+
}
105+
106+
private static String fetchLatestVersion() {
107+
try {
108+
URL url = new URL(API_URL);
109+
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
110+
conn.setRequestMethod("GET");
111+
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
112+
conn.setConnectTimeout(10000);
113+
conn.setReadTimeout(10000);
114+
115+
int responseCode = conn.getResponseCode();
116+
if (responseCode != 200) {
117+
Log.e(TAG, "HTTP " + responseCode);
118+
return null;
119+
}
120+
121+
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
122+
StringBuilder sb = new StringBuilder();
123+
String line;
124+
while ((line = reader.readLine()) != null) {
125+
sb.append(line);
126+
}
127+
reader.close();
128+
conn.disconnect();
129+
130+
JSONObject json = new JSONObject(sb.toString());
131+
String tag = json.getString("tag_name");
132+
return tag.startsWith("v") ? tag.substring(1) : tag;
133+
134+
} catch (Exception e) {
135+
Log.e(TAG, "Failed to fetch latest version", e);
136+
return null;
137+
}
138+
}
139+
140+
private static String fetchReleaseNotes(String version) {
141+
try {
142+
String fileUrl = "https://github.com/Fector101/wallpaper-carousel/releases/download/v"
143+
+ version + "/update-note-v" + version + ".txt";
144+
URL url = new URL(fileUrl);
145+
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
146+
conn.setRequestMethod("GET");
147+
conn.setConnectTimeout(10000);
148+
conn.setReadTimeout(10000);
149+
150+
int responseCode = conn.getResponseCode();
151+
if (responseCode != 200) {
152+
Log.e(TAG, "Release notes HTTP " + responseCode);
153+
return null;
154+
}
155+
156+
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
157+
StringBuilder sb = new StringBuilder();
158+
String line;
159+
while ((line = reader.readLine()) != null) {
160+
sb.append(line);
161+
sb.append("\n");
162+
}
163+
reader.close();
164+
conn.disconnect();
165+
return sb.toString().trim();
166+
167+
} catch (Exception e) {
168+
Log.e(TAG, "Failed to fetch release notes", e);
169+
return null;
170+
}
171+
}
172+
173+
private static boolean canPostNotification(Context context) {
174+
try {
175+
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
176+
if (nm == null) {
177+
Log.w(TAG, "NotificationManager unavailable, cannot post");
178+
return false;
179+
}
180+
181+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
182+
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
183+
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
184+
Log.w(TAG, "POST_NOTIFICATIONS permission not granted");
185+
return false;
186+
}
187+
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
188+
if (!nm.areNotificationsEnabled()) {
189+
Log.w(TAG, "Notifications disabled for app");
190+
return false;
191+
}
192+
}
193+
194+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
195+
NotificationChannel channel = new NotificationChannel(
196+
CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH);
197+
channel.setDescription("Notifications for app updates");
198+
nm.createNotificationChannel(channel);
199+
200+
NotificationChannel created = nm.getNotificationChannel(CHANNEL_ID);
201+
if (created == null || created.getImportance() == NotificationManager.IMPORTANCE_NONE) {
202+
Log.w(TAG, "Update channel disabled, cannot post");
203+
return false;
204+
}
205+
}
206+
return true;
207+
} catch (Exception e) {
208+
Log.e(TAG, "Notification eligibility check failed", e);
209+
return false;
210+
}
211+
}
212+
213+
private static boolean sendNotification(Context context, String version, String releaseNotes) {
214+
if (!canPostNotification(context)) {
215+
return false;
216+
}
217+
218+
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
219+
if (nm == null) return false;
220+
221+
Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
222+
if (launchIntent == null) return false;
223+
launchIntent.putExtra("action", "open_update");
224+
launchIntent.putExtra("version", version);
225+
launchIntent.putExtra("release_notes", releaseNotes);
226+
launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
227+
228+
PendingIntent pendingIntent = PendingIntent.getActivity(
229+
context, NOTIFICATION_ID, launchIntent,
230+
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
231+
232+
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
233+
.setSmallIcon(android.R.drawable.stat_notify_sync)
234+
.setContentTitle("New version available")
235+
.setContentText("v" + version + " is ready. Tap to update.")
236+
.setPriority(NotificationCompat.PRIORITY_HIGH)
237+
.setContentIntent(pendingIntent)
238+
.setAutoCancel(true);
239+
240+
nm.notify(NOTIFICATION_ID, builder.build());
241+
Log.d(TAG, "Notification sent for v" + version);
242+
return true;
243+
}
244+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.wally.waller;
2+
3+
import android.content.Context;
4+
5+
import androidx.work.Constraints;
6+
import androidx.work.ExistingPeriodicWorkPolicy;
7+
import androidx.work.NetworkType;
8+
import androidx.work.PeriodicWorkRequest;
9+
import androidx.work.WorkManager;
10+
11+
import java.util.concurrent.TimeUnit;
12+
13+
public class WorkScheduler {
14+
15+
public static final String WORK_TAG = "update_check_work";
16+
17+
public static void scheduleUpdateCheck(Context context) {
18+
Constraints constraints = new Constraints.Builder()
19+
.setRequiredNetworkType(NetworkType.CONNECTED)
20+
.build();
21+
22+
// PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder(
23+
// UpdateCheckWorker.class, 15, TimeUnit.MINUTES) // For testing purposes, using a shorter interval
24+
// .setConstraints(constraints)
25+
// .addTag(WORK_TAG)
26+
// .build();
27+
28+
PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder(
29+
UpdateCheckWorker.class, 7, TimeUnit.DAYS)
30+
.setConstraints(constraints)
31+
.addTag(WORK_TAG)
32+
.build();
33+
34+
WorkManager.getInstance(context)
35+
.enqueueUniquePeriodicWork(
36+
WORK_TAG,
37+
ExistingPeriodicWorkPolicy.KEEP,
38+
workRequest);
39+
}
40+
}

0 commit comments

Comments
 (0)