package io.freemytv; import android.app.Activity; import android.app.AlertDialog; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.BlurMaskFilter; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.RectF; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.provider.Settings; import android.text.StaticLayout; import android.text.TextPaint; import android.text.TextUtils; import android.view.Choreographer; import android.view.KeyEvent; import android.view.View; import android.view.accessibility.AccessibilityNodeProvider; import android.view.inputmethod.EditorInfo; import android.widget.EditText; import org.json.JSONArray; import org.json.JSONObject; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; /** * Free My TV's settings (the app's icon, "All Apps", or the gear on the home screen), in the style of Apple TV's * settings: the big symbol and title on the left, the list on the right with the focused row in white. "Home screen" * opens the Free My TV / Original choice (with a check on the current one); the switch runs in the main process * (HomeMode) and this screen only asks for it and follows the state file. */ public class SettingsActivity extends Activity { private SettingsView view; private DreamView preview; // "Screensaver": the screensaver right here, without changing the system setting @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Watchdog.install(this); Ui.init(this); view = new SettingsView(this); setContentView(view); } @Override protected void onResume() { super.onResume(); view.resume(); // From ADB: am start -n io.freemytv/.SettingsActivity --ez screensaver true (straight to the preview). if (preview == null && getIntent().getBooleanExtra("screensaver", false)) { getIntent().removeExtra("screensaver"); view.pause(); showPreview(); } } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); } @Override protected void onPause() { closePreview(); // before pausing the view: closing the preview resumes it (view.resume) view.pause(); super.onPause(); } @Override public boolean dispatchKeyEvent(KeyEvent event) { if (preview != null) { // Like the real screensaver: arrows, OK or Back close it (other keys go on to the system). switch (event.getKeyCode()) { case KeyEvent.KEYCODE_DPAD_UP: case KeyEvent.KEYCODE_DPAD_DOWN: case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_RIGHT: case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_BACK: if (event.getAction() == KeyEvent.ACTION_UP) closePreview(); return true; default: return super.dispatchKeyEvent(event); } } if (view.onKey(event)) return true; return super.dispatchKeyEvent(event); } void showPreview() { preview = new DreamView(this); setContentView(preview); preview.start(); } private void closePreview() { if (preview == null) return; preview.stop(); preview = null; setContentView(view); view.resume(); } static final class SettingsView extends View implements Choreographer.FrameCallback { private static final int MAIN = 0, HOME_CHOICE = 1, HOME_SETTINGS = 2, REMOTE_KEYS = 3, PICKER = 4, SHELF_EDIT = 5; private static final float LIST_ROW_H = 68, LIST_ROW_GAP = 6; private static final int PICK_VISIBLE = 9; private static final float LIST_X = 1000, LIST_R = 1824, LIST_TOP = 186, ROW_H = 74, ROW_GAP = 8, CHOICE_H = 104; private static final int AMBER = 0xFFFFC266, INK = 0xFF1C1C1E; private final SettingsActivity activity; private final Scene scene = new Scene(); private final Handler handler = new Handler(Looper.getMainLooper()); private final Spring[] pop = new Spring[12]; private final List appPkgs = new ArrayList<>(), appLabels = new ArrayList<>(); // The list picker (the app of a remote button): what it shows, which one is in use, where we are. private final List pickLabels = new ArrayList<>(); // The shelf editor: the apps it can hold (selected ones first, in order) and the selection. private final List shelfRows = new ArrayList<>(); private final List shelfOrder = new ArrayList<>(); private int pickFor, pickCurrent, pickScroll; // pickFor: the remote button (index + 1) private static final int PICK_CITY = 0; private final List cities = new ArrayList<>(); private String cityStatus = ""; private final RectF tmp = new RectF(), rowRect = new RectF(), focusBox = new RectF(); private JSONObject state = new JSONObject(), recs = new JSONObject(); private boolean hasKey; private int screen = MAIN, focus; private boolean running, okDown, backDown, wasBusy; private StaticLayout errorLayout; // the last error, wrapped (rebuilt only when it changes) private String errorShown; private final Spoken spoken; private long startedAt, lastNanos, pendingAt, lastSky; private int w, h; private float k = 1; private Paint title, subtitle, version, rowLabel, rowValue, rowSub, header, cellLabel, cellValue, warn, spinner, white, shadow; private TextPaint notice; private final Runnable poll = new Runnable() { @Override public void run() { if (!running) return; state = HomeMode.state(getContext()); hasKey = Recs.hasKey(getContext()); recs = Recs.status(getContext()); boolean nowBusy = !busyText().isEmpty(); if (nowBusy) pendingAt = 0; // A switch just ended: TalkBack reads how it went (the newest notice). JSONArray notices = state.optJSONArray("notices"); if (wasBusy && !nowBusy && notices != null && notices.length() > 0) { JSONObject newest = notices.optJSONObject(0); if (newest != null) announceForAccessibility(newest.optString("text")); } wasBusy = nowBusy; invalidate(); handler.postDelayed(this, busy() ? 500 : 2000); } }; SettingsView(SettingsActivity a) { super(a); activity = a; for (int i = 0; i < pop.length; i++) pop[i] = new Spring(0.28f, 0.85f, 0); HomeData.Weather wx = HomeData.get(a).weather; Shaders.sky(scene.palette, System.currentTimeMillis(), wx.sunrise, wx.sunset); scene.setMotion(0.5f, 0.2f); scene.applyPalette(); setFocusable(true); setContentDescription("Free My TV"); // TalkBack: one item per row, id = row + 1. spoken = new Spoken(this, new Spoken.Source() { @Override public void items(List out) { for (int i = 1; i <= rowCount(); i++) out.add(i); } @Override public String text(int id) { return id >= 1 && id <= rowCount() ? rowText(id - 1) : ""; } @Override public boolean bounds(int id, Rect out) { if (id < 1 || id > rowCount()) return false; rowBox(id - 1).round(out); return true; } @Override public int focused() { return focus + 1; } @Override public void focus(int id) { focus = id - 1; invalidate(); } @Override public void click(int id) { activate(); } }); } @Override public AccessibilityNodeProvider getAccessibilityNodeProvider() { return spoken; } /** A row as TalkBack reads it: its name and its value, as on screen. */ private String rowText(int row) { switch (screen) { case MAIN: { if (row == 0) { String busyText = busyText(); String value = busy() ? (busyText.isEmpty() ? Ui.tr("Começando…", "Starting…") : busyText) : freeActive() ? "Free My TV" : Ui.tr("Original", "Original"); return Ui.tr("Início, ", "Home Screen, ") + value; } String[] names = {Ui.tr("Tela de Descanso, ver agora", "Screensaver, preview"), Ui.tr("Ajustes do Início", "Home Settings"), Ui.tr("Prateleira", "Shelf"), Ui.tr("Botões do Controle", "Remote Buttons"), Ui.tr("Ajustes da TV", "TV Settings")}; return row - 1 < names.length ? names[row - 1] : ""; } case HOME_CHOICE: { String name = row == 0 ? "Free My TV, " + Ui.tr("sem anúncios, com a tela de descanso nova", "no ads, with the new screensaver") : "Original, " + Ui.tr("o Início de fábrica da TV", "the TV's factory home screen"); boolean inUse = (row == 0) == freeActive(); return name + (inUse ? Ui.tr(". Em uso", ". In use") : "") + ", " + (row + 1) + Ui.tr(" de 2", " of 2"); } case HOME_SETTINGS: case REMOTE_KEYS: { boolean keys = screen == REMOTE_KEYS; String[] labels = keys ? keyLabels() : settingLabels(), values = keys ? keyValues() : settingValues(); return row < labels.length ? labels[row] + ", " + values[row] : ""; } case PICKER: { int at = pickScroll + row; if (at >= pickLabels.size()) return ""; return pickLabels.get(at) + (at == pickCurrent ? Ui.tr(", escolhido", ", selected") : "") + ", " + (at + 1) + Ui.tr(" de ", " of ") + pickLabels.size(); } case SHELF_EDIT: { int at = pickScroll + row; if (at >= shelfRows.size()) return ""; String[] app = shelfRows.get(at); int place = shelfOrder.indexOf(app[0]); return app[1] + ", " + (place >= 0 ? Ui.tr("na prateleira, posição ", "on the shelf, place ") + (place + 1) : Ui.tr("fora da prateleira", "not on the shelf")); } default: return ""; } } private RectF rowBox(int row) { float top, height; if (screen == MAIN) { top = LIST_TOP + row * (ROW_H + ROW_GAP); height = ROW_H; } else if (screen == HOME_CHOICE) { top = LIST_TOP + 20 + row * (CHOICE_H + ROW_GAP); height = CHOICE_H; } else { top = LIST_TOP + 20 + row * (LIST_ROW_H + LIST_ROW_GAP); height = LIST_ROW_H; } focusBox.set(LIST_X * k, top * k, LIST_R * k, (top + height) * k); return focusBox; } void resume() { if (running) return; running = true; startedAt = SystemClock.uptimeMillis(); lastNanos = 0; handler.post(poll); Choreographer.getInstance().postFrameCallback(this); spoken.readAgain(); } void pause() { running = false; handler.removeCallbacks(poll); Choreographer.getInstance().removeFrameCallback(this); } @Override public void doFrame(long frameTimeNanos) { if (!running) return; boolean moving = busy(); for (Spring s : pop) moving |= !s.settled(); if (moving || SystemClock.uptimeMillis() - lastSky >= 100) invalidate(); Choreographer.getInstance().postFrameCallback(this); } @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { w = width; h = height; k = w / 1920f; scene.setSize(w, h); title = Ui.text(Ui.displaySemibold, 50 * k, 0xFFFFFFFF); title.setTextAlign(Paint.Align.CENTER); subtitle = Ui.text(Ui.regular, 26 * k, 0x99FFFFFF); subtitle.setTextAlign(Paint.Align.CENTER); version = Ui.text(Ui.regular, 22 * k, 0x66FFFFFF); version.setTextAlign(Paint.Align.CENTER); rowLabel = Ui.text(Ui.medium, 30 * k, 0xF2FFFFFF); rowValue = Ui.text(Ui.regular, 28 * k, 0x8CFFFFFF); rowValue.setTextAlign(Paint.Align.RIGHT); rowSub = Ui.text(Ui.regular, 23 * k, 0x8CFFFFFF); header = Ui.text(Ui.semibold, 21 * k, 0x73FFFFFF); header.setLetterSpacing(0.06f); cellLabel = Ui.text(Ui.regular, 25 * k, 0xD9FFFFFF); cellValue = Ui.numbers(Ui.regular, 25 * k, 0xD9FFFFFF); cellValue.setTextAlign(Paint.Align.RIGHT); warn = Ui.text(Ui.regular, 23 * k, AMBER); notice = new TextPaint(Ui.text(Ui.regular, 23 * k, 0xB3FFFFFF)); spinner = new Paint(Paint.ANTI_ALIAS_FLAG); spinner.setStyle(Paint.Style.STROKE); spinner.setStrokeCap(Paint.Cap.ROUND); spinner.setStrokeWidth(3.5f * k); white = new Paint(Paint.ANTI_ALIAS_FLAG); white.setColor(0xFFF5F5F7); shadow = new Paint(Paint.ANTI_ALIAS_FLAG); shadow.setMaskFilter(new BlurMaskFilter(22 * k, BlurMaskFilter.Blur.NORMAL)); } private boolean busy() { return !busyText().isEmpty() || (pendingAt != 0 && SystemClock.uptimeMillis() - pendingAt < 10_000); } private String busyText() { long at = state.optLong("busyAt"), now = SystemClock.elapsedRealtime(); boolean stale = now < at || now - at > 10 * 60_000L; return stale ? "" : state.optString("busy"); } private boolean freeActive() { return HomeMode.FREE.equals(state.optString("mode")) && !state.optBoolean("homeReverted"); } private int rowCount() { switch (screen) { case MAIN: return 6; case HOME_CHOICE: return 2; case HOME_SETTINGS: return settingLabels().length; case PICKER: return Math.min(PICK_VISIBLE, pickLabels.size()); case SHELF_EDIT: return Math.min(PICK_VISIBLE, shelfRows.size()); default: return Prefs.KEYS.length; } } @Override protected void onDraw(Canvas c) { if (w == 0) return; long now = System.nanoTime(); float dt = lastNanos == 0 ? 1 / 60f : Math.min(0.05f, (now - lastNanos) / 1e9f); lastNanos = now; for (int i = 0; i < pop.length; i++) { pop[i].target = i == focus && i < rowCount() ? 1 : 0; pop[i].step(dt); } long up = SystemClock.uptimeMillis(); float seconds = (up - startedAt) / 1000f; if (up - lastSky >= 100) { lastSky = up; scene.advance(seconds); } scene.drawBackground(c); // On the left: big symbol, title, description. float cx = 500 * k; Symbols.draw(getContext(), c, "sparkles", cx, 400 * k, 150 * k, 0xEBFFFFFF); c.drawText("Free My TV", cx, 612 * k, title); c.drawText(Ui.tr("Início e tela de descanso da TV", "Your TV's home screen and screensaver"), cx, 660 * k, subtitle); c.drawText(versionName(getContext()), cx, 704 * k, version); if (screen == MAIN) drawMain(c, seconds); else if (screen == HOME_CHOICE) drawChoice(c); else if (screen == PICKER) drawPicker(c); else if (screen == SHELF_EDIT) drawShelfEditor(c); else drawList(c); } private void drawMain(Canvas c, float seconds) { boolean working = busy(); String busyText = busyText(); String mode = freeActive() ? "Free My TV" : Ui.tr("Original", "Original"); String[] labels = {Ui.tr("Início", "Home Screen"), Ui.tr("Tela de Descanso", "Screensaver"), Ui.tr("Ajustes do Início", "Home Settings"), Ui.tr("Prateleira", "Shelf"), Ui.tr("Botões do Controle", "Remote Buttons"), Ui.tr("Ajustes da TV", "TV Settings")}; String[] values = {working ? "" : mode, Ui.tr("Ver agora", "Preview"), "", "", "", ""}; for (int i = 0; i < 6; i++) { float y = (LIST_TOP + i * (ROW_H + ROW_GAP)) * k; rowRect.set(LIST_X * k, y, LIST_R * k, y + ROW_H * k); float f = drawRowBackground(c, i); int ink = blend(0xF2FFFFFF, INK, f); rowLabel.setColor(ink); float base = rowRect.centerY() + Ui.halfCap(rowLabel); c.drawText(labels[i], rowRect.left + 30 * k, base, rowLabel); float right = rowRect.right - 30 * k; if (i >= 2) { float cw = Symbols.width(getContext(), "chevron_right", 22 * k); Symbols.draw(getContext(), c, "chevron_right", right - cw / 2, rowRect.centerY(), 22 * k, blend(0x66FFFFFF, 0x991C1C1E, f)); } else if (i == 0 && working) { // Switching: the current step in place of the value, with the spinning arc. String text = busyText.isEmpty() ? Ui.tr("Começando…", "Starting…") : busyText; rowValue.setColor(blend(0xB3FFFFFF, 0xB31C1C1E, f)); String shown = TextUtils.ellipsize(text, new TextPaint(rowValue), 470 * k, TextUtils.TruncateAt.END).toString(); c.drawText(shown, right, base, rowValue); float sx = right - rowValue.measureText(shown) - 26 * k; spinner.setColor(blend(0xFFFFFFFF, INK, f)); Ui.spinner(c, sx, rowRect.centerY(), 12 * k, seconds * 1.3f, spinner); } else { rowValue.setColor(blend(0x8CFFFFFF, 0x8C1C1C1E, f)); c.drawText(values[i], right, base, rowValue); } } float y = (LIST_TOP + 6 * (ROW_H + ROW_GAP) + 20) * k; String error = state.optString("error"); if (working && (busyText.contains("Permitir") || busyText.contains("Allow") || busyText.contains("разрешите"))) { c.drawText(Ui.tr("Na TV: marque “Sempre permitir” e aperte OK.", "On the TV: tick “Always allow” and press OK."), LIST_X * k + 30 * k, y, warn); y += 46 * k; } else if (!working && !error.isEmpty()) { // The whole message, on up to five lines: some say what to do (the Wi-Fi one, #14). int width = Math.round(760 * k); if (errorLayout == null || !error.equals(errorShown) || errorLayout.getWidth() != width) { errorShown = error; errorLayout = StaticLayout.Builder.obtain(error, 0, error.length(), new TextPaint(warn), width) .setMaxLines(5).setEllipsize(TextUtils.TruncateAt.END).build(); } c.save(); c.translate(LIST_X * k + 30 * k, y - errorLayout.getLineBaseline(0)); errorLayout.draw(c); c.restore(); y += errorLayout.getLineBaseline(errorLayout.getLineCount() - 1) - errorLayout.getLineBaseline(0) + 46 * k; } y = drawComparison(c, y); y = drawRecs(c, y + 26 * k); drawNotices(c, y + 40 * k); } /** Home screen picks: status, and the credit TMDB asks for. */ private float drawRecs(Canvas c, float y) { float x = LIST_X * k + 30 * k; c.drawText(Ui.tr("RECOMENDAÇÕES", "RECOMMENDATIONS"), x, y, header); String line; String error = recs.optString("error"); int count = recs.optInt("count"); if (!hasKey) line = Ui.tr("Sem chave do TMDB: veja o README (config.properties).", "No TMDB key: see the README (config.properties)."); else if (!error.isEmpty()) line = Ui.tr("Erro: ", "Error: ") + error + "."; else if (count > 0) line = Ui.tr("Ligadas · " + count + " títulos nos apps da TV, 3 novos a cada hora.", "On · " + count + " titles from the TV's apps, 3 new ones every hour."); else line = Ui.tr("Buscando os títulos…", "Fetching titles…"); c.drawText(TextUtils.ellipsize(line, notice, 790 * k, TextUtils.TruncateAt.END).toString(), x, y + 42 * k, notice); version.setTextAlign(Paint.Align.LEFT); c.drawText(Ui.tr("Usa a API do TMDB, sem endosso ou certificação do TMDB.", "This product uses the TMDB API but is not endorsed or certified by TMDB."), x, y + 78 * k, version); version.setTextAlign(Paint.Align.CENTER); return y + 78 * k; } /** List row: no background; focused, white with a shadow (the Apple TV focus). Returns how focused it is. */ private float drawRowBackground(Canvas c, int i) { float f = Ui.clamp(pop[i].value); if (f > 0.01f) { float s = 1 + 0.02f * f; float cx = rowRect.centerX(), cy = rowRect.centerY(); tmp.set(cx - rowRect.width() * s / 2, cy - rowRect.height() * s / 2, cx + rowRect.width() * s / 2, cy + rowRect.height() * s / 2); float r = 18 * k * s; tmp.offset(0, 10 * k * f); shadow.setColor(Ui.alpha(0xFF000000, 0.4f * f)); c.drawRoundRect(tmp, r, r, shadow); tmp.offset(0, -10 * k * f); white.setAlpha(Math.round(255 * f)); c.drawRoundRect(tmp, r, r, white); rowRect.set(tmp); } return f; } private float drawComparison(Canvas c, float y) { float x = LIST_X * k + 30 * k, colA = LIST_R * k - 220 * k, colB = LIST_R * k - 30 * k; c.drawText(Ui.tr("COMPARAÇÃO", "COMPARISON"), x, y, header); cellValue.setColor(0x73FFFFFF); c.drawText(Ui.tr("Original", "Original"), colA, y, cellValue); c.drawText("Free My TV", colB, y, cellValue); cellValue.setColor(0xD9FFFFFF); JSONObject metrics = state.optJSONObject("metrics"); JSONObject a = metrics == null ? null : metrics.optJSONObject(HomeMode.ORIGINAL); JSONObject b = metrics == null ? null : metrics.optJSONObject(HomeMode.FREE); String[][] rows = {{Ui.tr("Botão Início", "Home button"), "homeMs"}, {Ui.tr("Memória livre", "Free memory"), "memAvailableMB"}, {Ui.tr("Memória do Início", "Home screen memory"), "homePssMB"}}; for (int i = 0; i < rows.length; i++) { float base = y + (46 + 40 * i) * k; c.drawText(rows[i][0], x, base, cellLabel); c.drawText(metric(a, rows[i][1]), colA, base, cellValue); c.drawText(metric(b, rows[i][1]), colB, base, cellValue); } return y + (46 + 40 * rows.length) * k; } private void drawNotices(Canvas c, float y) { float x = LIST_X * k + 30 * k; c.drawText(Ui.tr("AVISOS", "NOTICES"), x, y, header); JSONArray notices = state.optJSONArray("notices"); int shown = 0; for (int i = 0; notices != null && i < notices.length() && shown < 2; i++) { JSONObject o = notices.optJSONObject(i); if (o == null) continue; String line = ago(o.optLong("at")) + " · " + (Ui.ru ? Russian.text(o.optString("text")) : o.optString("text")); c.drawText(TextUtils.ellipsize(line, notice, 790 * k, TextUtils.TruncateAt.END).toString(), x, y + (42 + 38 * shown) * k, notice); shown++; } if (shown == 0) c.drawText(Ui.tr("Nenhum ainda.", "None yet."), x, y + 42 * k, notice); } /** Home screen choice: Free My TV or the original, with a check on the one in use. */ private void drawChoice(Canvas c) { Paint heading = Ui.text(Ui.displaySemibold, 34 * k, 0xFFFFFFFF); c.drawText(Ui.tr("Início", "Home Screen"), LIST_X * k + 30 * k, (LIST_TOP - 24) * k, heading); String[][] options = {{"Free My TV", Ui.tr("Sem anúncios, com a tela de descanso nova", "No ads, with the new screensaver")}, {Ui.tr("Original", "Original"), Ui.tr("O Início de fábrica da TV", "The TV's factory home screen")}}; int current = freeActive() ? 0 : 1; for (int i = 0; i < 2; i++) { float y = (LIST_TOP + 20 + i * (CHOICE_H + ROW_GAP)) * k; rowRect.set(LIST_X * k, y, LIST_R * k, y + CHOICE_H * k); float f = drawRowBackground(c, i); rowLabel.setColor(blend(0xF2FFFFFF, INK, f)); rowSub.setColor(blend(0x8CFFFFFF, 0x991C1C1E, f)); c.drawText(options[i][0], rowRect.left + 30 * k, rowRect.top + 44 * k, rowLabel); c.drawText(options[i][1], rowRect.left + 30 * k, rowRect.top + 80 * k, rowSub); if (i == current) { float cw = Symbols.width(getContext(), "check", 26 * k); Symbols.draw(getContext(), c, "check", rowRect.right - 30 * k - cw / 2, rowRect.centerY(), 26 * k, blend(0xFFFFFFFF, INK, f)); } } Paint hint = Ui.text(Ui.regular, 23 * k, 0x8CFFFFFF); c.drawText(Ui.tr("A troca leva uns segundos e a tela pisca nas medições.", "Switching takes a few seconds; the screen blinks while it measures."), LIST_X * k + 30 * k, (LIST_TOP + 20 + 2 * (CHOICE_H + ROW_GAP) + 40) * k, hint); } private static int blend(int a, int b, float f) { int aa = a >>> 24, ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255; int ba = b >>> 24, br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255; return (Math.round(aa + (ba - aa) * f) << 24) | (Math.round(ar + (br - ar) * f) << 16) | (Math.round(ag + (bg - ag) * f) << 8) | Math.round(ab + (bb - ab) * f); } private static String metric(JSONObject m, String key) { if (m == null || !m.has(key)) return "—"; long v = m.optLong(key); if (key.equals("homeMs")) return v + (Ui.ru ? " мс" : " ms"); if (key.equals("memAvailableMB") && v >= 1024) return String.format(Ui.locale(), Ui.ru ? "%.2f ГБ" : "%.2f GB", v / 1024.0); return v + (Ui.ru ? " МБ" : " MB"); } private static String ago(long at) { long minutes = Math.max(0, (System.currentTimeMillis() - at) / 60_000); if (minutes < 1) return Ui.tr("agora", "now"); if (minutes < 60) return Ui.tr("há " + minutes + " min", minutes + " min ago"); if (minutes < 48 * 60) return Ui.tr("há " + (minutes / 60) + " h", (minutes / 60) + " h ago"); return Ui.tr("há " + (minutes / 1440) + " dias", (minutes / 1440) + " days ago"); } private static String versionName(Context c) { try { return "Free My TV " + c.getPackageManager().getPackageInfo(c.getPackageName(), 0).versionName; } catch (android.content.pm.PackageManager.NameNotFoundException e) { return "Free My TV"; } } boolean onKey(KeyEvent e) { boolean down = e.getAction() == KeyEvent.ACTION_DOWN; switch (e.getKeyCode()) { case KeyEvent.KEYCODE_DPAD_UP: if (down && focus > 0) focus--; else if (down && scrolling() && pickScroll > 0) pickScroll--; invalidate(); spoken.focusMoved(); return true; case KeyEvent.KEYCODE_DPAD_DOWN: if (down && focus < rowCount() - 1) focus++; else if (down && scrolling() && pickScroll + rowCount() < listSize()) pickScroll++; invalidate(); spoken.focusMoved(); return true; case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_RIGHT: if (down && screen == SHELF_EDIT) { moveShelfApp(e.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT ? 1 : -1); invalidate(); } else if (down && screen == HOME_SETTINGS) { adjust(e.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT ? 1 : -1); invalidate(); } return true; case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_NUMPAD_ENTER: if (down) { if (e.getRepeatCount() == 0) okDown = true; } else { if (okDown && !e.isCanceled() && spoken.keyClick()) activate(); okDown = false; } return true; case KeyEvent.KEYCODE_BACK: if (screen == MAIN) return false; // leaves the settings if (down) { if (e.getRepeatCount() == 0) backDown = true; } else { if (backDown && !e.isCanceled()) { if (screen == PICKER && pickFor == PICK_CITY) { focus = CITY_ROW; screen = HOME_SETTINGS; } else if (screen == PICKER) { focus = pickFor - 1; screen = REMOTE_KEYS; } else { focus = screen == HOME_SETTINGS ? 2 : screen == SHELF_EDIT ? 3 : screen == REMOTE_KEYS ? 4 : 0; screen = MAIN; } } backDown = false; } invalidate(); spoken.focusMoved(); return true; default: return false; } } // MARK: - Home Settings and Remote Buttons private static final String[] LANGUAGES = {"auto", "pt", "en", "es", "de", "ru"}, UNITS = {"auto", "C", "F"}, THEMES = {"auto", "light", "dark"}; /** Home Settings: language, temperature, theme, weather, quotes, then one row per coin. */ private static final int CITY_ROW = 4, COIN_ROWS = 6; private boolean dollar() { return "BRL".equalsIgnoreCase(Config.CURRENCY.trim()); } private String[] settingLabels() { List l = new ArrayList<>(); l.add(Ui.tr("Idioma", "Language")); l.add(Ui.tr("Temperatura", "Temperature")); l.add(Ui.tr("Tema", "Theme")); l.add(Ui.tr("Widget do clima", "Weather widget")); l.add(Ui.tr("Cidade", "City")); l.add(Ui.tr("Widget de cotações", "Quotes widget")); l.add("Bitcoin"); l.add("Ethereum"); l.add("Solana"); if (dollar()) l.add(Ui.tr("Dólar", "Dollar")); return l.toArray(new String[0]); } private String[] settingValues() { String[] labels = settingLabels(); String[] v = new String[labels.length]; String lang = Prefs.language(); v[0] = lang.equals("pt") ? "Português" : lang.equals("en") ? "English" : lang.equals("es") ? "Español" : lang.equals("de") ? "Deutsch" : lang.equals("ru") ? "Русский" : Ui.tr("Automático", "Automatic"); String unit = Prefs.temperature(); v[1] = unit.equals("C") ? "°C" : unit.equals("F") ? "°F" : Ui.tr("Automática", "Automatic"); String theme = Prefs.theme(); v[2] = theme.equals("light") ? Ui.tr("Claro", "Light") : theme.equals("dark") ? Ui.tr("Escuro", "Dark") : Ui.tr("Automático", "Automatic"); v[3] = onOff(Prefs.weather()); v[CITY_ROW] = !cityStatus.isEmpty() ? cityStatus : Prefs.city().isEmpty() ? "—" : Prefs.city(); v[5] = onOff(Prefs.quotes()); // No quotes widget, nowhere to show prices: the coins read as off (and stay locked) until it is back on. for (int i = COIN_ROWS; i < v.length; i++) v[i] = onOff(Prefs.quotes() && Prefs.coin(i - COIN_ROWS)); return v; } private static String onOff(boolean on) { return on ? Ui.tr("Ligado", "On") : Ui.tr("Desligado", "Off"); } private String[] keyLabels() { return new String[] {Ui.tr("Botão Netflix", "Netflix button"), Ui.tr("Botão YouTube", "YouTube button"), Ui.tr("Botão Prime Video", "Prime Video button"), Ui.tr("Botão TCL Channel", "TCL Channel button")}; } private String[] keyValues() { String[] v = new String[Prefs.KEYS.length]; for (int i = 0; i < v.length; i++) { int at = appPkgs.indexOf(Prefs.keyApp(Prefs.KEYS[i])); v[i] = at >= 0 ? appLabels.get(at) : Ui.tr("Não instalado", "Not installed"); } return v; } /** Installed TV apps, by name, after "Nothing": what a remote button can open. */ private void loadApps() { PackageManager pm = getContext().getPackageManager(); List found = new ArrayList<>(); Set seen = new HashSet<>(); Intent query = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER); for (ResolveInfo ri : pm.queryIntentActivities(query, 0)) { String pkg = ri.activityInfo.packageName; if (pkg.equals(getContext().getPackageName()) || !seen.add(pkg)) continue; found.add(new String[] {pkg, String.valueOf(ri.loadLabel(pm))}); } Collections.sort(found, (a, b) -> a[1].toLowerCase(Locale.ROOT).compareTo(b[1].toLowerCase(Locale.ROOT))); appPkgs.clear(); appLabels.clear(); appPkgs.add(""); appLabels.add(Ui.tr("Nada", "Nothing")); for (String[] f : found) { appPkgs.add(f[0]); appLabels.add(f[1]); } } /** Changes the focused row's value one step (OK and → go forward, ← goes back). */ private void adjust(int dir) { if (screen == REMOTE_KEYS) { int n = appPkgs.size(); int at = appPkgs.indexOf(Prefs.keyApp(Prefs.KEYS[focus])); int next = at < 0 ? (dir > 0 ? 0 : n - 1) : (at + dir + n) % n; Prefs.setKeyApp(Prefs.KEYS[focus], appPkgs.get(next)); return; } switch (focus) { case 0: { int at = java.util.Arrays.asList(LANGUAGES).indexOf(Prefs.language()); Prefs.setLanguage(LANGUAGES[(at + dir + LANGUAGES.length) % LANGUAGES.length]); break; } case 1: { int at = java.util.Arrays.asList(UNITS).indexOf(Prefs.temperature()); Prefs.setTemperature(UNITS[(at + dir + UNITS.length) % UNITS.length]); break; } case 2: { int at = java.util.Arrays.asList(THEMES).indexOf(Prefs.theme()); Prefs.setTheme(THEMES[(at + dir + THEMES.length) % THEMES.length]); break; } case 3: Prefs.setWeather(!Prefs.weather()); break; case CITY_ROW: return; case 5: Prefs.setQuotes(!Prefs.quotes()); break; default: if (!Prefs.quotes()) return; Prefs.setCoin(focus - COIN_ROWS, !Prefs.coin(focus - COIN_ROWS)); break; } HomeData.get(getContext()).settingsChanged(); } private void askCity() { if (Ui.tr("Procurando…", "Searching…").equals(cityStatus)) return; EditText field = new EditText(activity); field.setSingleLine(true); field.setHint(Ui.tr("Digite o nome da cidade", "Type the city's name")); field.setImeOptions(EditorInfo.IME_ACTION_SEARCH); AlertDialog dialog = new AlertDialog.Builder(activity, android.R.style.Theme_DeviceDefault_Dialog_Alert) .setTitle(Ui.tr("Cidade", "City")) .setView(field) .setPositiveButton(Ui.tr("Buscar", "Search"), (d, which) -> searchCity(field.getText().toString())) .setNegativeButton(Ui.tr("Cancelar", "Cancel"), null) .create(); field.setOnEditorActionListener((v, action, event) -> { dialog.dismiss(); searchCity(field.getText().toString()); return true; }); dialog.show(); } private void searchCity(String name) { if (name.trim().isEmpty()) return; cityStatus = Ui.tr("Procurando…", "Searching…"); invalidate(); new Thread(() -> { List found; String error; try { found = HomeData.findCity(name); error = found.isEmpty() ? Ui.tr("Nenhuma cidade com esse nome.", "No city with that name.") : ""; } catch (Exception e) { found = new ArrayList<>(); error = Ui.tr("Não deu para buscar agora: ", "Couldn't search now: ") + e.getMessage(); AppLog.w("city search: " + e.getMessage()); } List result = found; String message = error; handler.post(() -> { cityStatus = message; if (!result.isEmpty() && screen == HOME_SETTINGS) { cities.clear(); cities.addAll(result); openPicker(PICK_CITY); spoken.focusMoved(); } invalidate(); if (!message.isEmpty()) handler.postDelayed(() -> { cityStatus = ""; invalidate(); }, 4000); }); }).start(); } private boolean scrolling() { return screen == PICKER || screen == SHELF_EDIT; } private int listSize() { return screen == SHELF_EDIT ? shelfRows.size() : pickLabels.size(); } // MARK: - Shelf editor private void openShelfEditor() { shelfOrder.clear(); shelfOrder.addAll(HomeApps.shelfPackages(getContext())); rebuildShelfRows(HomeApps.shelfCandidates(getContext())); pickScroll = 0; focus = 0; screen = SHELF_EDIT; } /** Selected apps first, in shelf order, then the rest in the order they came. */ private void rebuildShelfRows(List candidates) { shelfRows.clear(); for (String pkg : shelfOrder) { for (String[] cand : candidates) if (cand[0].equals(pkg)) { shelfRows.add(cand); break; } } for (String[] cand : candidates) if (!shelfOrder.contains(cand[0])) shelfRows.add(cand); } /** Same rows after a change: keeps the focus on the app that was focused. */ private void refreshShelfRows(String focusedPkg) { List all = new ArrayList<>(shelfRows); rebuildShelfRows(all); for (int i = 0; i < shelfRows.size(); i++) { if (!shelfRows.get(i)[0].equals(focusedPkg)) continue; int visible = rowCount(); if (i < pickScroll) pickScroll = i; else if (i >= pickScroll + visible) pickScroll = i - visible + 1; focus = i - pickScroll; break; } Prefs.setShelf(shelfOrder); } /** OK: puts the app on the shelf (at the end) or takes it off. */ private void toggleShelfApp() { String pkg = shelfRows.get(pickScroll + focus)[0]; if (shelfOrder.contains(pkg)) shelfOrder.remove(pkg); else if (shelfOrder.size() < HomeApps.MAX_SHELF) shelfOrder.add(pkg); else return; refreshShelfRows(pkg); } /** ← → : moves a shelf app one place earlier or later. */ private void moveShelfApp(int dir) { String pkg = shelfRows.get(pickScroll + focus)[0]; int at = shelfOrder.indexOf(pkg), to = at + dir; if (at < 0 || to < 0 || to >= shelfOrder.size()) return; shelfOrder.remove(at); shelfOrder.add(to, pkg); refreshShelfRows(pkg); } private void drawShelfEditor(Canvas c) { Paint heading = Ui.text(Ui.displaySemibold, 34 * k, 0xFFFFFFFF); c.drawText(Ui.tr("Prateleira", "Shelf"), LIST_X * k + 30 * k, (LIST_TOP - 24) * k, heading); int rows = rowCount(); for (int i = 0; i < rows; i++) { String[] app = shelfRows.get(pickScroll + i); int place = shelfOrder.indexOf(app[0]); float y = (LIST_TOP + 20 + i * (LIST_ROW_H + LIST_ROW_GAP)) * k; rowRect.set(LIST_X * k, y, LIST_R * k, y + LIST_ROW_H * k); float f = drawRowBackground(c, i); rowLabel.setColor(blend(place >= 0 ? 0xF2FFFFFF : 0x80FFFFFF, place >= 0 ? INK : 0x801C1C1E, f)); rowValue.setColor(blend(0xB3FFFFFF, 0xB31C1C1E, f)); float base = rowRect.centerY() + Ui.halfCap(rowLabel); c.drawText(TextUtils.ellipsize(app[1], new TextPaint(rowLabel), 520 * k, TextUtils.TruncateAt.END).toString(), rowRect.left + 30 * k, base, rowLabel); c.drawText(place >= 0 ? "#" + (place + 1) : Ui.tr("Fora", "Off"), rowRect.right - 30 * k, base, rowValue); } Paint hint = Ui.text(Ui.regular, 23 * k, 0x8CFFFFFF); float hy = (LIST_TOP + 20 + rows * (LIST_ROW_H + LIST_ROW_GAP) + 36) * k; c.drawText(Ui.tr("OK põe ou tira da prateleira (até " + HomeApps.MAX_SHELF + "). ← → mudam a ordem.", "OK adds or removes (up to " + HomeApps.MAX_SHELF + "). ← → change the order.", "OK pone o quita del estante (hasta " + HomeApps.MAX_SHELF + "). ← → cambian el orden."), LIST_X * k + 30 * k, hy, hint); } private void openPicker(int which) { pickFor = which; pickLabels.clear(); if (which == PICK_CITY) { for (String[] city : cities) pickLabels.add(city[0]); pickCurrent = -1; } else { pickLabels.addAll(appLabels); pickCurrent = appPkgs.indexOf(Prefs.keyApp(Prefs.KEYS[which - 1])); } int visible = Math.min(PICK_VISIBLE, pickLabels.size()); int at = Math.max(0, pickCurrent); pickScroll = Math.max(0, Math.min(at - 3, pickLabels.size() - visible)); focus = at - pickScroll; screen = PICKER; } /** A long list (the apps): nine rows at a time, the one in use checked. */ private void drawPicker(Canvas c) { Paint heading = Ui.text(Ui.displaySemibold, 34 * k, 0xFFFFFFFF); String title = pickFor == PICK_CITY ? Ui.tr("Cidade", "City") : keyLabels()[pickFor - 1]; c.drawText(title, LIST_X * k + 30 * k, (LIST_TOP - 24) * k, heading); int rows = rowCount(); for (int i = 0; i < rows; i++) { int at = pickScroll + i; float y = (LIST_TOP + 20 + i * (LIST_ROW_H + LIST_ROW_GAP)) * k; rowRect.set(LIST_X * k, y, LIST_R * k, y + LIST_ROW_H * k); float f = drawRowBackground(c, i); rowLabel.setColor(blend(0xF2FFFFFF, INK, f)); c.drawText(pickLabels.get(at), rowRect.left + 30 * k, rowRect.centerY() + Ui.halfCap(rowLabel), rowLabel); if (at == pickCurrent) { float cw = Symbols.width(getContext(), "check", 26 * k); Symbols.draw(getContext(), c, "check", rowRect.right - 30 * k - cw / 2, rowRect.centerY(), 26 * k, blend(0xFFFFFFFF, INK, f)); } } Paint hint = Ui.text(Ui.regular, 23 * k, 0x8CFFFFFF); float hy = (LIST_TOP + 20 + rows * (LIST_ROW_H + LIST_ROW_GAP) + 36) * k; c.drawText((pickScroll + focus + 1) + " / " + pickLabels.size() + " · " + Ui.tr("OK escolhe, Voltar cancela.", "OK picks, Back cancels."), LIST_X * k + 30 * k, hy, hint); } private void drawList(Canvas c) { boolean keys = screen == REMOTE_KEYS; String[] labels = keys ? keyLabels() : settingLabels(); String[] values = keys ? keyValues() : settingValues(); Paint heading = Ui.text(Ui.displaySemibold, 34 * k, 0xFFFFFFFF); c.drawText(keys ? Ui.tr("Botões do Controle", "Remote Buttons") : Ui.tr("Ajustes do Início", "Home Settings"), LIST_X * k + 30 * k, (LIST_TOP - 24) * k, heading); for (int i = 0; i < labels.length; i++) { float y = (LIST_TOP + 20 + i * (LIST_ROW_H + LIST_ROW_GAP)) * k; rowRect.set(LIST_X * k, y, LIST_R * k, y + LIST_ROW_H * k); float f = drawRowBackground(c, i); rowLabel.setColor(blend(0xF2FFFFFF, INK, f)); boolean locked = !keys && i >= COIN_ROWS && !Prefs.quotes(); rowValue.setColor(blend(locked ? 0x59FFFFFF : 0xB3FFFFFF, locked ? 0x591C1C1E : 0xB31C1C1E, f)); if (locked) rowLabel.setColor(blend(0x73FFFFFF, 0x731C1C1E, f)); float base = rowRect.centerY() + Ui.halfCap(rowLabel); c.drawText(labels[i], rowRect.left + 30 * k, base, rowLabel); c.drawText(values[i], rowRect.right - 30 * k, base, rowValue); } Paint hint = Ui.text(Ui.regular, 23 * k, 0x8CFFFFFF); float hy = (LIST_TOP + 20 + labels.length * (LIST_ROW_H + LIST_ROW_GAP) + 36) * k; c.drawText(Ui.tr("OK ou as setas mudam o valor.", "OK or the arrows change the value."), LIST_X * k + 30 * k, hy, hint); if (keys) { c.drawText(RemoteKeys.supported() ? Ui.tr("Disney e Globoplay: a própria TV controla esses botões.", "Disney and Globoplay: the TV itself controls those buttons.") : Ui.tr("Esses são os botões do controle da TCL: nesta TV, os botões funcionam como sempre.", "These are the TCL remote's buttons: on this TV, the buttons work as they always did."), LIST_X * k + 30 * k, hy + 38 * k, hint); } } private void activate() { Context c = getContext(); if (screen == HOME_CHOICE) { boolean wantFree = focus == 0; boolean already = wantFree ? freeActive() : HomeMode.ORIGINAL.equals(state.optString("mode", HomeMode.ORIGINAL)); if (!already && !busy()) { HomeMode.request(c, wantFree ? HomeMode.FREE : HomeMode.ORIGINAL); pendingAt = SystemClock.uptimeMillis(); } screen = MAIN; focus = 0; } else if (screen == PICKER && pickFor == PICK_CITY) { String[] city = cities.get(pickScroll + focus); Prefs.setCity(city[1], city[2], city[3]); HomeData.get(c).settingsChanged(); focus = CITY_ROW; screen = HOME_SETTINGS; } else if (screen == PICKER) { int at = pickScroll + focus; Prefs.setKeyApp(Prefs.KEYS[pickFor - 1], appPkgs.get(at)); focus = pickFor - 1; screen = REMOTE_KEYS; } else if (screen == SHELF_EDIT) { toggleShelfApp(); invalidate(); return; } else if (screen == REMOTE_KEYS) { openPicker(focus + 1); } else if (screen == HOME_SETTINGS && focus == CITY_ROW) { askCity(); return; } else if (screen == HOME_SETTINGS) { adjust(1); invalidate(); return; } else if (focus == 0) { if (busy()) return; screen = HOME_CHOICE; focus = freeActive() ? 0 : 1; } else if (focus == 1) { pause(); activity.showPreview(); } else if (focus == 2) { screen = HOME_SETTINGS; focus = 0; } else if (focus == 3) { openShelfEditor(); } else if (focus == 4) { HomeMode.ensureRemoteService(c, 0); // the buttons work from inside other apps through this service loadApps(); screen = REMOTE_KEYS; focus = 0; } else { activity.startActivity(new Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); } for (Spring s : pop) s.snap(0); invalidate(); spoken.focusMoved(); } } }