001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.app.ActivityManager; 007import android.app.AlarmManager; 008import android.app.Notification; 009import android.app.NotificationManager; 010import android.app.PendingIntent; 011import android.app.Service; 012import android.content.ActivityNotFoundException; 013import android.content.Context; 014import android.content.Intent; 015import android.content.SharedPreferences; 016import android.content.pm.ServiceInfo; 017import android.net.Uri; 018import android.os.Build; 019import android.os.FileObserver; 020import android.os.Handler; 021import android.os.HandlerThread; 022import android.os.IBinder; 023import android.os.PowerManager; 024import android.os.Process; 025import android.os.SystemClock; 026import android.provider.Settings; 027import android.util.Log; 028 029import org.json.JSONException; 030import org.json.JSONObject; 031 032import java.util.List; 033import java.util.Locale; 034 035/** 036 * Sioul in the background: it keeps your devices in step while Sioul is 037 * closed (crates/sioul-app/src/steps.rs; docs/android.md, "In the 038 * background"). A foreground service in a process of its own (":steps"): 039 * Qt's activity ends its whole process when it closes (System.exit in 040 * QtActivityBase.onDestroy), which would end a service living there too. It 041 * holds Sioul's library without Qt's window, and one quiet notification. 042 * 043 * - An alarm through Doze (exact while allowed, else inexact) asks Rust for 044 * a step: the other devices' news read (Sioul's own pull from the server 045 * while it works, else the sync app asked to look, then a quick exchange), 046 * do-not-disturb asked of Sioul's own process when it changed 047 * (DndReceiver), mail at its rhythm; Rust says when to look next. 048 * - A watch on the sharing folder: another device's file written by the 049 * sync app brings a step three seconds later, without waiting for the alarm 050 * (Rust leaves it out when its pull brought that file already). 051 * - The phone is kept awake only while a step runs: a step asked for later 052 * (a folder's, a message's) comes by an exact alarm of its own (`later`). 053 * - While Sioul's window is on the screen, it exchanges by itself: the steps 054 * wait. 055 * - While this phone screens calls (Calls.java), its notification offers "Let 056 * every call through", for an hour or until turned off, and "Screen calls 057 * again"; each step keeps the calls' table in step too. 058 * 059 * Rust's questions come through call(verb, json) (android/main.cpp), also 060 * from Sioul's own process: starting and stopping the service, the stars of 061 * the list's people (DndContacts), the battery's exemption, the alarm of 062 * do-not-disturb's next end (DndReceiver). 063 */ 064public final class StepService extends Service 065{ 066 static final String TAG = DoseAlarms.TAG; 067 static final String STEP = "com.aurelienpierre.sioul.action.STEP"; 068 /** "Let every call through" pressed on the notification (StepReceiver): extras "on", "minutes". */ 069 static final String CALLS = "com.aurelienpierre.sioul.action.CALLS"; 070 /** A press of the switch heard in Sioul's own process (DndReceiver, the tile): sent at once (StepReceiver). */ 071 static final String HEARD = "com.aurelienpierre.sioul.action.HEARD"; 072 /** The notification's words, as Rust says them after each apply in Sioul's own process (StepReceiver): extra "words". */ 073 static final String NOTE_WORDS = "com.aurelienpierre.sioul.action.NOTE_WORDS"; 074 /** A message's line written for your computers by the listener (AppNotes, `soon`): a step soon shares it. */ 075 static final String SOON = "com.aurelienpierre.sioul.action.SOON"; 076 /** The alarm of a step asked for later (`later`): a folder's, a message's. */ 077 static final String LATER = "com.aurelienpierre.sioul.action.LATER"; 078 /** How long a step asked by `SOON` waits: the messages that follow go with it. */ 079 private static final long SOON_MS = 20_000; 080 private static final String CHANNEL = "steps"; 081 private static final int NOTE = 0x5137; 082 private static final String KEPT = "sioul-steps"; 083 /** Seconds between two steps when Rust gave no answer. */ 084 private static final long USUAL_S = 300; 085 /** A step holds the phone awake this long at most. */ 086 private static final long AWAKE_MS = 90_000; 087 /** The sync app's writing settles this long before a step reads it. */ 088 private static final long SETTLE_MS = 3_000; 089 090 private static volatile StepService running; 091 092 private HandlerThread thread; 093 private Handler worker; 094 private PowerManager.WakeLock awake; 095 private FileObserver watch; 096 private String watched = ""; 097 private String own = ""; 098 /** Its text: do-not-disturb on or off, the calls screened or ringing (Rust's `steps::note`). */ 099 private String line = ""; 100 /** Its title: what now is for, until when; Sioul's words for the service before Rust gave any. */ 101 private String title = ""; 102 private String channelName = ""; 103 /** 104 * The calls screened, as Rust words them at each step: {screening, through, 105 * line, hour, off, again} (crates/sioul-app/src/calls.rs, `step`); 106 * empty while this phone does not screen calls. 107 */ 108 private String calls = ""; 109 /** A step asked while one runs: the phone stays awake for it. */ 110 private volatile boolean posted; 111 /** The step asked for later, waiting for its alarm (`later`); none: null. */ 112 private Later waiting; 113 /** The names of the other devices' files the sync app wrote since the last step, for Rust ("folder:"). */ 114 private final java.util.Set<String> written = new java.util.TreeSet<>(); 115 /** When the words shown were made (Unix milliseconds, Rust's clock): older ones are left aside. */ 116 private long saidAt; 117 118 /** One step, on the worker thread: {next (seconds), folder, own, line, title, channel, stop}. */ 119 static native String nativeStep(String reason); 120 121 /** Tells Rust it runs in the service's process: it never touches Android's modes from here. */ 122 static native void nativeService(); 123 124 // ---------------------------------------------------------------- Rust's questions 125 126 /** Rust's question (android/main.cpp), from either process. Any thread. */ 127 static String call(Context context, String verb, String json) 128 { 129 try { 130 JSONObject asked = json == null || json.isEmpty() ? new JSONObject() : new JSONObject(json); 131 switch (verb) { 132 case "start": 133 return start(context, asked) ? "true" : "false"; 134 case "stop": 135 stop(context); 136 return "true"; 137 case "running": 138 return runningHere(context) ? "true" : "false"; 139 case "battery": 140 return exempt(context) ? "true" : "false"; 141 case "ask-battery": 142 askBattery(context); 143 return "true"; 144 case "poke": 145 DndReceiver.poke(context); 146 return "true"; 147 case "heard": 148 // A press made in Sioul's own process: sent at once by the service when it runs; else Rust sends it. 149 if (!runningHere(context)) 150 return "{\"stepped\":false}"; 151 context.sendBroadcast(new Intent(context, StepReceiver.class).setAction(HEARD)); 152 return "{\"stepped\":true}"; 153 case "next": 154 DndReceiver.schedule(context, asked.optLong("at", 0)); 155 return "true"; 156 case "note": 157 // The notification's words now, from Sioul's own process: to the service, in its own. 158 return note(context, json) ? "true" : "false"; 159 case "stars": 160 return DndContacts.stars(context, asked).toString(); 161 case "open-contact": 162 return DndContacts.open(context, asked.optString("uri", "")) ? "true" : "false"; 163 case "add-contact": 164 return DndContacts.add(context, asked) ? "true" : "false"; 165 case "contacts-allowed": 166 return DndContacts.allowed(context) ? "true" : "false"; 167 case "sms-app": 168 // The phone's SMS app, for Settings ▸ This phone ▸ On your computers. 169 return JSONObject.quote(PhoneMessages.smsApp(context)); 170 default: 171 // texts: read and sent for your computers (Texts.java, TextSend.java). 172 if (verb.startsWith("texts-")) 173 return Texts.call(context, verb, asked); 174 // A mail attachment opened in the app you choose, or saved where you choose (Attachments.java). 175 if (verb.startsWith("attachment-")) 176 return Attachments.call(context, verb, asked); 177 // The calls screened (Calls.java): the role, its pages, the phone app's dial pad. 178 return verb.startsWith("calls-") ? Calls.call(context, verb, asked) : null; 179 } 180 } catch (JSONException | RuntimeException e) { 181 Log.e(TAG, "Steps: " + verb + " failed: " + e); 182 return null; 183 } 184 } 185 186 /** 187 * Whether it runs and its words, written by Sioul's own process and read 188 * by the service's: read again from the file when it changed (both 189 * processes use it, rarely). 190 */ 191 @SuppressWarnings("deprecation") 192 private static SharedPreferences kept(Context context) 193 { 194 return context.getSharedPreferences(KEPT, Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS); 195 } 196 197 /** 198 * The service started, or told new words: {title, channel}. Kept, so that 199 * a restart of the phone starts it again with them. False when Android 200 * refused (Sioul in the background on Android 12 and later). 201 */ 202 static boolean start(Context context, JSONObject words) 203 { 204 kept(context).edit().putBoolean("on", true) 205 .putString("title", words.optString("title", "")) 206 .putString("channel", words.optString("channel", "")).commit(); 207 // Running already: nothing more (its next step brings the words). 208 if (runningHere(context)) 209 return true; 210 Intent intent = new Intent(context, StepService.class).setAction(STEP).putExtra("reason", "start") 211 .putExtra("title", words.optString("title", "")).putExtra("channel", words.optString("channel", "")); 212 try { 213 context.startForegroundService(intent); 214 return true; 215 } catch (IllegalStateException | SecurityException e) { 216 // Not allowed from the background now: the next start of Sioul's window, or the next restart. 217 Log.w(TAG, "Steps: not started now: " + e); 218 return false; 219 } 220 } 221 222 /** Stopped, and not started again at the phone's restart. */ 223 static void stop(Context context) 224 { 225 kept(context).edit().putBoolean("on", false).commit(); 226 context.stopService(new Intent(context, StepService.class)); 227 cancel(context); 228 } 229 230 /** At the phone's restart or after an update (StepReceiver): started again if it was on. */ 231 static void restart(Context context, String reason) 232 { 233 if (!kept(context).getBoolean("on", false)) 234 return; 235 try { 236 context.startForegroundService(new Intent(context, StepService.class).setAction(STEP).putExtra("reason", reason)); 237 } catch (IllegalStateException | SecurityException e) { 238 Log.w(TAG, "Steps: not started again: " + e); 239 } 240 } 241 242 /** An alarm's step (StepReceiver): to the service running, else it starts (an exact alarm allows it). */ 243 static void alarm(Context context) 244 { 245 StepService service = running; 246 if (service != null) { 247 service.step("alarm", 0); 248 return; 249 } 250 restart(context, "alarm"); 251 } 252 253 /** Whether the service runs in this very process (its own, ":steps"). */ 254 static boolean inThisProcess() 255 { 256 return running != null; 257 } 258 259 /** Whether the service runs, asked from either process. */ 260 static boolean runningHere(Context context) 261 { 262 if (running != null) 263 return true; 264 ActivityManager manager = context.getSystemService(ActivityManager.class); 265 try { 266 @SuppressWarnings("deprecation") 267 List<ActivityManager.RunningServiceInfo> services = manager.getRunningServices(64); 268 for (ActivityManager.RunningServiceInfo info : services) 269 if (StepService.class.getName().equals(info.service.getClassName()) && info.foreground) 270 return true; 271 } catch (SecurityException e) { 272 // Not ours to ask: not known. 273 } 274 return false; 275 } 276 277 /** Whether Android lets Sioul run in the background, battery optimisation aside. */ 278 static boolean exempt(Context context) 279 { 280 PowerManager power = context.getSystemService(PowerManager.class); 281 return power != null && power.isIgnoringBatteryOptimizations(context.getPackageName()); 282 } 283 284 /** Android's question to let Sioul run in the background; else its list. */ 285 static void askBattery(Context context) 286 { 287 Intent[] pages = { 288 new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.getPackageName())), 289 new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), 290 new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + context.getPackageName())), 291 }; 292 for (Intent page : pages) { 293 try { 294 context.startActivity(page.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); 295 return; 296 } catch (ActivityNotFoundException | SecurityException e) { 297 // The next one. 298 } 299 } 300 } 301 302 // ---------------------------------------------------------------- the service 303 304 @Override 305 public void onCreate() 306 { 307 super.onCreate(); 308 running = this; 309 thread = new HandlerThread("sioul-steps", Process.THREAD_PRIORITY_BACKGROUND); 310 thread.start(); 311 worker = new Handler(thread.getLooper()); 312 PowerManager power = getSystemService(PowerManager.class); 313 awake = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sioul:steps"); 314 awake.setReferenceCounted(false); 315 SharedPreferences words = kept(this); 316 title = words.getString("title", ""); 317 channelName = words.getString("channel", ""); 318 foreground(); 319 // The phone's texts watched in this process: the import asks Android only when they changed. 320 Texts.watch(this); 321 } 322 323 @Override 324 public int onStartCommand(Intent intent, int flags, int startId) 325 { 326 if (intent != null) { 327 String given = intent.getStringExtra("title"); 328 if (given != null && !given.isEmpty()) 329 title = given; 330 String channel = intent.getStringExtra("channel"); 331 if (channel != null && !channel.isEmpty()) 332 channelName = channel; 333 } 334 // Within five seconds of a start, every time: Android asks it of a foreground service. 335 foreground(); 336 step(intent == null ? "restart" : intent.getStringExtra("reason") == null ? "start" : intent.getStringExtra("reason"), 0); 337 return START_STICKY; 338 } 339 340 @Override 341 public void onDestroy() 342 { 343 running = null; 344 if (watch != null) 345 watch.stopWatching(); 346 Texts.unwatch(this); 347 cancelLater(this); 348 if (awake.isHeld()) 349 awake.release(); 350 thread.quitSafely(); 351 super.onDestroy(); 352 } 353 354 @Override 355 public IBinder onBind(Intent intent) 356 { 357 return null; 358 } 359 360 /** The service in the foreground with its notification: at each start, as Android asks. */ 361 private void foreground() 362 { 363 Notification built = note(); 364 try { 365 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 366 startForeground(NOTE, built, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); 367 else 368 startForeground(NOTE, built); 369 } catch (RuntimeException e) { 370 // Refused (Android 12 and later, from the background): the next start. 371 Log.w(TAG, "Steps: not in the foreground: " + e); 372 stopSelf(); 373 } 374 } 375 376 /** Its notification changed in place: new words, the do-not-disturb line. */ 377 private void refresh() 378 { 379 getSystemService(NotificationManager.class).notify(NOTE, note()); 380 } 381 382 /** One quiet notification: the lowest importance, no sound, no badge, hidden on the lock screen. */ 383 private Notification note() 384 { 385 NotificationManager notifications = getSystemService(NotificationManager.class); 386 notifications.createNotificationChannel(Channels.quiet(CHANNEL, channelName.isEmpty() ? word(0) : channelName, NotificationManager.IMPORTANCE_MIN)); 387 Notification.Builder note = new Notification.Builder(this, CHANNEL) 388 .setSmallIcon(R.drawable.sioul_notification) 389 .setColor(getColor(R.color.sioul_green)) 390 .setContentTitle(title.isEmpty() ? word(1) : title) 391 .setCategory(Notification.CATEGORY_SERVICE) 392 .setVisibility(Notification.VISIBILITY_SECRET) 393 .setOngoing(true) 394 .setOnlyAlertOnce(true) 395 .setShowWhen(false) 396 .setContentIntent(open(this)); 397 // The calls' line while every call rings ("Every call rings until 15:30."), after do-not-disturb's. 398 JSONObject words = callsWords(); 399 String through = words == null ? "" : words.optString("line", ""); 400 String text = line.isEmpty() ? through : through.isEmpty() ? line : line + " " + through; 401 if (!text.isEmpty()) { 402 // Whole when opened: what now is for above, do-not-disturb and the calls below. 403 note.setContentText(text); 404 note.setStyle(new Notification.BigTextStyle().bigText(text)); 405 } 406 // "Let every call through" while this phone screens calls: for an hour or until 407 // turned off; once on, "Screen calls again" (docs/android.md, "Calls"). 408 if (words != null && words.optBoolean("screening")) { 409 if (words.optBoolean("through")) { 410 note.addAction(action(this, 0x5142, words.optString("again", ""), false, 0)); 411 } else { 412 note.addAction(action(this, 0x5140, words.optString("hour", ""), true, 60)); 413 note.addAction(action(this, 0x5141, words.optString("off", ""), true, 0)); 414 } 415 } 416 return note.build(); 417 } 418 419 private JSONObject callsWords() 420 { 421 if (calls.isEmpty()) 422 return null; 423 try { 424 return new JSONObject(calls); 425 } catch (JSONException e) { 426 return null; 427 } 428 } 429 430 /** One of the notification's buttons for the calls: on for `minutes` (0: until turned off), or off. */ 431 private static Notification.Action action(Context context, int code, String title, boolean on, int minutes) 432 { 433 Intent press = new Intent(context, StepReceiver.class).setAction(CALLS).putExtra("on", on).putExtra("minutes", minutes); 434 PendingIntent pending = PendingIntent.getBroadcast(context, code, press, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 435 return new Notification.Action.Builder(null, title, pending).build(); 436 } 437 438 /** 439 * The notification's words, {title, text, calls}, told by Rust after each 440 * apply in Sioul's own process: to the service running in this process, 441 * else to its process (StepReceiver), only while it runs. True when told. 442 */ 443 static boolean note(Context context, String json) 444 { 445 StepService service = running; 446 if (service != null) { 447 service.words(json); 448 return true; 449 } 450 if (!runningHere(context)) 451 return false; 452 // Android's queue of foreground broadcasts: never behind one held open meanwhile 453 // (DndReceiver holds its own while Sioul's change settles, three seconds). 454 context.sendBroadcast(new Intent(context, StepReceiver.class).setAction(NOTE_WORDS).putExtra("words", json) 455 .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)); 456 return true; 457 } 458 459 /** The words told (StepReceiver, in this process): the notification changed in place at once when they differ. */ 460 static void told(String json) 461 { 462 StepService service = running; 463 if (service != null && json != null) 464 service.words(json); 465 } 466 467 /** 468 * New words, kept and shown at once when they changed: on the caller's 469 * thread, never behind a step, which may wait for the sync app or for 470 * mail on the worker's. 471 */ 472 private void words(String json) 473 { 474 try { 475 said(new JSONObject(json)); 476 } catch (JSONException e) { 477 Log.w(TAG, "Steps: words not read: " + e); 478 } 479 } 480 481 /** 482 * The notification's words from Rust ({at, title, text, channel, calls}): 483 * shown again when one changed; words made before those shown (`at`, 484 * Unix milliseconds: a step's, finished after a press told newer ones) 485 * are left aside. 486 */ 487 private synchronized void said(JSONObject answer) 488 { 489 long at = answer.optLong("at", 0); 490 if (at > 0 && at < saidAt) 491 return; 492 saidAt = Math.max(saidAt, at); 493 String said = answer.optString("text", answer.optString("line", line)); 494 String words = answer.optString("title", title); 495 String channel = answer.optString("channel", channelName); 496 JSONObject callsSaid = answer.optJSONObject("calls"); 497 String callsNow = callsSaid == null ? calls : callsSaid.toString(); 498 if (!said.equals(line) || !words.equals(title) || !channel.equals(channelName) || !callsNow.equals(calls)) { 499 line = said; 500 title = words; 501 channelName = channel; 502 calls = callsNow; 503 refresh(); 504 // When, for the time each change takes to show (adb logcat -s sioul); nothing of what it says. 505 Log.i(TAG, "Steps: the notification says the state now."); 506 } 507 } 508 509 /** 510 * A line written for your computers, from the listener's process: a step 511 * soon, while the service runs (else the window's own exchanges, or the 512 * next start, send it). 513 */ 514 static void soon(Context context) 515 { 516 if (runningHere(context)) 517 context.sendBroadcast(new Intent(context, StepReceiver.class).setAction(SOON)); 518 } 519 520 /** 521 * `SOON` heard (StepReceiver, in this process): a step `SOON_MS` on, or 522 * the one waiting for its alarm then, which shares the line too 523 * (`Later.of`); none when a step is about to run. 524 */ 525 static void soonHere() 526 { 527 StepService service = running; 528 if (service != null && !service.posted) 529 service.step("messages", SOON_MS); 530 } 531 532 /** The alarm of the step asked for later (StepReceiver, in this process): it runs now; the service started again if Android had stopped it. */ 533 static void laterHere(Context context) 534 { 535 StepService service = running; 536 if (service == null) { 537 restart(context, "alarm"); 538 return; 539 } 540 String reason; 541 synchronized (service) { 542 reason = service.waiting == null ? "folder" : service.waiting.reason; 543 service.waiting = null; 544 } 545 service.step(reason, 0); 546 } 547 548 /** A press of the switch made in Sioul's own process (StepReceiver): a step at once, which sends it. */ 549 static void heard(Context context) 550 { 551 StepService service = running; 552 if (service != null) 553 service.step("heard", 0); 554 } 555 556 /** 557 * Expecting a call (once "Let every call through") pressed on the 558 * notification (StepReceiver, in this process): kept for the screening at 559 * once ({@link Calls#press}), then a 560 * step, in which Rust shares it with your other devices and words the 561 * notification again. 562 */ 563 static void callsPressed(Context context, boolean on, int minutes) 564 { 565 Calls.press(context, on, minutes); 566 StepService service = running; 567 if (service != null) 568 service.step("calls", 0); 569 } 570 571 /** A tap: Sioul, as by its icon. */ 572 private static PendingIntent open(Context context) 573 { 574 Intent sioul = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); 575 if (sioul == null) 576 return null; 577 return PendingIntent.getActivity(context, NOTE, sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED), 578 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 579 } 580 581 /** 582 * A step: now, on the worker thread, the phone kept awake while it waits 583 * there and runs (one asked meanwhile replaces one not started; the one 584 * waiting for its alarm goes, this one does its work); or `delay` 585 * milliseconds on, by an exact alarm of its own, the phone left to sleep 586 * meanwhile (`later`). 587 */ 588 void step(String reason, long delay) 589 { 590 if (delay > 0) { 591 later(reason, delay); 592 return; 593 } 594 synchronized (this) { 595 waiting = null; 596 } 597 cancelLater(this); 598 awake.acquire(AWAKE_MS); 599 posted = true; 600 worker.removeCallbacksAndMessages(null); 601 worker.post(() -> run(reason)); 602 } 603 604 /** 605 * A step asked for later, as `Later.of` joins it to the one waiting: 606 * its exact alarm set, or moved (one alarm at a time). Through Doze too 607 * (allow-while-idle); Android brings an alarm five seconds on at the 608 * soonest. 609 */ 610 private void later(String reason, long delay) 611 { 612 long now = SystemClock.elapsedRealtime(); 613 Later next; 614 synchronized (this) { 615 next = Later.of(waiting, reason, delay, now); 616 waiting = next; 617 } 618 AlarmManager alarms = getSystemService(AlarmManager.class); 619 PendingIntent pending = laterPending(this); 620 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) 621 alarms.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, next.at, pending); 622 else 623 alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, next.at, pending); 624 } 625 626 /** 627 * A step asked for later: its reason, and when it comes (elapsed realtime, 628 * milliseconds). Pure: android/jvm-checks/StepsCheck.java. 629 */ 630 static final class Later 631 { 632 final String reason; 633 final long at; 634 635 Later(String reason, long at) 636 { 637 this.reason = reason; 638 this.at = at; 639 } 640 641 /** 642 * `asked`, `delay` ms on at `now`, joined to the one `waiting` (null: 643 * none). A folder's step comes `delay` after the last file written 644 * (each moves it on), never in place of a message's, which then comes 645 * at that time instead: a folder's may find nothing new and be left 646 * out, a message's line must go. A message's joins the one waiting, 647 * at its time (the lines that follow go with it), shares the line 648 * then; none waiting, it comes `delay` on. 649 */ 650 static Later of(Later waiting, String asked, long delay, long now) 651 { 652 if (waiting == null) 653 return new Later(asked, now + delay); 654 if ("folder".equals(asked)) 655 return new Later(waiting.reason, now + delay); 656 if ("messages".equals(asked)) 657 return new Later(asked, waiting.at); 658 return new Later(asked, now + delay); 659 } 660 } 661 662 private void run(String reason) 663 { 664 posted = false; 665 String names = takeWritten(); 666 try { 667 // A folder's step whose files a step since has read (their names taken): nothing left to do. 668 if ("folder".equals(reason) && names.isEmpty()) 669 return; 670 // Sioul's window on the screen exchanges by itself: the step waits for the next alarm. 671 if (windowShown()) { 672 schedule(this, USUAL_S); 673 return; 674 } 675 DoseAlarms.load(getApplicationContext()); 676 nativeService(); 677 Texts.watch(getApplicationContext()); 678 // A folder's step names the files written, for Rust to leave out one its pull brought already. 679 JSONObject answer = new JSONObject(nativeStep("folder".equals(reason) ? "folder:" + names : reason)); 680 // Nothing new: the alarm stays as it was. 681 if (answer.optBoolean("skip")) 682 return; 683 if (answer.optBoolean("stop")) { 684 // Sharing off, or the setting: no more steps; whether it starts again 685 // at the next restart stays as Sioul's window last said (`stop`, `start`). 686 cancel(this); 687 stopSelf(); 688 return; 689 } 690 schedule(this, answer.optLong("next", USUAL_S)); 691 own = answer.optString("own", own); 692 watchFolder(answer.optString("folder", "")); 693 if (answer.optJSONObject("calls") == null) 694 answer.put("calls", new JSONObject()); 695 said(answer); 696 } catch (Throwable e) { 697 // Sioul's library that does not load, a step that failed: tried again at the usual pace. 698 Log.e(TAG, "Steps: a step failed (" + reason + ")", e); 699 schedule(this, USUAL_S); 700 } finally { 701 if (!posted && awake.isHeld()) 702 awake.release(); 703 } 704 } 705 706 /** Whether Sioul's window is on the screen (its own process in the foreground). */ 707 private boolean windowShown() 708 { 709 ActivityManager manager = getSystemService(ActivityManager.class); 710 List<ActivityManager.RunningAppProcessInfo> processes = manager.getRunningAppProcesses(); 711 if (processes == null) 712 return false; 713 for (ActivityManager.RunningAppProcessInfo process : processes) 714 if (getPackageName().equals(process.processName) && process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) 715 return true; 716 return false; 717 } 718 719 /** The sharing folder watched: another device's file written there brings a step. */ 720 private void watchFolder(String folder) 721 { 722 if (folder.equals(watched)) 723 return; 724 if (watch != null) 725 watch.stopWatching(); 726 watch = null; 727 watched = folder; 728 if (folder.isEmpty()) 729 return; 730 @SuppressWarnings("deprecation") 731 FileObserver observer = new FileObserver(folder, FileObserver.CLOSE_WRITE | FileObserver.MOVED_TO) { 732 @Override 733 public void onEvent(int event, String name) 734 { 735 if (!othersFile(name, own)) 736 return; 737 synchronized (written) { 738 written.add(name); 739 } 740 step("folder", SETTLE_MS); 741 } 742 }; 743 watch = observer; 744 watch.startWatching(); 745 } 746 747 /** 748 * Another device's file, by its name in the sharing folder's top: hidden 749 * names are files being written; this device's own are its own steps. 750 * Pure: android/jvm-checks/StepsCheck.java. 751 */ 752 static boolean othersFile(String name, String own) 753 { 754 return name != null && !name.isEmpty() && !name.startsWith(".") && name.indexOf('/') < 0 && (own.isEmpty() || !name.startsWith(own)); 755 } 756 757 /** The names written since the last step, "/" between them, forgotten here (any step reads the folder). */ 758 private String takeWritten() 759 { 760 synchronized (written) { 761 String names = String.join("/", written); 762 written.clear(); 763 return names; 764 } 765 } 766 767 /** The next step's alarm, `seconds` on: exact through Doze while allowed, else inexact. */ 768 static void schedule(Context context, long seconds) 769 { 770 AlarmManager alarms = context.getSystemService(AlarmManager.class); 771 long at = SystemClock.elapsedRealtime() + Math.max(60, seconds) * 1000L; 772 PendingIntent step = pending(context); 773 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) 774 alarms.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, step); 775 else 776 alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, step); 777 } 778 779 private static void cancel(Context context) 780 { 781 context.getSystemService(AlarmManager.class).cancel(pending(context)); 782 cancelLater(context); 783 } 784 785 /** The alarm of a step asked for later, taken away: a step now does its work. */ 786 private static void cancelLater(Context context) 787 { 788 context.getSystemService(AlarmManager.class).cancel(laterPending(context)); 789 } 790 791 private static PendingIntent laterPending(Context context) 792 { 793 return PendingIntent.getBroadcast(context, NOTE + 1, new Intent(context, StepReceiver.class).setAction(LATER), 794 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 795 } 796 797 private static PendingIntent pending(Context context) 798 { 799 return PendingIntent.getBroadcast(context, NOTE, new Intent(context, StepReceiver.class).setAction(STEP), 800 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 801 } 802 803 // ---------------------------------------------------------------- words before Rust gave them 804 805 private static final String[][] WORDS = { 806 { "Devices in step", "Appareils à jour" }, 807 { "Sioul keeps your devices in step", "Sioul tient vos appareils à jour" }, 808 }; 809 810 private static String word(int which) 811 { 812 boolean french = "fr".equals(Locale.getDefault().getLanguage()); 813 return WORDS[which][french ? 1 : 0]; 814 } 815}