001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.content.Context; 007import android.content.Intent; 008import android.graphics.Typeface; 009import android.net.Uri; 010import android.text.SpannableString; 011import android.text.Spanned; 012import android.text.style.StyleSpan; 013import android.view.View; 014import android.widget.RemoteViews; 015import android.widget.RemoteViewsService; 016 017import org.json.JSONArray; 018import org.json.JSONObject; 019 020import java.util.ArrayList; 021import java.util.List; 022 023/** 024 * The rows of a home-screen card's list (HomeCard, through HomeCardService), 025 * as a mail card and an agenda card show them, from what Rust wrote (crates/ 026 * sioul-app/src/homecard.rs). First "Mail": the messages the Porch shows in the 027 * frame for now, newest first, each with a thin bar in its account's colour, 028 * its sender (bold while not read), when it came, its subject and a line of 029 * its text; or the Porch's sentence instead ("The Porch opens tomorrow at 030 * 09:00.", "Nothing waits on the Porch."). Then "Agenda": the events not over 031 * yet, each under its day ("Today, Wed 7 Oct", "Tomorrow, Thu 8 Oct", "Mon 12 032 * Oct": each name as it holds now, Rust having said until when), as a block 033 * in its calendar's colour with its title and its time. Never a count. 034 * 035 * A row tapped fills its address into the list's template (HomeCard), for 036 * HomeCardOpener: the Porch, a message ("mail/" and its file), the Agenda, an 037 * event ("event/" and its file). 038 */ 039final class HomeCardRows implements RemoteViewsService.RemoteViewsFactory 040{ 041 /** The rows' kinds, each with its layout. */ 042 static final int SECTION = 0, LINE = 1, MAIL = 2, DAY = 3, EVENT = 4; 043 private static final int[] LAYOUTS = { 044 R.layout.sioul_home_card_section, R.layout.sioul_home_card_line, R.layout.sioul_home_card_mail, 045 R.layout.sioul_home_card_day, R.layout.sioul_home_card_event, 046 }; 047 /** 048 * The accounts' colours, by their place among your accounts: Theme.qml's 049 * chart colours, taken between their light and dark tones so that each 050 * reads on either ground (the host draws the card in the phone's theme, 051 * these are fixed here). 052 */ 053 private static final int[] ACCOUNTS = { 0xff5f8572, 0xffb07d3f, 0xff5b7ba3, 0xffa86464, 0xff8571ad, 0xff7d8c3a, 0xff4b8f8f }; 054 /** An event whose calendar gives no colour: Sioul's green. */ 055 private static final int OWN = 0xff5f8572; 056 /** The text on a light block; on a dark one, white. */ 057 private static final int DARK = 0xff1f1f1c; 058 059 /** One row: its kind, the message or event it shows, its words, where a tap leads. */ 060 static final class Row 061 { 062 final int type; 063 final JSONObject item; 064 final String text; 065 final String open; 066 067 Row(int type, JSONObject item, String text, String open) 068 { 069 this.type = type; 070 this.item = item; 071 this.text = text; 072 this.open = open; 073 } 074 } 075 076 private final Context context; 077 private final String kind; 078 private List<Row> rows = new ArrayList<>(); 079 /** When the rows were made: the dates and days said as at then. */ 080 private long now = System.currentTimeMillis(); 081 082 HomeCardRows(Context context, String kind) 083 { 084 this.context = context; 085 this.kind = kind; 086 } 087 088 @Override 089 public void onCreate() 090 { 091 } 092 093 /** The file read again (HomeCard asks it at each drawing): the rows for now. */ 094 @Override 095 public void onDataSetChanged() 096 { 097 now = System.currentTimeMillis(); 098 rows = rows(HomeCard.read(context), now, kind); 099 } 100 101 @Override 102 public void onDestroy() 103 { 104 rows = new ArrayList<>(); 105 } 106 107 @Override 108 public int getCount() 109 { 110 return rows.size(); 111 } 112 113 @Override 114 public RemoteViews getViewAt(int position) 115 { 116 if (position < 0 || position >= rows.size()) 117 return getLoadingView(); 118 Row row = rows.get(position); 119 RemoteViews views = new RemoteViews(context.getPackageName(), LAYOUTS[row.type]); 120 if (row.type == MAIL) 121 message(views, row.item); 122 else if (row.type == EVENT) 123 event(views, row.item); 124 else 125 views.setTextViewText(R.id.sioul_home_card_row, row.text); 126 views.setOnClickFillInIntent(R.id.sioul_home_card_row, new Intent().setData(Uri.fromParts(HomeCard.SCHEME, row.open, null))); 127 return views; 128 } 129 130 /** A blank line while a row comes, rather than Android's "Loading…". */ 131 @Override 132 public RemoteViews getLoadingView() 133 { 134 RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.sioul_home_card_line); 135 views.setTextViewText(R.id.sioul_home_card_row, ""); 136 return views; 137 } 138 139 @Override 140 public int getViewTypeCount() 141 { 142 return LAYOUTS.length; 143 } 144 145 @Override 146 public long getItemId(int position) 147 { 148 return position; 149 } 150 151 @Override 152 public boolean hasStableIds() 153 { 154 return false; 155 } 156 157 // ---------------------------------------------------------------- what the list says 158 159 /** 160 * The rows for `now`, for a card of `kind` (HomeCard.ALL, MAIL, AGENDA): 161 * the Porch's part of the frame for now, then the agenda's. None before 162 * Sioul wrote a card with lists (an older Sioul's card, or none: the lines 163 * on top say what there is). 164 */ 165 static List<Row> rows(JSONObject card, long now, String kind) 166 { 167 List<Row> rows = new ArrayList<>(); 168 if (card == null || card.optInt("v") < 2) 169 return rows; 170 JSONObject words = card.optJSONObject("words"); 171 JSONObject frame = HomeCard.frame(HomeCard.frames(card), now); 172 if (!HomeCard.AGENDA.equals(kind)) 173 mail(rows, card, frame, words == null ? "" : words.optString("mail"), kind); 174 if (!HomeCard.MAIL.equals(kind)) 175 agenda(rows, card, frame, words == null ? "" : words.optString("agenda"), now, kind); 176 return rows; 177 } 178 179 /** 180 * "Mail", then the messages the frame lists, or its sentence. Asleep, in a 181 * pause, in free time, the Porch says nothing: on the full card the status 182 * line says why; the mail card says it under its heading. 183 */ 184 private static void mail(List<Row> rows, JSONObject card, JSONObject frame, String heading, String kind) 185 { 186 JSONObject porch = frame == null ? null : frame.optJSONObject("porch"); 187 if (porch == null) { 188 if (HomeCard.MAIL.equals(kind)) { 189 rows.add(new Row(SECTION, null, heading, "porch")); 190 rows.add(new Row(LINE, null, frame == null ? card.optString("beyond") : frame.optString("status"), "porch")); 191 } 192 return; 193 } 194 rows.add(new Row(SECTION, null, heading, "porch")); 195 String line = porch.optString("line"); 196 if (!line.isEmpty()) 197 rows.add(new Row(LINE, null, line, "porch")); 198 JSONArray all = card.optJSONArray("mail"); 199 JSONArray listed = porch.optJSONArray("mail"); 200 for (int i = 0; all != null && listed != null && i < listed.length(); i++) { 201 JSONObject message = all.optJSONObject(listed.optInt(i, -1)); 202 if (message == null) 203 continue; 204 String key = message.optString("key"); 205 rows.add(new Row(MAIL, message, "", key.isEmpty() ? "porch" : "mail/" + key)); 206 } 207 } 208 209 /** 210 * "Agenda", then each event not over yet under its day: the day it starts, 211 * or today while it runs. Rust gave them nearest first, so the days only 212 * go forward. In a pause the full card shows the pause alone; the agenda 213 * card says it under its heading. Without a calendar, nothing. 214 */ 215 private static void agenda(List<Row> rows, JSONObject card, JSONObject frame, String heading, long now, String kind) 216 { 217 JSONObject agenda = card.optJSONObject("agenda"); 218 if (agenda == null) 219 return; 220 if (frame != null && !frame.optBoolean("events", true)) { 221 if (HomeCard.AGENDA.equals(kind)) { 222 rows.add(new Row(SECTION, null, heading, "agenda")); 223 rows.add(new Row(LINE, null, frame.optString("status"), "agenda")); 224 } 225 return; 226 } 227 rows.add(new Row(SECTION, null, heading, "agenda")); 228 JSONArray days = agenda.optJSONArray("days"); 229 JSONArray events = agenda.optJSONArray("events"); 230 int shownDay = -1; 231 boolean any = false; 232 for (int i = 0; events != null && i < events.length(); i++) { 233 JSONObject event = events.optJSONObject(i); 234 if (event == null || event.optLong("end") <= now) 235 continue; 236 int day = dayOf(days, Math.max(event.optLong("start"), now)); 237 if (day < 0) 238 continue; 239 if (day != shownDay) { 240 JSONObject named = days.optJSONObject(day); 241 rows.add(new Row(DAY, null, label(named == null ? null : named.optJSONArray("label"), now), "agenda")); 242 shownDay = day; 243 } 244 String key = event.optString("key"); 245 rows.add(new Row(EVENT, event, "", key.isEmpty() ? "agenda" : "event/" + key)); 246 any = true; 247 } 248 if (!any) 249 rows.add(new Row(LINE, null, agenda.optString("empty"), "agenda")); 250 } 251 252 /** The day `at` falls in, by its place in `days`; -1 outside them. */ 253 private static int dayOf(JSONArray days, long at) 254 { 255 for (int i = 0; days != null && i < days.length(); i++) { 256 JSONObject day = days.optJSONObject(i); 257 if (day != null && day.optLong("from") <= at && at < day.optLong("until")) 258 return i; 259 } 260 return -1; 261 } 262 263 /** The wording that holds at `now`: the first whose `until` is ahead, or 0 (for good). */ 264 static String label(JSONArray labels, long now) 265 { 266 for (int i = 0; labels != null && i < labels.length(); i++) { 267 JSONObject label = labels.optJSONObject(i); 268 if (label == null) 269 continue; 270 long until = label.optLong("until"); 271 if (until == 0 || until > now) 272 return label.optString("text"); 273 } 274 return ""; 275 } 276 277 /** The first `until` ahead of `now` among some wordings; none, Long.MAX_VALUE. */ 278 static long ahead(JSONArray labels, long now) 279 { 280 for (int i = 0; labels != null && i < labels.length(); i++) { 281 JSONObject label = labels.optJSONObject(i); 282 long until = label == null ? 0 : label.optLong("until"); 283 if (until > now) 284 return until; 285 } 286 return Long.MAX_VALUE; 287 } 288 289 /** 290 * When the list says something else (Unix ms), for HomeCard's next 291 * drawing: an event over, a message's or a day's name changing at 292 * midnight. Long.MAX_VALUE: never. 293 */ 294 static long nextChange(JSONObject card, long now) 295 { 296 long next = Long.MAX_VALUE; 297 if (card == null) 298 return next; 299 JSONArray mail = card.optJSONArray("mail"); 300 for (int i = 0; mail != null && i < mail.length(); i++) { 301 JSONObject message = mail.optJSONObject(i); 302 if (message != null) 303 next = Math.min(next, ahead(message.optJSONArray("when"), now)); 304 } 305 JSONObject agenda = card.optJSONObject("agenda"); 306 JSONArray days = agenda == null ? null : agenda.optJSONArray("days"); 307 for (int i = 0; days != null && i < days.length(); i++) { 308 JSONObject day = days.optJSONObject(i); 309 if (day != null) 310 next = Math.min(next, ahead(day.optJSONArray("label"), now)); 311 } 312 JSONArray events = agenda == null ? null : agenda.optJSONArray("events"); 313 for (int i = 0; events != null && i < events.length(); i++) { 314 JSONObject event = events.optJSONObject(i); 315 long end = event == null ? 0 : event.optLong("end"); 316 if (end > now) 317 next = Math.min(next, end); 318 } 319 return next; 320 } 321 322 // ---------------------------------------------------------------- drawn 323 324 /** A message, as a mail card shows it; sender and subject in bold while it is not read. */ 325 private void message(RemoteViews views, JSONObject message) 326 { 327 boolean unread = message.optBoolean("unread"); 328 views.setTextViewText(R.id.sioul_home_card_sender, bold(message.optString("sender"), unread)); 329 views.setTextViewText(R.id.sioul_home_card_date, label(message.optJSONArray("when"), now)); 330 views.setTextViewText(R.id.sioul_home_card_subject, bold(message.optString("subject"), unread)); 331 String text = message.optString("text"); 332 views.setTextViewText(R.id.sioul_home_card_text, text); 333 views.setViewVisibility(R.id.sioul_home_card_text, text.isEmpty() ? View.GONE : View.VISIBLE); 334 int account = message.optInt("account", -1); 335 if (account < 0) { 336 views.setViewVisibility(R.id.sioul_home_card_bar, View.INVISIBLE); 337 return; 338 } 339 views.setViewVisibility(R.id.sioul_home_card_bar, View.VISIBLE); 340 views.setInt(R.id.sioul_home_card_bar, "setColorFilter", ACCOUNTS[account % ACCOUNTS.length]); 341 } 342 343 /** An event, as an agenda card shows it: a block in its calendar's colour, the text as that colour asks. */ 344 private void event(RemoteViews views, JSONObject event) 345 { 346 int color = colour(event.optString("color")); 347 int text = textOn(color); 348 views.setInt(R.id.sioul_home_card_block, "setColorFilter", color); 349 views.setTextViewText(R.id.sioul_home_card_title, event.optString("title")); 350 views.setTextColor(R.id.sioul_home_card_title, text); 351 String time = event.optString("time"); 352 views.setTextViewText(R.id.sioul_home_card_time, time); 353 // The time a little softer than the title, as the window's agenda has it. 354 views.setTextColor(R.id.sioul_home_card_time, (text & 0x00ffffff) | 0xd9000000); 355 views.setViewVisibility(R.id.sioul_home_card_time, time.isEmpty() ? View.GONE : View.VISIBLE); 356 } 357 358 private static CharSequence bold(String text, boolean bold) 359 { 360 if (!bold || text.isEmpty()) 361 return text; 362 SpannableString styled = new SpannableString(text); 363 styled.setSpan(new StyleSpan(Typeface.BOLD), 0, styled.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 364 return styled; 365 } 366 367 /** "#4c6b5c" as a colour; anything else, Sioul's own. */ 368 private static int colour(String hex) 369 { 370 if (hex != null && hex.length() == 7 && hex.charAt(0) == '#') { 371 try { 372 return 0xff000000 | Integer.parseInt(hex.substring(1), 16); 373 } catch (NumberFormatException e) { 374 // Not a colour: Sioul's own. 375 } 376 } 377 return OWN; 378 } 379 380 /** Dark text on a light block, white on a dark one: whichever contrasts more (WCAG 2's ratio). */ 381 static int textOn(int color) 382 { 383 double ground = luminance(color); 384 double white = 1.05 / (ground + 0.05); 385 double dark = (ground + 0.05) / (luminance(DARK) + 0.05); 386 return white >= dark ? 0xffffffff : DARK; 387 } 388 389 /** WCAG 2's relative luminance of an sRGB colour. */ 390 private static double luminance(int color) 391 { 392 return 0.2126 * linear((color >> 16) & 0xff) + 0.7152 * linear((color >> 8) & 0xff) + 0.0722 * linear(color & 0xff); 393 } 394 395 private static double linear(int value) 396 { 397 double c = value / 255.0; 398 return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); 399 } 400}