001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.app.AlarmManager; 007import android.app.Notification; 008import android.app.NotificationChannel; 009import android.app.NotificationManager; 010import android.app.PendingIntent; 011import android.content.ActivityNotFoundException; 012import android.content.Context; 013import android.content.Intent; 014import android.content.SharedPreferences; 015import android.net.Uri; 016import android.os.Build; 017import android.os.UserManager; 018import android.provider.Settings; 019import android.util.Log; 020 021import org.json.JSONArray; 022import org.json.JSONException; 023import org.json.JSONObject; 024 025import java.time.Instant; 026import java.time.LocalDate; 027import java.time.LocalDateTime; 028import java.time.LocalTime; 029import java.time.ZoneId; 030import java.time.ZonedDateTime; 031import java.util.Locale; 032 033/** 034 * The alarm at waking (docs/android.md, "Waking"): it rings when the night 035 * ends, on the mornings ticked with the night in Sioul's Health settings. 036 * Rust hands the coming wakings, eight days ahead, whenever they change 037 * (crates/sioul-app/src/wake.rs, through android/main.cpp). Here they are 038 * kept, in the storage a phone reads before its first unlock (a phone that 039 * restarted in the night still rings), and the next is given to Android's 040 * alarm clock: setAlarmClock, exact, through Doze, the alarm shown in the 041 * status bar. At each ring, a little before each and as each night starts, 042 * after a restart, a change of time or of zone, Rust is asked again 043 * (WakeReceiver); when it cannot answer, the list kept goes on, then the 044 * usual week, repeated: never nothing. Apart from the doses: its own list, 045 * codes, channel and receiver. 046 */ 047final class WakeAlarms 048{ 049 static final String RING = "com.aurelienpierre.sioul.action.WAKE_RING"; 050 static final String LOOK = "com.aurelienpierre.sioul.action.WAKE_LOOK"; 051 static final String STOP = "com.aurelienpierre.sioul.action.WAKE_STOP"; 052 static final String LATER = "com.aurelienpierre.sioul.action.WAKE_LATER"; 053 /** "Try the alarm": rung as a waking, by its own action (told apart in `dumpsys alarm`). */ 054 static final String TRY = "com.aurelienpierre.sioul.action.WAKE_TRY"; 055 /** A ring's time (Unix milliseconds), on its intent. */ 056 static final String AT = "at"; 057 /** On a ring and its buttons: a try's; and a try's last ring, after its "10 min later" (no other). */ 058 static final String TRIAL = "trial"; 059 static final String LAST = "last"; 060 /** Seconds before a try rings (wake.rs, TRY_SECONDS). */ 061 static final int TRY_SECONDS = 10; 062 /** A try's answers (wake.rs): it rings; exact alarms, notifications or the full screen refused. */ 063 static final int TRY_RINGS = 0; 064 static final int TRY_NO_EXACT = 1; 065 static final int TRY_NO_NOTIFICATIONS = 2; 066 static final int TRY_NO_SCREEN = 3; 067 /** Minutes "10 min later" puts the alarm off by (wake.rs, LATER_MINUTES). */ 068 static final int LATER_MINUTES = 10; 069 /** The "Waking" channel: the ring's notification, and the one after "10 min later". */ 070 static final String CHANNEL = "waking"; 071 072 // Its alarms and buttons, by codes of their own (the doses' are their keys' hashes, to another receiver). 073 private static final int RING_CODE = 0x5711; 074 private static final int LOOK_CODE = 0x5712; 075 private static final int AGAIN_CODE = 0x5713; 076 private static final int SHOW_CODE = 0x5714; 077 static final int STOP_CODE = 0x5715; 078 static final int LATER_CODE = 0x5716; 079 static final int SCREEN_CODE = 0x5717; 080 // A try's own: its alarm, and its buttons (their extras say a try's: never a waking's buttons changed). 081 private static final int TRY_CODE = 0x5718; 082 static final int TRY_STOP_CODE = 0x5719; 083 static final int TRY_LATER_CODE = 0x571A; 084 085 private static final String KEPT = "sioul-waking"; 086 private static final String LIST = "list"; 087 /** The ring last given to Android, and the last that rang: a ring missed while the phone was off. */ 088 private static final String ARMED = "armed"; 089 private static final String RANG = "rang"; 090 /** After "10 min later": when it rings again. */ 091 private static final String AGAIN = "again"; 092 /** A try's own keys: when it rings, and its words (before any list). Never the waking's. */ 093 private static final String TRY_AT = "try_at"; 094 private static final String TRY_WORDS = "try_words"; 095 /** A ring missed while the phone was off rings as it starts again, when this late at most. */ 096 private static final long MISSED_MS = 30 * 60 * 1000L; 097 /** A ring due within a minute is the one ringing now: never given again. */ 098 private static final long DUE_MS = 60 * 1000L; 099 100 private WakeAlarms() {} 101 102 /** The coming wakings (wake.rs, `sioul_wake_next`), in the phone's `zone`; JSON, or {"unknown": true}. */ 103 static native String nativeNext(String zone, boolean fetch); 104 105 // ---------------------------------------------------------------- the list 106 107 /** 108 * Rust's wakings (wake.rs): {rings, looks (Unix ms), week (Monday first, 109 * "07:00" or ""), zone, words}. Kept, then the next ring and look given to 110 * Android. Rust not knowing ({"unknown": true}: the night's file does not 111 * read) changes nothing kept. Any thread. 112 */ 113 static synchronized void set(Context context, String json) 114 { 115 final JSONObject list; 116 try { 117 list = new JSONObject(json); 118 } catch (JSONException e) { 119 Log.e(DoseAlarms.TAG, "Waking: a list that does not read; the one kept stays. " + e); 120 return; 121 } 122 if (list.optBoolean("unknown")) { 123 Log.w(DoseAlarms.TAG, "Waking: Sioul cannot read the night now; the alarms kept stay."); 124 arm(context, "not known"); 125 return; 126 } 127 kept(context).edit().putString(LIST, list.toString()).commit(); 128 channel(context); 129 arm(context, "given"); 130 } 131 132 /** 133 * The next ring and look given to Android's alarm clock, from the list 134 * kept: Android forgets them at a restart, a change of zone moves them. 135 * The list run out (Sioul not asked for eight days), the usual week. A 136 * ring after "10 min later" given again too. Never inexact: without 137 * "Alarms & reminders", nothing, and the Health settings say so. Any thread. 138 */ 139 static synchronized void arm(Context context, String why) 140 { 141 JSONObject list = list(context); 142 long now = System.currentTimeMillis(); 143 ZoneId here = ZoneId.systemDefault(); 144 ZoneId made = zone(list.optString("zone")); 145 long ring = first(list.optJSONArray("rings"), made, here, now + DUE_MS); 146 boolean usual = ring == 0; 147 if (usual) 148 ring = usual(list.optJSONArray("week"), here, now + DUE_MS); 149 long look = first(list.optJSONArray("looks"), made, here, now); 150 long again = kept(context).getLong(AGAIN, 0); 151 if (!exact(context)) { 152 Log.w(DoseAlarms.TAG, "Waking: no \"Alarms & reminders\": no alarm given (" + why + ")."); 153 return; 154 } 155 AlarmManager alarms = context.getSystemService(AlarmManager.class); 156 try { 157 if (ring > 0) 158 alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(ring, show(context)), ring(context, RING_CODE, ring)); 159 else 160 alarms.cancel(ring(context, RING_CODE, 0)); 161 if (again > now) 162 alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(again, show(context)), ring(context, AGAIN_CODE, again)); 163 if (look > 0) 164 alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, look, look(context)); 165 else 166 alarms.cancel(look(context)); 167 } catch (SecurityException e) { 168 // "Alarms & reminders" taken back meanwhile. 169 Log.w(DoseAlarms.TAG, "Waking: Android refused the alarm (" + why + "). " + e); 170 return; 171 } 172 kept(context).edit().putLong(ARMED, ring).apply(); 173 Log.i(DoseAlarms.TAG, "Waking: " + (ring > 0 ? "next at " + Instant.ofEpochMilli(ring) + (usual ? " (the usual week)" : "") : "none") + ", " + why + "."); 174 } 175 176 /** 177 * Rust asked for the coming wakings, then given them (WakeReceiver, on a 178 * thread of its own: Rust reads the sharing first, `fetch` the sync app 179 * given twenty seconds). Not before the phone's first unlock: Sioul's 180 * files cannot be read then, and the list kept rings. No answer: the 181 * list kept goes on. 182 */ 183 static void ask(Context context, boolean fetch, String why) 184 { 185 if (!unlocked(context)) { 186 Log.i(DoseAlarms.TAG, "Waking: not unlocked since the phone started; the list kept rings (" + why + ")."); 187 return; 188 } 189 String answer = null; 190 try { 191 DoseAlarms.load(context); 192 answer = nativeNext(ZoneId.systemDefault().getId(), fetch); 193 } catch (Throwable e) { 194 // Sioul's library that does not load, among others. 195 Log.e(DoseAlarms.TAG, "Waking: Sioul did not answer (" + why + "); the list kept goes on.", e); 196 } 197 if (answer != null && !answer.isEmpty()) 198 set(context, answer); 199 } 200 201 /** A ring rang (or rings now): known, so that a restart does not ring it again. */ 202 static synchronized void rang(Context context, long at) 203 { 204 SharedPreferences.Editor edit = kept(context).edit().putLong(RANG, at); 205 if (kept(context).getLong(AGAIN, 0) <= at + DUE_MS) 206 edit.remove(AGAIN); 207 edit.commit(); 208 } 209 210 /** A ring the phone missed while it was off, no more than half an hour ago; 0 when none. */ 211 static synchronized long missed(Context context) 212 { 213 SharedPreferences kept = kept(context); 214 long now = System.currentTimeMillis(); 215 long rang = kept.getLong(RANG, 0); 216 for (long at : new long[] { kept.getLong(ARMED, 0), kept.getLong(AGAIN, 0) }) 217 if (at > rang && at <= now && now - at < MISSED_MS) 218 return at; 219 return 0; 220 } 221 222 /** "10 min later": the alarm again in ten minutes, kept for a restart; when. */ 223 static synchronized long later(Context context) 224 { 225 long again = System.currentTimeMillis() + LATER_MINUTES * 60_000L; 226 kept(context).edit().putLong(AGAIN, again).commit(); 227 try { 228 if (exact(context)) 229 context.getSystemService(AlarmManager.class).setAlarmClock(new AlarmManager.AlarmClockInfo(again, show(context)), ring(context, AGAIN_CODE, again)); 230 } catch (SecurityException e) { 231 Log.w(DoseAlarms.TAG, "Waking: Android refused the alarm 10 min later. " + e); 232 } 233 return again; 234 } 235 236 /** Stop: nothing rings again before the next waking. */ 237 static synchronized void stopped(Context context) 238 { 239 kept(context).edit().remove(AGAIN).commit(); 240 context.getSystemService(AlarmManager.class).cancel(ring(context, AGAIN_CODE, 0)); 241 } 242 243 // ---------------------------------------------------------------- a try 244 245 /** 246 * "Try the alarm" (Health's settings; wake.rs hands {at (Unix ms), words}): 247 * the alarm at `at`, ten seconds on, through the path a waking takes: 248 * Android's alarm clock, WakeReceiver, WakeRinger (the rising sound, in 249 * the foreground), WakeScreen over the lock screen. By its own action, 250 * code and keys: the next waking, its list and its "10 min later" are left 251 * as they are, and nothing is written of the night. When Android refuses 252 * exact alarms, notifications or the full screen, nothing is set: the 253 * answer says which (TRY_NO_…), for the setting to say why, rather than a 254 * try that rings unseen or not at all. Not given again after a restart. 255 */ 256 static synchronized int tryNow(Context context, String json) 257 { 258 int refused = !exact(context) ? TRY_NO_EXACT : !notificationsAllowed(context) ? TRY_NO_NOTIFICATIONS : !fullScreen(context) ? TRY_NO_SCREEN : TRY_RINGS; 259 if (refused != TRY_RINGS) { 260 Log.w(DoseAlarms.TAG, "Waking: no try: " + (refused == TRY_NO_EXACT ? "no \"Alarms & reminders\"." : refused == TRY_NO_NOTIFICATIONS ? "notifications off." : "no full screen.")); 261 return refused; 262 } 263 long now = System.currentTimeMillis(); 264 long at = now + TRY_SECONDS * 1000L; 265 JSONObject words = null; 266 try { 267 JSONObject asked = new JSONObject(json); 268 long given = asked.optLong("at"); 269 if (given > now && given < now + 60_000L) 270 at = given; 271 words = asked.optJSONObject("words"); 272 } catch (JSONException e) { 273 // Ten seconds on, the words kept. 274 } 275 SharedPreferences.Editor edit = kept(context).edit().putLong(TRY_AT, at); 276 if (words != null) 277 edit.putString(TRY_WORDS, words.toString()); 278 edit.commit(); 279 // A try's "rings again" line, from a try before: no longer true. 280 context.getSystemService(NotificationManager.class).cancel(WakeRinger.TRY_AGAIN_NOTE); 281 channel(context); 282 try { 283 context.getSystemService(AlarmManager.class).setAlarmClock(new AlarmManager.AlarmClockInfo(at, show(context)), trial(context, at, false)); 284 } catch (SecurityException e) { 285 Log.w(DoseAlarms.TAG, "Waking: no try: Android refused the alarm. " + e); 286 return TRY_NO_EXACT; 287 } 288 Log.i(DoseAlarms.TAG, "Waking: a try at " + Instant.ofEpochMilli(at) + "; the next waking left as it is."); 289 return TRY_RINGS; 290 } 291 292 /** A try's "10 min later": rung once more, as a try, its last time (without "10 min later"); when. */ 293 static synchronized long tryLater(Context context) 294 { 295 long at = System.currentTimeMillis() + LATER_MINUTES * 60_000L; 296 kept(context).edit().putLong(TRY_AT, at).commit(); 297 try { 298 if (exact(context)) 299 context.getSystemService(AlarmManager.class).setAlarmClock(new AlarmManager.AlarmClockInfo(at, show(context)), trial(context, at, true)); 300 } catch (SecurityException e) { 301 Log.w(DoseAlarms.TAG, "Waking: Android refused the try 10 min later. " + e); 302 } 303 Log.i(DoseAlarms.TAG, "Waking: the try again at " + Instant.ofEpochMilli(at) + ", its last time."); 304 return at; 305 } 306 307 /** A try over (Stop, or its last ring): its alarm, if one is left, taken back; nothing of the waking's. */ 308 static synchronized void tryStopped(Context context) 309 { 310 kept(context).edit().remove(TRY_AT).commit(); 311 context.getSystemService(AlarmManager.class).cancel(trial(context, 0, false)); 312 Log.i(DoseAlarms.TAG, "Waking: the try is over; the next waking left as it is."); 313 } 314 315 /** A try's alarm: to WakeReceiver as a waking's, by its own action and code. */ 316 private static PendingIntent trial(Context context, long at, boolean last) 317 { 318 Intent ring = new Intent(context, WakeReceiver.class).setAction(TRY).putExtra(AT, at).putExtra(TRIAL, true).putExtra(LAST, last); 319 return PendingIntent.getBroadcast(context, TRY_CODE, ring, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 320 } 321 322 /** The first of `times` after `after`, moved to the same time on the clock here; 0 when none. */ 323 private static long first(JSONArray times, ZoneId made, ZoneId here, long after) 324 { 325 long first = 0; 326 for (int i = 0; times != null && i < times.length(); i++) { 327 long at = rezoned(times.optLong(i), made, here); 328 if (at > after && (first == 0 || at < first)) 329 first = at; 330 } 331 return first; 332 } 333 334 /** 335 * A time made in another zone (the phone taken elsewhere, or Sioul's 336 * zone a few minutes behind a change), at the same time on the clock 337 * here: a waking at 07:00 stays at 07:00. 338 */ 339 static long rezoned(long at, ZoneId made, ZoneId here) 340 { 341 if (at <= 0 || made == null || made.equals(here)) 342 return at; 343 LocalDateTime clock = LocalDateTime.ofInstant(Instant.ofEpochMilli(at), made); 344 return clock.atZone(here).toInstant().toEpochMilli(); 345 } 346 347 /** 348 * The next ring of the usual `week` (Monday first: "07:00", or "" when 349 * that morning does not ring) after `after`, here; 0 when none is ticked. 350 * What rings when Sioul cannot be asked: the usual mornings, repeated. 351 */ 352 static long usual(JSONArray week, ZoneId here, long after) 353 { 354 if (week == null || week.length() != 7) 355 return 0; 356 LocalDate day = Instant.ofEpochMilli(after).atZone(here).toLocalDate(); 357 for (int i = 0; i < 9; i++, day = day.plusDays(1)) { 358 String time = week.optString(day.getDayOfWeek().getValue() - 1); 359 if (time.isEmpty()) 360 continue; 361 try { 362 // A time the change of hour skips: just after it, as Rust says it. 363 long at = ZonedDateTime.of(day, LocalTime.parse(time), here).toInstant().toEpochMilli(); 364 if (at > after) 365 return at; 366 } catch (RuntimeException e) { 367 Log.w(DoseAlarms.TAG, "Waking: a usual time that does not read: " + time); 368 } 369 } 370 return 0; 371 } 372 373 private static ZoneId zone(String id) 374 { 375 try { 376 return id == null || id.isEmpty() ? null : ZoneId.of(id); 377 } catch (RuntimeException e) { 378 return null; 379 } 380 } 381 382 /** A ring's alarm (the next waking, or the one after "10 min later"), to WakeReceiver, by no other app. */ 383 private static PendingIntent ring(Context context, int code, long at) 384 { 385 Intent ring = new Intent(context, WakeReceiver.class).setAction(RING).putExtra(AT, at); 386 return PendingIntent.getBroadcast(context, code, ring, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 387 } 388 389 /** The question before a ring and as a night starts. */ 390 private static PendingIntent look(Context context) 391 { 392 return PendingIntent.getBroadcast(context, LOOK_CODE, new Intent(context, WakeReceiver.class).setAction(LOOK), 393 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 394 } 395 396 /** The alarm tapped in the status bar or on the lock screen: Sioul, as by its icon. */ 397 private static PendingIntent show(Context context) 398 { 399 Intent sioul = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); 400 if (sioul == null) 401 return null; 402 return PendingIntent.getActivity(context, SHOW_CODE, sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED), 403 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 404 } 405 406 // ---------------------------------------------------------------- what Android allows 407 408 /** "Alarms & reminders": Android 12 gives it at install and lets you take it back; 13 gives it for good (USE_EXACT_ALARM). */ 409 static boolean exact(Context context) 410 { 411 return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || context.getSystemService(AlarmManager.class).canScheduleExactAlarms(); 412 } 413 414 /** The screen lit over the lock screen as it rings: yours to refuse from Android 14. */ 415 static boolean fullScreen(Context context) 416 { 417 return Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || context.getSystemService(NotificationManager.class).canUseFullScreenIntent(); 418 } 419 420 /** Sioul's notifications shown, and the "Waking" channel not turned off: else the ring has no Stop to show. */ 421 static boolean notificationsAllowed(Context context) 422 { 423 NotificationManager notifications = context.getSystemService(NotificationManager.class); 424 if (!notifications.areNotificationsEnabled()) 425 return false; 426 NotificationChannel waking = notifications.getNotificationChannel(CHANNEL); 427 return waking == null || waking.getImportance() != NotificationManager.IMPORTANCE_NONE; 428 } 429 430 /** For Rust (android/main.cpp): 1 exact alarms, 2 the full screen, 4 notifications. */ 431 static int state(Context context) 432 { 433 return (exact(context) ? 1 : 0) | (fullScreen(context) ? 2 : 0) | (notificationsAllowed(context) ? 4 : 0); 434 } 435 436 /** Android's page where one is allowed: "exact", "screen", "notifications"; Sioul's own page else. */ 437 static void settings(Context context, String which) 438 { 439 String app = context.getPackageName(); 440 Intent page; 441 if ("exact".equals(which) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) 442 page = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:" + app)); 443 else if ("screen".equals(which) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 444 page = new Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, Uri.parse("package:" + app)); 445 else if ("notifications".equals(which)) 446 page = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, app); 447 else 448 page = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app)); 449 try { 450 context.startActivity(page.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); 451 } catch (ActivityNotFoundException e) { 452 context.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); 453 } 454 } 455 456 /** Whether the phone was unlocked since it started: Sioul's own files can be read. */ 457 static boolean unlocked(Context context) 458 { 459 return context.getSystemService(UserManager.class).isUserUnlocked(); 460 } 461 462 // ---------------------------------------------------------------- kept, and said 463 464 /** 465 * The "Waking" channel: high importance (over the lock screen, the 466 * screen lit). Without a sound of its own: WakeRinger plays it, rising, 467 * where a channel's would be at one volume and stop as the shade opens. 468 */ 469 static void channel(Context context) 470 { 471 NotificationChannel waking = Channels.quiet(CHANNEL, word(context, "channel"), NotificationManager.IMPORTANCE_HIGH); 472 waking.setSound(null, null); 473 waking.enableVibration(false); 474 waking.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); 475 context.getSystemService(NotificationManager.class).createNotificationChannel(waking); 476 } 477 478 /** 479 * Kept where the phone reads it before its first unlock (device-protected 480 * storage): times and words only, nothing of what Sioul keeps. 481 */ 482 private static SharedPreferences kept(Context context) 483 { 484 return context.createDeviceProtectedStorageContext().getSharedPreferences(KEPT, Context.MODE_PRIVATE); 485 } 486 487 private static JSONObject list(Context context) 488 { 489 try { 490 return new JSONObject(kept(context).getString(LIST, "{}")); 491 } catch (JSONException e) { 492 return new JSONObject(); 493 } 494 } 495 496 private static final String[][] WORDS = { 497 { "channel", "Waking", "Réveil" }, 498 { "title", "Waking", "Réveil" }, 499 { "stop", "Stop", "Arrêter" }, 500 { "later", "10 min later", "10 min plus tard" }, 501 { "again", "Rings again at {time}", "Sonne de nouveau à {time}" }, 502 }; 503 504 /** 505 * The words said here, as Rust gave them with the wakings (or with a 506 * try), in the person's language (crates/sioul-core/locales); before 507 * either, these, by the phone's language. 508 */ 509 static String word(Context context, String which) 510 { 511 JSONObject words = list(context).optJSONObject("words"); 512 String said = words == null ? "" : words.optString(which); 513 if (said.isEmpty()) { 514 try { 515 said = new JSONObject(kept(context).getString(TRY_WORDS, "{}")).optString(which); 516 } catch (JSONException e) { 517 said = ""; 518 } 519 } 520 if (!said.isEmpty()) 521 return said; 522 boolean french = "fr".equals(Locale.getDefault().getLanguage()); 523 for (String[] word : WORDS) 524 if (word[0].equals(which)) 525 return word[french ? 2 : 1]; 526 return which; 527 } 528}