diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 95fdb68..7f7cdc5 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="4" + android:versionName="1.3"> @@ -12,7 +12,7 @@ - + @@ -22,15 +22,15 @@ - - - diff --git a/README.md b/README.md index efbf123..c121d33 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,15 @@ A small native calendar client for Gingerbread (API 9/10). Built for a Samsung G **What it does** -- Agenda of upcoming events grouped by day, colour-coded per calendar, cached offline (SQLite) +- Month view: a 6-week grid with a coloured mark per event (calendar colour), today outlined, + the selected day's events listed below. Swipe or tap the arrows to change month; tap the + month name for today; long-press a day to create an event on it. Months outside the synced + window are fetched from the server when you open them +- Agenda view: upcoming events grouped by day (Menu > View switches; the choice is remembered) +- Interface in Galician (default) or English: Settings > Lingua / Language. Month and weekday + names are built in, since Android 2.3 has no Galician locale data; in Galician weeks start + on Monday. The system date/time pickers follow the phone's own language +- Offline cache (SQLite) of the last 35 days and the next 60 (Settings: "Days ahead") - Create, edit and delete events (title, all-day, start/end, location, notes, alarm) - Repeating events are shown correctly (server-side expansion, with a client-side fallback); they can be deleted on the phone but edited only in Nextcloud @@ -66,6 +74,24 @@ Protocol** on the resource, add the `pp-transport-v1/v2` block to Pangolin's `listen 8443 ssl proxy_protocol;`, `set_real_ip_from ;`, `real_ip_header proxy_protocol;`. Enable both sides together or every connection fails. +### Option C: Nginx Proxy Manager with port forwarding (no Pangolin) + +If your router already forwards 80/443 to Nginx Proxy Manager, the legacy endpoint can live in +NPM itself on its own port. NPM's normal sites on 443 keep their modern TLS settings. + +1. `./bridge/make-cert.sh nxc.anosanube.ovh /data/legacy-caldav`, and note the fingerprint. +2. Copy `bridge/npm/http.conf` to `/data/nginx/custom/http.conf`; if that file already + exists, append the block to it. NPM includes it at the end of its `http` block. +3. In NPM's `docker-compose.yml` add `- '8443:8443'` under `ports`, then `docker compose up -d`. +4. Forward TCP 8443 on the router to the NPM host. +5. Phone: `https://nxc.anosanube.ovh:8443`, then tap Trust if the fingerprint matches. + +It forwards to NPM's own `nxc.anosanube.ovh` proxy host over loopback HTTPS, so Nextcloud's +backend address, HSTS and forced HTTPS need no extra handling. Check from a PC with +`openssl s_client -connect nxc.anosanube.ovh:8443 -tls1 -cipher 'AES128-SHA:@SECLEVEL=0'`. +At home, the phone needs your router to support NAT loopback (it already does if +`https://nxc.anosanube.ovh` works from your Wi-Fi). + ### Option B: home Wi-Fi only (no Pangolin) Use `http://` as the server address. Nothing to set up, but it syncs only at @@ -104,7 +130,8 @@ you should turn `proxy_ssl_verify` on. 3. Open GingerCal, Menu, Settings. In Nextcloud create an app password: Settings, Security, Devices & sessions. Fill in server, user, app password, then **Save and test connection**. -Menu key: Sync, New event, Calendars, Settings. Tap an event for details, Edit and Delete. +Menu key: View (Month/Agenda), New event, Sync, Today, Calendars, Settings. Tap an event for +details, Edit and Delete. If installation fails, check the file arrived whole (53 KB; some browsers save a truncated or renamed `.apk.zip`) and that there are a few MB free on the internal storage. On a PC with @@ -135,9 +162,11 @@ Tests (desktop JVM, need a CalDAV server such as Radicale on :5232 with user `xo javac -d out src/org/gingercal/*.java test/org/gingercal/*.java java -cp out org.gingercal.CoreTest # discovery, sync, recurrence, create/edit/delete java -cp out org.gingercal.RedirectTest # forced-HTTPS / redirect-loop handling (no server needed) +java -cp out org.gingercal.I18nTest # Galician dates, titles and messages ``` -`tools/check_api.py` verifies every framework call in the dex against published Android API +`tools/check_i18n.py` (run by the build) fails if any interface text lacks a Galician +translation or the placeholders differ. `tools/check_api.py` verifies every framework call in the dex against published Android API declarations. ## Layout @@ -145,7 +174,8 @@ declarations. - `src/` CalDAV core, platform-neutral: discovery, PROPFIND/REPORT, iCalendar, sync, pinning - `android/` activities, sync service, alarms, SQLite cache, Apache HttpClient transport - `stubs/` compile-only Android API 10 signatures (never packaged) -- `bridge/` nginx legacy-TLS bridge: config, compose file, certificate script +- `bridge/` legacy-TLS endpoint: standalone nginx (config, compose file), `npm/http.conf` for + Nginx Proxy Manager, certificate script - `res/` app name; the icon is drawn at build time (mdpi + ldpi for the Galaxy Mini) - `tools/` icon, dex disassembler helper, zipalign, dex/API checkers, d8 workaround diff --git a/android/org/gingercal/App.java b/android/org/gingercal/App.java index 3b9fd9b..d17a745 100644 --- a/android/org/gingercal/App.java +++ b/android/org/gingercal/App.java @@ -32,6 +32,9 @@ import org.gingercal.Model.Ev; public final class App { private App() {} + /** Days of past events kept locally, so this and last month's grid work offline. */ + public static final int BACK_DAYS = 35; + public static final int[] INTERVALS = {0, 1, 3, 6, 12, 24}; public static final int[] ALARMS = {-1, 0, 5, 10, 15, 30, 60, 120, 1440}; @@ -121,14 +124,14 @@ public final class App { public static String describe(Throwable t) { if (t instanceof DavException || t instanceof DavException.UntrustedCert) return t.getMessage(); if (t instanceof UnknownHostException) { - return "Can't resolve " + t.getMessage() + ". No connection, or a typo in the server address."; + return L.f("Can't resolve %s. No connection, or a typo in the server address.", t.getMessage()); } - if (t instanceof ConnectException) return "Can't connect to the server (" + t.getMessage() + "). Wrong port, or the bridge is down?"; - if (t instanceof SocketTimeoutException) return "The server took too long to answer."; - if (t instanceof SSLException) return "TLS error: " + t.getMessage(); - if (t instanceof IOException) return "Network error: " + t; - if (t instanceof OutOfMemoryError) return "Out of memory. Lower 'Days ahead' in Settings."; - return "Error: " + t; + if (t instanceof ConnectException) return L.f("Can't connect to the server (%s). Wrong port, or the bridge is down?", t.getMessage()); + if (t instanceof SocketTimeoutException) return L.t("The server took too long to answer."); + if (t instanceof SSLException) return L.f("TLS error: %s", t.getMessage()); + if (t instanceof IOException) return L.f("Network error: %s", t); + if (t instanceof OutOfMemoryError) return L.t("Out of memory. Lower 'Days ahead' in Settings."); + return L.f("Error: %s", t); } public static void toast(Context c, String s) { @@ -141,22 +144,19 @@ public final class App { public static void askTrust(Activity a, final DavException.UntrustedCert u, final Trusted cb) { String msg = (u.pinMismatch - ? "WARNING: the server certificate changed since you trusted it. That happens when you " - + "regenerate the bridge certificate - or when someone is intercepting the connection.\n\n" - : "This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, " - + "or for Let's Encrypt on Android 2.3).\n\n") - + "Subject: " + u.subject + "\nIssuer: " + u.issuer + "\nExpires: " + u.expires - + "\n\nSHA-256:\n" + u.fingerprint - + "\n\nOnly trust it if this matches the fingerprint printed on your server."; + ? L.t("WARNING: the server certificate changed since you trusted it. That happens when you regenerate the bridge certificate - or when someone is intercepting the connection.\n\n") + : L.t("This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, or for Let's Encrypt on Android 2.3).\n\n")) + + L.f("Subject: %s\nIssuer: %s\nExpires: %s\n\nSHA-256:\n%s\n\nOnly trust it if this matches the fingerprint printed on your server.", + u.subject, u.issuer, u.expires, u.fingerprint); new AlertDialog.Builder(a) - .setTitle(u.pinMismatch ? "Certificate changed!" : "Trust this certificate?") + .setTitle(u.pinMismatch ? L.t("Certificate changed!") : L.t("Trust this certificate?")) .setMessage(msg) - .setPositiveButton("Trust", new DialogInterface.OnClickListener() { + .setPositiveButton(L.t("Trust"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { cb.trusted(u.fingerprint); } }) - .setNegativeButton("Cancel", null) + .setNegativeButton(L.t("Cancel"), null) .show(); } @@ -164,12 +164,12 @@ public final class App { public static String time(Context c, long t) { String p = android.text.format.DateFormat.is24HourFormat(c) ? "HH:mm" : "h:mma"; - return new SimpleDateFormat(p, Locale.getDefault()).format(new Date(t)); + return L.df(p).format(new Date(t)); } public static String day(long t) { boolean thisYear = year(t) == year(System.currentTimeMillis()); - return new SimpleDateFormat(thisYear ? "EEE d MMM" : "EEE d MMM yyyy", Locale.getDefault()).format(new Date(t)); + return L.df(thisYear ? "EEE d MMM" : "EEE d MMM yyyy").format(new Date(t)); } static int year(long t) { @@ -180,16 +180,16 @@ public final class App { public static String dayHeader(long dayStart) { long today = ICal.startOfDay(System.currentTimeMillis()); - if (dayStart == today) return "Today · " + day(dayStart); - if (dayStart == ICal.addDays(today, 1)) return "Tomorrow · " + day(dayStart); + if (dayStart == today) return L.f("Today \u00b7 %s", day(dayStart)); + if (dayStart == ICal.addDays(today, 1)) return L.f("Tomorrow \u00b7 %s", day(dayStart)); return day(dayStart); } public static String when(Context c, Ev e) { if (e.allDay) { long lastDay = ICal.addDays(e.end, -1); - if (lastDay <= e.start) return "All day"; - return "All day, until " + day(lastDay); + if (lastDay <= e.start) return L.t("All day"); + return L.f("All day, until %s", day(lastDay)); } String s = time(c, e.start); if (e.end <= e.start) return s; @@ -200,11 +200,11 @@ public final class App { } public static String alarmLabel(int m) { - if (m < 0) return "No reminder"; - if (m == 0) return "At start time"; - if (m < 60) return m + " min before"; - if (m < 1440) return (m / 60) + " h before"; - return (m / 1440) + " day before"; + if (m < 0) return L.t("No reminder"); + if (m == 0) return L.t("At start time"); + if (m < 60) return L.f("%d min before", m); + if (m < 1440) return L.f("%d h before", m / 60); + return L.f("%d day before", m / 1440); } // --------------------------------------------------------------- views diff --git a/android/org/gingercal/CalendarsActivity.java b/android/org/gingercal/CalendarsActivity.java index 7307fe9..2a0e7df 100644 --- a/android/org/gingercal/CalendarsActivity.java +++ b/android/org/gingercal/CalendarsActivity.java @@ -24,13 +24,13 @@ public class CalendarsActivity extends Activity { @Override protected void onCreate(Bundle b) { super.onCreate(b); - setTitle("Calendars"); + setTitle(L.t("Calendars")); cals.addAll(Db.get(this).calendarList()); LinearLayout f = App.vbox(this); int p = App.dp(this, 8); f.setPadding(p, p, p, p); if (cals.isEmpty()) { - f.addView(App.text(this, "No calendars yet. Set up the account in Settings and sync.", 14, 0xFFFFFFFF)); + f.addView(App.text(this, L.t("No calendars yet. Set up the account in Settings and sync."), 14, 0xFFFFFFFF)); } for (Cal c : cals) { LinearLayout row = new LinearLayout(this); @@ -41,7 +41,7 @@ public class CalendarsActivity extends Activity { lp.setMargins(0, App.dp(this, 6), App.dp(this, 4), App.dp(this, 6)); row.addView(bar, lp); CheckBox cb = new CheckBox(this); - cb.setText(c.name + (c.writable ? "" : " (read-only)")); + cb.setText(c.name + (c.writable ? "" : " " + L.t("(read-only)"))); cb.setChecked(c.enabled); row.addView(cb, new LinearLayout.LayoutParams(-1, -2)); boxes.add(cb); @@ -62,9 +62,9 @@ public class CalendarsActivity extends Activity { void label() { String def = App.get(this, "defcal"); - String name = "first writable"; + String name = L.t("first writable"); for (Cal c : cals) if (c.href.equals(def)) name = c.name; - defBtn.setText("New events go to: " + name); + defBtn.setText(L.f("New events go to: %s", name)); } void pickDefault() { @@ -72,7 +72,7 @@ public class CalendarsActivity extends Activity { for (Cal c : cals) if (c.writable) w.add(c); String[] names = new String[w.size()]; for (int i = 0; i < names.length; i++) names[i] = w.get(i).name; - new AlertDialog.Builder(this).setTitle("Default calendar") + new AlertDialog.Builder(this).setTitle(L.t("Default calendar")) .setItems(names, new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int which) { App.prefs(CalendarsActivity.this).edit().putString("defcal", w.get(which).href).commit(); diff --git a/android/org/gingercal/EditActivity.java b/android/org/gingercal/EditActivity.java index dade074..e636f10 100644 --- a/android/org/gingercal/EditActivity.java +++ b/android/org/gingercal/EditActivity.java @@ -10,7 +10,9 @@ import android.app.AlertDialog; import android.app.DatePickerDialog; import android.app.ProgressDialog; import android.app.TimePickerDialog; +import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; import android.view.View; @@ -41,39 +43,51 @@ public class EditActivity extends Activity { @Override protected void onCreate(Bundle b) { super.onCreate(b); - long id = getIntent().getLongExtra("id", -1); - if (id >= 0) editing = Db.get(this).event(id); - setTitle(editing == null ? "New event" : "Edit event"); + // Events come by value: month view can show events fetched live that aren't in the DB. + String href = getIntent().getStringExtra("href"); + if (href != null) { + editing = new Ev(); + editing.href = href; + editing.cal = str("cal"); + editing.etag = str("etag"); + editing.title = str("title"); + editing.location = str("location"); + editing.descr = str("descr"); + editing.start = getIntent().getLongExtra("start", 0); + editing.end = getIntent().getLongExtra("end", 0); + editing.allDay = getIntent().getBooleanExtra("allDay", false); + } + setTitle(editing == null ? L.t("New event") : L.t("Edit event")); for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) cals.add(c); LinearLayout f = App.vbox(this); int p = App.dp(this, 8); f.setPadding(p, p, p, p); - title = field(f, "Title", false); + title = field(f, L.t("Title"), false); allDay = new CheckBox(this); - allDay.setText("All day"); + allDay.setText(L.t("All day")); allDay.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { refresh(); } }); f.addView(allDay); - f.addView(label("Starts")); + f.addView(label(L.t("Starts"))); startDate = new Button(this); startTime = new Button(this); f.addView(pair(startDate, startTime)); - f.addView(label("Ends")); + f.addView(label(L.t("Ends"))); endDate = new Button(this); endTime = new Button(this); f.addView(pair(endDate, endTime)); - location = field(f, "Location", false); - notes = field(f, "Notes", true); + location = field(f, L.t("Location"), false); + notes = field(f, L.t("Notes"), true); alarmBtn = new Button(this); f.addView(alarmBtn, new LinearLayout.LayoutParams(-1, -2)); calBtn = new Button(this); f.addView(calBtn, new LinearLayout.LayoutParams(-1, -2)); save = new Button(this); - save.setText("Save"); + save.setText(L.t("Save")); f.addView(save, new LinearLayout.LayoutParams(-1, -2)); ScrollView sv = new ScrollView(this); sv.addView(f); @@ -117,7 +131,15 @@ public class EditActivity extends Activity { if (editing == null) { long now = System.currentTimeMillis(); - start.setTimeInMillis(now - now % ICal.HOUR + ICal.HOUR); + long day = getIntent().getLongExtra("day", -1); + if (day >= 0 && day != ICal.startOfDay(now)) { + start.setTimeInMillis(day); // a day picked in the month view: 09:00 there + start.set(Calendar.HOUR_OF_DAY, 9); + } else { + start.setTimeInMillis(now); // next full hour + start.add(Calendar.HOUR_OF_DAY, 1); + } + start.set(Calendar.MINUTE, 0); start.set(Calendar.SECOND, 0); start.set(Calendar.MILLISECOND, 0); end.setTimeInMillis(start.getTimeInMillis() + ICal.HOUR); @@ -134,6 +156,25 @@ public class EditActivity extends Activity { } } + String str(String k) { + String v = getIntent().getStringExtra(k); + return v == null ? "" : v; + } + + static Intent editIntent(Context c, Ev e) { + Intent i = new Intent(c, EditActivity.class); + i.putExtra("href", e.href); + i.putExtra("cal", e.cal); + i.putExtra("etag", e.etag); + i.putExtra("title", e.title); + i.putExtra("location", e.location); + i.putExtra("descr", e.descr); + i.putExtra("start", e.start); + i.putExtra("end", e.end); + i.putExtra("allDay", e.allDay); + return i; + } + EditText field(LinearLayout f, String hint, boolean multi) { EditText e = new EditText(this); e.setHint(hint); @@ -181,10 +222,10 @@ public class EditActivity extends Activity { endTime.setText(App.time(this, end.getTimeInMillis())); startTime.setVisibility(ad ? View.GONE : View.VISIBLE); endTime.setVisibility(ad ? View.GONE : View.VISIBLE); - alarmBtn.setText("Alarm: " + App.alarmLabel(alarm)); + alarmBtn.setText(L.f("Alarm: %s", App.alarmLabel(alarm))); String name = "?"; for (Cal c : Db.get(this).calendarList()) if (c.href.equals(calHref)) name = c.name; - calBtn.setText("Calendar: " + name); + calBtn.setText(L.f("Calendar: %s", name)); } void pickDate(final Calendar c, final boolean isStart) { @@ -214,7 +255,7 @@ public class EditActivity extends Activity { void pickAlarm() { String[] labels = new String[App.ALARMS.length]; for (int i = 0; i < labels.length; i++) labels[i] = App.alarmLabel(App.ALARMS[i]); - new AlertDialog.Builder(this).setTitle("Alarm (saved in the event)") + new AlertDialog.Builder(this).setTitle(L.t("Alarm (saved in the event)")) .setItems(labels, new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { alarm = App.ALARMS[w]; @@ -226,7 +267,7 @@ public class EditActivity extends Activity { void pickCalendar() { String[] names = new String[cals.size()]; for (int i = 0; i < names.length; i++) names[i] = cals.get(i).name; - new AlertDialog.Builder(this).setTitle("Calendar") + new AlertDialog.Builder(this).setTitle(L.t("Calendar")) .setItems(names, new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { calHref = cals.get(w).href; @@ -250,15 +291,15 @@ public class EditActivity extends Activity { e = end.getTimeInMillis() - end.getTimeInMillis() % 60000L; } if (e < s || (ad && e <= s)) { - App.toast(this, "The end is before the start."); + App.toast(this, L.t("The end is before the start.")); return; } if (calHref == null) { - App.toast(this, "No writable calendar. Sync first."); + App.toast(this, L.t("No writable calendar. Sync first.")); return; } String t = txt(title); - new SaveTask(t.length() > 0 ? t : "(no title)", txt(location), notes.getText().toString().trim(), s, e, ad).execute(); + new SaveTask(t.length() > 0 ? t : L.t("(no title)"), txt(location), notes.getText().toString().trim(), s, e, ad).execute(); } // ----------------------------------------------------------- network @@ -268,7 +309,7 @@ public class EditActivity extends Activity { @Override protected void onPreExecute() { - pd = ProgressDialog.show(EditActivity.this, null, "Loading…"); + pd = ProgressDialog.show(EditActivity.this, null, L.t("Loading\u2026")); } @Override @@ -291,19 +332,19 @@ public class EditActivity extends Activity { } catch (RuntimeException ignored) { } if (r instanceof Throwable) { - fail("Can't load event", App.describe((Throwable) r)); + fail(L.t("Can't load event"), App.describe((Throwable) r)); return; } String[] got = (String[]) r; ics = got[0]; etag = got[1] != null ? got[1] : editing.etag; if (ICal.isRecurring(ics)) { - fail("Repeating event", "Repeating events can't be edited on the phone yet. Edit the series in Nextcloud."); + fail(L.t("Repeating event"), L.t("Repeating events can't be edited on the phone yet. Edit the series in Nextcloud.")); return; } List vs = ICal.parse(ics); if (vs.isEmpty()) { - fail("Can't edit", "The server returned no event data."); + fail(L.t("Can't edit"), L.t("The server returned no event data.")); return; } ICal.VEvent v = vs.get(0); @@ -316,7 +357,7 @@ public class EditActivity extends Activity { void fail(String t, String msg) { new AlertDialog.Builder(this).setTitle(t).setMessage(msg) - .setPositiveButton("OK", new DialogInterface.OnClickListener() { + .setPositiveButton(L.t("OK"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { finish(); } @@ -340,7 +381,7 @@ public class EditActivity extends Activity { @Override protected void onPreExecute() { - pd = ProgressDialog.show(EditActivity.this, null, "Saving…"); + pd = ProgressDialog.show(EditActivity.this, null, L.t("Saving\u2026")); } @Override @@ -370,13 +411,13 @@ public class EditActivity extends Activity { } catch (RuntimeException ignored) { } if (x != null) { - new AlertDialog.Builder(EditActivity.this).setTitle("Not saved") - .setMessage(App.describe(x)).setPositiveButton("OK", null).show(); + new AlertDialog.Builder(EditActivity.this).setTitle(L.t("Not saved")) + .setMessage(App.describe(x)).setPositiveButton(L.t("OK"), null).show(); return; } if (editing == null) App.prefs(EditActivity.this).edit().putString("defcal", calHref).commit(); App.requestSync(EditActivity.this, true, false); - App.toast(EditActivity.this, "Saved"); + App.toast(EditActivity.this, L.t("Saved")); finish(); } } diff --git a/android/org/gingercal/GingerApp.java b/android/org/gingercal/GingerApp.java new file mode 100644 index 0000000..597a660 --- /dev/null +++ b/android/org/gingercal/GingerApp.java @@ -0,0 +1,12 @@ +package org.gingercal; + +import android.app.Application; + +/** Runs before any screen, service or alarm: sets the interface language (Galician by default). */ +public class GingerApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + L.gl = !"en".equals(getSharedPreferences("gingercal", 0).getString("lang", "gl")); + } +} diff --git a/android/org/gingercal/MainActivity.java b/android/org/gingercal/MainActivity.java index ebf75f8..fbadb79 100644 --- a/android/org/gingercal/MainActivity.java +++ b/android/org/gingercal/MainActivity.java @@ -1,7 +1,12 @@ package org.gingercal; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import android.app.Activity; import android.app.AlertDialog; @@ -11,6 +16,7 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; +import android.content.res.Configuration; import android.os.AsyncTask; import android.os.Bundle; import android.view.Menu; @@ -26,15 +32,23 @@ import android.widget.TextView; import org.gingercal.Model.Cal; import org.gingercal.Model.Ev; -/** Agenda: upcoming events grouped by day. */ -public class MainActivity extends Activity { - static final int M_SYNC = 1, M_NEW = 2, M_CALS = 3, M_SETTINGS = 4; +/** Main screen: a month grid with the selected day's events, or the upcoming-events agenda. */ +public class MainActivity extends Activity implements MonthView.Listener { + static final int M_SYNC = 1, M_NEW = 2, M_CALS = 3, M_SETTINGS = 4, M_VIEW = 5, M_TODAY = 6; - TextView status; - ListView list; - final List rows = new ArrayList(); - final Agenda adapter = new Agenda(); - boolean askedSetup; + LinearLayout root, body; + TextView status, monthTitle; + ListView agendaList, dayList; + MonthView monthView; + final List agendaRows = new ArrayList(), dayRows = new ArrayList(); + final Rows agendaAdapter = new Rows(agendaRows), dayAdapter = new Rows(dayRows); + boolean askedSetup, monthMode, uiGl; + long selectedDay; + List monthEvents = new ArrayList(); + /** Months outside the synced window, fetched from the server on demand (key: grid start). */ + final Map> fetched = new HashMap>(); + long fetching = -1; + String fetchError; final BroadcastReceiver rx = new BroadcastReceiver() { @Override @@ -44,7 +58,7 @@ public class MainActivity extends Activity { showStatus(); return; } - reload(); + reload(true); String fp = i.getStringExtra("fp"); if (fp != null) { DavException.UntrustedCert u = new DavException.UntrustedCert(fp, i.getStringExtra("subject"), @@ -62,29 +76,115 @@ public class MainActivity extends Activity { @Override protected void onCreate(Bundle b) { super.onCreate(b); - setTitle("GingerCal"); - LinearLayout root = App.vbox(this); - status = App.text(this, "", 12, 0xFFAAAAAA); - int p = App.dp(this, 6); + requestWindowFeature(1 /* Window.FEATURE_NO_TITLE: every pixel counts at 240x320 */); + uiGl = L.gl; + monthMode = !"agenda".equals(App.get(this, "view")); + selectedDay = ICal.startOfDay(System.currentTimeMillis()); + + root = App.vbox(this); + status = App.text(this, "", 11, 0xFFAAAAAA); + int p = App.dp(this, 4); status.setPadding(p * 2, p, p * 2, p); status.setBackgroundColor(0xFF1C1C1C); root.addView(status, new LinearLayout.LayoutParams(-1, -2)); - list = new ListView(this); - list.setAdapter(adapter); - list.setOnItemClickListener(new AdapterView.OnItemClickListener() { + + agendaList = list(agendaAdapter, agendaRows); + dayList = list(dayAdapter, dayRows); + monthView = new MonthView(this); + monthView.listener = this; + monthView.setMonth(selectedDay); + monthView.setSelected(selectedDay); + monthTitle = App.text(this, "", 15, 0xFFFFFFFF); + monthTitle.setGravity(17 /* Gravity.CENTER */); + monthTitle.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + goToday(); + } + }); + buildBody(); + setContentView(root); + App.scheduleSync(this); + } + + ListView list(Rows adapter, final List rows) { + ListView l = new ListView(this); + l.setAdapter(adapter); + l.setOnItemClickListener(new AdapterView.OnItemClickListener() { public void onItemClick(AdapterView parent, View v, int pos, long id) { Object o = rows.get(pos); if (o instanceof Ev) showEvent((Ev) o); } }); - root.addView(list, new LinearLayout.LayoutParams(-1, -1)); - setContentView(root); - App.scheduleSync(this); + return l; + } + + static void detach(View v) { + // Views can only have one parent; LinearLayout is the only container we use. + Object parent = v.getParent(); + if (parent instanceof LinearLayout) ((LinearLayout) parent).removeView(v); + } + + /** (Re)builds the area under the status line for the current mode and orientation. */ + void buildBody() { + if (body != null) root.removeView(body); + detach(agendaList); + detach(dayList); + detach(monthView); + detach(monthTitle); + body = App.vbox(this); + if (!monthMode) { + body.addView(agendaList, new LinearLayout.LayoutParams(-1, -1)); + } else { + LinearLayout nav = new LinearLayout(this); + nav.setOrientation(LinearLayout.HORIZONTAL); + nav.setBackgroundColor(0xFF1C2329); + nav.addView(navArrow("\u00ab", -1), new LinearLayout.LayoutParams(App.dp(this, 56), App.dp(this, 34))); + nav.addView(monthTitle, new LinearLayout.LayoutParams(0, App.dp(this, 34), 1f)); + nav.addView(navArrow("\u00bb", 1), new LinearLayout.LayoutParams(App.dp(this, 56), App.dp(this, 34))); + body.addView(nav, new LinearLayout.LayoutParams(-1, -2)); + boolean landscape = getResources().getDisplayMetrics().widthPixels + > getResources().getDisplayMetrics().heightPixels; + if (landscape) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.addView(monthView, new LinearLayout.LayoutParams(0, -1, 1.25f)); + row.addView(dayList, new LinearLayout.LayoutParams(0, -1, 1f)); + body.addView(row, new LinearLayout.LayoutParams(-1, -1)); + } else { + body.addView(monthView, new LinearLayout.LayoutParams(-1, -2)); + body.addView(dayList, new LinearLayout.LayoutParams(-1, -1)); + } + } + root.addView(body, new LinearLayout.LayoutParams(-1, -1)); + } + + TextView navArrow(String label, final int delta) { + TextView b = App.text(this, label, 20, 0xFFD66E2A); // « »: Latin-1, always in the 2.3 font + b.setGravity(17 /* Gravity.CENTER */); + int sel = App.sysDrawable(this, "list_selector_background"); + if (sel != 0) b.setBackgroundResource(sel); // press highlight + b.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + monthSwiped(delta); + } + }); + return b; + } + + @Override + public void onConfigurationChanged(Configuration c) { + super.onConfigurationChanged(c); + buildBody(); } @Override protected void onResume() { super.onResume(); + if (uiGl != L.gl) { // language switched in Settings: rebuild every label + finish(); + startActivity(new Intent(this, MainActivity.class)); + return; + } registerReceiver(rx, new IntentFilter(SyncService.STATE)); if (!App.configured(this)) { showStatus(); @@ -94,7 +194,9 @@ public class MainActivity extends Activity { } return; } - reload(); + long now = ICal.startOfDay(System.currentTimeMillis()); + if (now != monthView.today) monthView.setMonth(monthView.days[15]); // date rolled over + reload(true); long last = App.prefs(this).getLong("lastsync", 0); if (!SyncService.running && System.currentTimeMillis() - last > 15 * ICal.MIN) { App.requestSync(this, true, false); @@ -109,74 +211,264 @@ public class MainActivity extends Activity { void showStatus() { if (!App.configured(this)) { - status.setText("Not set up yet: press Menu > Settings."); + status.setText(L.t("Not set up yet: press Menu > Settings.")); return; } if (SyncService.running) { - status.setText("Syncing…"); + status.setText(L.t("Syncing\u2026")); status.setTextColor(0xFFAAAAAA); return; } + if (monthMode && fetching >= 0) { + status.setText(L.f("Loading %s from the server\u2026", monthTitle.getText())); + status.setTextColor(0xFFAAAAAA); + return; + } + if (monthMode && fetchError != null) { + status.setText(L.f("Couldn't load this month: %s", fetchError)); + status.setTextColor(0xFFFF8A80); + return; + } long last = App.prefs(this).getLong("lastsync", 0); boolean err = App.prefs(this).getBoolean("error", false); String s = App.get(this, "status"); - String when = last == 0 ? "never" : App.day(last) + " " + App.time(this, last); + String when = last == 0 ? L.t("never") : App.day(last) + " " + App.time(this, last); if (err) { - status.setText("Sync failed: " + s + "\nLast good sync: " + when); + status.setText(L.f("Sync failed: %s\nLast good sync: %s", s, when)); status.setTextColor(0xFFFF8A80); } else { - status.setText("Synced " + when + (s.length() > 0 ? " · " + s : "")); + status.setText(L.f("Synced %s", when) + (s.length() > 0 ? " \u00b7 " + s : "")); status.setTextColor(0xFFAAAAAA); } } - void reload() { - rows.clear(); + void reload(boolean allowFetch) { + if (monthMode) loadMonth(allowFetch); + else loadAgenda(); + showStatus(); + } + + // -------------------------------------------------------------- agenda + + void loadAgenda() { + agendaRows.clear(); long today = ICal.startOfDay(System.currentTimeMillis()); List evs = Db.get(this).agenda(today, ICal.addDays(today, App.ahead(this) + 1)); long lastDay = -1; for (Ev e : evs) { long d = Math.max(ICal.startOfDay(e.start), today); if (d != lastDay) { - rows.add(Long.valueOf(d)); + agendaRows.add(Long.valueOf(d)); lastDay = d; } - rows.add(e); + agendaRows.add(e); + } + if (agendaRows.isEmpty() && App.configured(this)) { + agendaRows.add(L.t("No upcoming events. Menu > New event, or Menu > Sync.")); + } + agendaAdapter.notifyDataSetChanged(); + } + + // --------------------------------------------------------------- month + + void loadMonth(boolean allowFetch) { + long gs = monthView.gridStart(), ge = monthView.gridEnd(); + monthTitle.setText(L.monthTitle(monthView.days[15])); + long wf = App.prefs(this).getLong("win_from", 0), wt = App.prefs(this).getLong("win_to", 0); + if (wf > 0 && gs >= wf && ge <= wt) { + fetchError = null; + showMonthEvents(Db.get(this).agenda(gs, ge)); + } else { + List cached = fetched.get(Long.valueOf(gs)); + showMonthEvents(cached != null ? cached : Db.get(this).agenda(gs, ge)); + if (allowFetch && fetching != gs && App.configured(this)) new FetchTask(gs, ge).execute(); + } + } + + void showMonthEvents(List evs) { + monthEvents = evs; + Map> marks = new HashMap>(); + long gs = monthView.gridStart(), ge = monthView.gridEnd(); + for (Ev e : evs) { + long d = Math.max(ICal.startOfDay(e.start), gs); + for (int guard = 0; d < ge && guard < 42; guard++) { + if (!(occupies(e, d))) break; + List l = marks.get(Long.valueOf(d)); + if (l == null) { + l = new ArrayList(); + marks.put(Long.valueOf(d), l); + } + l.add(Integer.valueOf(e.color)); + d = ICal.addDays(d, 1); + } + } + monthView.setMarks(marks); + showDay(); + } + + static boolean occupies(Ev e, long d) { + return ICal.onDay(e.start, e.end, d); + } + + void showDay() { + dayRows.clear(); + int n = 0; + for (Ev e : monthEvents) { + if (occupies(e, selectedDay)) { + dayRows.add(e); + n++; + } + } + if (n == 0) dayRows.add(L.f("Nothing on %s. Long-press a day to add an event.", App.day(selectedDay))); + dayAdapter.notifyDataSetChanged(); + } + + public void daySelected(long day) { + selectedDay = day; + monthView.setSelected(day); + if (!monthView.inMonth(day)) { + monthView.setMonth(day); + reload(true); + } else { + showDay(); + } + } + + public void dayLongPressed(long day) { + daySelected(day); + newEvent(day); + } + + public void monthSwiped(int delta) { + java.util.Calendar c = java.util.Calendar.getInstance(); + c.setTimeInMillis(monthView.days[15]); + c.set(java.util.Calendar.DAY_OF_MONTH, 1); + c.add(java.util.Calendar.MONTH, delta); + long first = c.getTimeInMillis(); + monthView.setMonth(first); + long today = ICal.startOfDay(System.currentTimeMillis()); + selectedDay = monthView.inMonth(today) ? today : ICal.startOfDay(first); + monthView.setSelected(selectedDay); + fetchError = null; + reload(true); + } + + void goToday() { + selectedDay = ICal.startOfDay(System.currentTimeMillis()); + monthView.setMonth(selectedDay); + monthView.setSelected(selectedDay); + reload(true); + } + + /** Loads a month outside the synced window straight from the server. */ + class FetchTask extends AsyncTask { + final long gs, ge; + + FetchTask(long gs, long ge) { + this.gs = gs; + this.ge = ge; + } + + @Override + protected void onPreExecute() { + fetching = gs; + fetchError = null; + showStatus(); + } + + @Override + protected Object doInBackground(Void... v) { + Dav d = null; + try { + d = App.dav(MainActivity.this); + List out = new ArrayList(); + for (Cal c : Db.get(MainActivity.this).calendarList()) { + if (!c.enabled) continue; + for (Ev e : d.events(c, gs, ge)) { + e.color = c.color; + e.writable = c.writable; + out.add(e); + } + } + java.util.Collections.sort(out, new java.util.Comparator() { + public int compare(Ev a, Ev b) { + return a.start < b.start ? -1 : a.start > b.start ? 1 : 0; + } + }); + return out; + } catch (Throwable t) { + return t; + } finally { + if (d != null) d.close(); + } + } + + @SuppressWarnings("unchecked") + @Override + protected void onPostExecute(Object r) { + if (fetching == gs) fetching = -1; + if (r instanceof Throwable) { + fetchError = App.describe((Throwable) r); + } else { + fetched.put(Long.valueOf(gs), (List) r); + if (monthMode && monthView.gridStart() == gs) showMonthEvents((List) r); + } + showStatus(); } - if (rows.isEmpty() && App.configured(this)) rows.add("No upcoming events. Menu > New event, or Menu > Sync."); - adapter.notifyDataSetChanged(); - showStatus(); } // ---------------------------------------------------------------- menu @Override public boolean onCreateOptionsMenu(Menu m) { - icon(m.add(0, M_SYNC, 0, "Sync"), "ic_menu_refresh"); - icon(m.add(0, M_NEW, 1, "New event"), "ic_menu_add"); - icon(m.add(0, M_CALS, 2, "Calendars"), "ic_menu_agenda"); - icon(m.add(0, M_SETTINGS, 3, "Settings"), "ic_menu_preferences"); + icon(m.add(0, M_VIEW, 0, L.t("View")), "ic_menu_month"); + icon(m.add(0, M_NEW, 1, L.t("New event")), "ic_menu_add"); + icon(m.add(0, M_SYNC, 2, L.t("Sync")), "ic_menu_refresh"); + icon(m.add(0, M_TODAY, 3, L.t("Today")), "ic_menu_today"); + icon(m.add(0, M_CALS, 4, L.t("Calendars")), "ic_menu_agenda"); + icon(m.add(0, M_SETTINGS, 5, L.t("Settings")), "ic_menu_preferences"); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu m) { + MenuItem v = m.findItem(M_VIEW); + if (v != null) { + v.setTitle(monthMode ? L.t("Agenda") : L.t("Month")); + icon(v, monthMode ? "ic_menu_agenda" : "ic_menu_month"); + } return true; } void icon(MenuItem i, String name) { int id = App.sysDrawable(this, name); + if (id == 0) id = App.sysDrawable(this, "ic_menu_my_calendar"); if (id != 0) i.setIcon(id); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { + case M_VIEW: + monthMode = !monthMode; + App.prefs(this).edit().putString("view", monthMode ? "month" : "agenda").commit(); + buildBody(); + reload(true); + return true; + case M_TODAY: + if (monthMode) goToday(); + else agendaList.setSelection(0); + return true; case M_SYNC: - if (App.configured(this)) App.requestSync(this, true, false); - else startActivity(new Intent(this, SettingsActivity.class)); + if (App.configured(this)) { + fetched.clear(); + App.requestSync(this, true, false); + } else { + startActivity(new Intent(this, SettingsActivity.class)); + } return true; case M_NEW: - if (writableCalendars().isEmpty()) { - App.toast(this, "Sync first: no writable calendar yet."); - } else { - startActivity(new Intent(this, EditActivity.class)); - } + newEvent(monthMode ? selectedDay : -1); return true; case M_CALS: startActivity(new Intent(this, CalendarsActivity.class)); @@ -188,6 +480,17 @@ public class MainActivity extends Activity { return false; } + void newEvent(long day) { + if (writableCalendars().isEmpty()) { + App.toast(this, L.t("Sync first: no writable calendar yet.")); + return; + } + Intent i = new Intent(this, EditActivity.class); + if (day >= 0) i.putExtra("day", day); + fetched.clear(); + startActivity(i); + } + List writableCalendars() { List out = new ArrayList(); for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) out.add(c); @@ -202,23 +505,22 @@ public class MainActivity extends Activity { if (e.location.length() > 0) m.append("\n\n").append(e.location); if (e.descr.length() > 0) m.append("\n\n").append(e.descr); Cal cal = Db.get(this).calendars().get(e.cal); - if (cal != null) m.append("\n\nCalendar: ").append(cal.name); - if (e.recurring) m.append("\n\nRepeating event: edit the series in Nextcloud. Delete removes the whole series."); + if (cal != null) m.append("\n\n").append(L.f("Calendar: %s", cal.name)); + if (e.recurring) m.append("\n\n").append(L.t("Repeating event: edit the series in Nextcloud. Delete removes the whole series.")); AlertDialog.Builder b = new AlertDialog.Builder(this) - .setTitle(e.title.length() > 0 ? e.title : "(no title)") + .setTitle(e.title.length() > 0 ? e.title : L.t("(no title)")) .setMessage(m.toString()) - .setNegativeButton("Close", null); + .setNegativeButton(L.t("Close"), null); if (e.writable && !e.recurring) { - b.setPositiveButton("Edit", new DialogInterface.OnClickListener() { + b.setPositiveButton(L.t("Edit"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { - Intent i = new Intent(MainActivity.this, EditActivity.class); - i.putExtra("id", e.id); - startActivity(i); + fetched.clear(); + startActivity(EditActivity.editIntent(MainActivity.this, e)); } }); } if (e.writable) { - b.setNeutralButton("Delete", new DialogInterface.OnClickListener() { + b.setNeutralButton(L.t("Delete"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { confirmDelete(e); } @@ -229,14 +531,14 @@ public class MainActivity extends Activity { void confirmDelete(final Ev e) { new AlertDialog.Builder(this) - .setTitle("Delete event?") - .setMessage(e.recurring ? "This deletes EVERY occurrence of \"" + e.title + "\"." : "\"" + e.title + "\" will be deleted on the server.") - .setPositiveButton("Delete", new DialogInterface.OnClickListener() { + .setTitle(L.t("Delete event?")) + .setMessage(e.recurring ? L.f("This deletes EVERY occurrence of \"%s\".", e.title) : L.f("\"%s\" will be deleted on the server.", e.title)) + .setPositiveButton(L.t("Delete"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { new DeleteTask(e).execute(); } }) - .setNegativeButton("Cancel", null) + .setNegativeButton(L.t("Cancel"), null) .show(); } @@ -250,7 +552,7 @@ public class MainActivity extends Activity { @Override protected void onPreExecute() { - pd = ProgressDialog.show(MainActivity.this, null, "Deleting…"); + pd = ProgressDialog.show(MainActivity.this, null, L.t("Deleting\u2026")); } @Override @@ -274,16 +576,21 @@ public class MainActivity extends Activity { } catch (RuntimeException ignored) { } if (t != null) { - new AlertDialog.Builder(MainActivity.this).setTitle("Delete failed") - .setMessage(App.describe(t)).setPositiveButton("OK", null).show(); + new AlertDialog.Builder(MainActivity.this).setTitle(L.t("Delete failed")) + .setMessage(App.describe(t)).setPositiveButton(L.t("OK"), null).show(); return; } - App.requestSync(MainActivity.this, true, false); - for (int i = rows.size() - 1; i >= 0; i--) { - Object o = rows.get(i); - if (o instanceof Ev && ((Ev) o).href.equals(e.href)) rows.remove(i); + // Hide it right away; the sync that follows brings the cache in line. + List keep = new ArrayList(); + for (Ev x : monthEvents) if (!x.href.equals(e.href)) keep.add(x); + fetched.clear(); + for (int i = agendaRows.size() - 1; i >= 0; i--) { + Object o = agendaRows.get(i); + if (o instanceof Ev && ((Ev) o).href.equals(e.href)) agendaRows.remove(i); } - adapter.notifyDataSetChanged(); + agendaAdapter.notifyDataSetChanged(); + if (monthMode) showMonthEvents(keep); + App.requestSync(MainActivity.this, true, false); } } @@ -294,7 +601,14 @@ public class MainActivity extends Activity { TextView time, title, where; } - class Agenda extends BaseAdapter { + /** Day headers (Long), events (Ev) and hint lines (String). */ + class Rows extends BaseAdapter { + final List rows; + + Rows(List rows) { + this.rows = rows; + } + public int getCount() { return rows.size(); } @@ -332,10 +646,12 @@ public class MainActivity extends Activity { Context c = MainActivity.this; if (!(o instanceof Ev)) { TextView h = convert instanceof TextView ? (TextView) convert : App.text(c, "", 13, 0xFFFFFFFF); - int pad = App.dp(c, 6); + int pad = App.dp(c, 5); h.setPadding(pad * 2, pad, pad, pad); - h.setBackgroundColor(0xFF2E3B44); - h.setText(o instanceof Long ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o)); + boolean header = o instanceof Long; + h.setBackgroundColor(header ? 0xFF2E3B44 : 0x00000000); + h.setTextColor(header ? 0xFFFFFFFF : 0xFF90A4AE); + h.setText(header ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o)); return h; } Ev e = (Ev) o; @@ -348,7 +664,7 @@ public class MainActivity extends Activity { hd.bar = new View(c); row.addView(hd.bar, new LinearLayout.LayoutParams(App.dp(c, 5), -1)); LinearLayout col = App.vbox(c); - int pad = App.dp(c, 6); + int pad = App.dp(c, 5); col.setPadding(pad * 2, pad, pad, pad); hd.time = App.text(c, "", 12, 0xFFB0BEC5); hd.title = App.text(c, "", 16, 0xFFFFFFFF); @@ -363,8 +679,8 @@ public class MainActivity extends Activity { hd = (Holder) v.getTag(); } hd.bar.setBackgroundColor(e.color); - hd.time.setText(App.when(c, e) + (e.recurring ? " (repeats)" : "")); - hd.title.setText(e.title.length() > 0 ? e.title : "(no title)"); + hd.time.setText(App.when(c, e) + (e.recurring ? " " + L.t("(repeats)") : "")); + hd.title.setText(e.title.length() > 0 ? e.title : L.t("(no title)")); hd.where.setText(e.location); hd.where.setVisibility(e.location.length() > 0 ? View.VISIBLE : View.GONE); return v; diff --git a/android/org/gingercal/MonthView.java b/android/org/gingercal/MonthView.java new file mode 100644 index 0000000..0fece48 --- /dev/null +++ b/android/org/gingercal/MonthView.java @@ -0,0 +1,215 @@ +package org.gingercal; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +/** + * Month grid drawn by hand (Android 2.3 has no calendar widget). Six weeks of days, each with + * up to four coloured marks for its events. Tap selects a day, long-press asks to create an + * event on it, a sideways swipe changes month. + */ +public class MonthView extends View { + + public interface Listener { + void daySelected(long day); + + void dayLongPressed(long day); + + void monthSwiped(int delta); + } + + static final int BG = 0xFF101418, GRID = 0xFF2A3036, WEEKDAY = 0xFF90A4AE, NUM = 0xFFECEFF1, + OTHER = 0xFF5C666E, TODAY = 0xFFD66E2A, SELECTED = 0xFF2E3B44; + + final float density; + final Paint fill = new Paint(1 /* ANTI_ALIAS_FLAG */), text = new Paint(1), line = new Paint(); + final GestureDetector gestures; + final String[] weekdays = new String[7]; + final long[] days = new long[42]; + Listener listener; + int month = -1; // Calendar.MONTH of the displayed month + long selected = -1, today; + Map> marks = new HashMap>(); + + public MonthView(Context c) { + super(c); + density = c.getResources().getDisplayMetrics().density; + line.setColor(GRID); + String[] shortNames = L.symbols().getShortWeekdays(); // index 1 = Sunday + int first = L.firstDayOfWeek(); + for (int i = 0; i < 7; i++) { + String n = shortNames[(first - 1 + i) % 7 + 1]; + weekdays[i] = n.length() > 3 ? n.substring(0, 3) : n; + } + gestures = new GestureDetector(c, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + long d = dayAt(e.getX(), e.getY()); + if (d >= 0 && listener != null) listener.daySelected(d); + return true; + } + + @Override + public void onLongPress(MotionEvent e) { + long d = dayAt(e.getX(), e.getY()); + if (d >= 0 && listener != null) { + performHapticFeedback(0 /* HapticFeedbackConstants.LONG_PRESS */); + listener.dayLongPressed(d); + } + } + + @Override + public boolean onFling(MotionEvent a, MotionEvent b, float vx, float vy) { + if (listener != null && Math.abs(vx) > Math.abs(vy) * 1.5f && Math.abs(vx) > 250 * density) { + listener.monthSwiped(vx < 0 ? 1 : -1); + return true; + } + return false; + } + }); + } + + /** Shows the month containing `anyDay`, weeks starting on the locale's first weekday. */ + public void setMonth(long anyDay) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(ICal.startOfDay(anyDay)); + c.set(Calendar.DAY_OF_MONTH, 1); + month = c.get(Calendar.MONTH); + int back = (c.get(Calendar.DAY_OF_WEEK) - L.firstDayOfWeek() + 7) % 7; + c.add(Calendar.DAY_OF_MONTH, -back); + for (int i = 0; i < 42; i++) { + days[i] = c.getTimeInMillis(); + c.add(Calendar.DAY_OF_MONTH, 1); + } + today = ICal.startOfDay(System.currentTimeMillis()); + invalidate(); + } + + public long gridStart() { + return days[0]; + } + + public long gridEnd() { + return ICal.addDays(days[41], 1); + } + + public boolean inMonth(long day) { + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(day); + return c.get(Calendar.MONTH) == month; + } + + public void setSelected(long day) { + selected = day; + invalidate(); + } + + public void setMarks(Map> m) { + marks = m; + invalidate(); + } + + // -------------------------------------------------------------- layout + + float headerH() { + return 16 * density; + } + + @Override + protected void onMeasure(int wSpec, int hSpec) { + int w = MeasureSpec.getSize(wSpec); + int h; + if (MeasureSpec.getMode(hSpec) == MeasureSpec.EXACTLY) { + h = MeasureSpec.getSize(hSpec); + } else { + float cell = Math.max(22 * density, Math.min(w / 7f * 0.66f, 44 * density)); + h = (int) (headerH() + 6 * cell); + if (MeasureSpec.getMode(hSpec) == MeasureSpec.AT_MOST) h = Math.min(h, MeasureSpec.getSize(hSpec)); + } + setMeasuredDimension(w, h); + } + + long dayAt(float x, float y) { + float cw = getWidth() / 7f, ch = (getHeight() - headerH()) / 6f; + if (y < headerH() || cw <= 0 || ch <= 0) return -1; + int col = Math.min(6, Math.max(0, (int) (x / cw))); + int row = Math.min(5, Math.max(0, (int) ((y - headerH()) / ch))); + return days[row * 7 + col]; + } + + @Override + public boolean onTouchEvent(MotionEvent e) { + return gestures.onTouchEvent(e); + } + + // ------------------------------------------------------------- drawing + + @Override + protected void onDraw(Canvas canvas) { + if (month < 0) return; + float w = getWidth(), cw = w / 7f, top = headerH(), ch = (getHeight() - top) / 6f; + fill.setStyle(Paint.Style.FILL); + fill.setColor(BG); + canvas.drawRect(0, 0, w, getHeight(), fill); + + text.setTextAlign(Paint.Align.CENTER); + text.setFakeBoldText(false); + text.setTextSize(11 * density); + text.setColor(WEEKDAY); + for (int i = 0; i < 7; i++) canvas.drawText(weekdays[i], cw * i + cw / 2, top - 4 * density, text); + + float numSize = Math.min(14 * density, ch * 0.45f); + float mark = Math.max(2, 3 * density); + for (int i = 0; i < 42; i++) { + float x0 = cw * (i % 7), y0 = top + ch * (i / 7); + long d = days[i]; + if (d == selected) { + fill.setColor(SELECTED); + canvas.drawRect(x0, y0, x0 + cw, y0 + ch, fill); + } + boolean isToday = d == today, here = inMonth(d); + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(d); + String num = String.valueOf(c.get(Calendar.DAY_OF_MONTH)); + text.setTextSize(numSize); + text.setFakeBoldText(isToday); + text.setColor(isToday ? TODAY : here ? NUM : OTHER); + float ty = y0 + ch * 0.12f - text.ascent(); + canvas.drawText(num, x0 + cw / 2, ty, text); + if (isToday) { + fill.setStyle(Paint.Style.STROKE); + fill.setStrokeWidth(Math.max(1, 1.5f * density)); + fill.setColor(TODAY); + canvas.drawRect(x0 + 1, y0 + 1, x0 + cw - 1, y0 + ch - 1, fill); + fill.setStyle(Paint.Style.FILL); + } + List m = marks.get(Long.valueOf(d)); + if (m != null && !m.isEmpty()) { + int n = Math.min(4, m.size()); + float gap = 2 * density, mw = Math.min(6 * density, (cw - gap * 5) / 4); + float total = n * mw + (n - 1) * gap, mx = x0 + (cw - total) / 2; + float my = Math.min(y0 + ch - mark - 3 * density, ty + text.descent() + 2 * density); + for (int k = 0; k < n; k++) { + fill.setColor(here ? m.get(k).intValue() : (m.get(k).intValue() & 0x00FFFFFF) | 0x77000000); + canvas.drawRect(mx, my, mx + mw, my + mark, fill); + mx += mw + gap; + } + } + } + for (int r = 0; r <= 6; r++) canvas.drawLine(0, top + ch * r, w, top + ch * r, line); + } +} diff --git a/android/org/gingercal/Reminders.java b/android/org/gingercal/Reminders.java index 052be75..072f986 100644 --- a/android/org/gingercal/Reminders.java +++ b/android/org/gingercal/Reminders.java @@ -51,7 +51,7 @@ public final class Reminders { PendingIntent pi = PendingIntent.getActivity(c, 0, open, 0); Notification n = new Notification(icon, e.title, e.start); String body = App.when(c, e) + (e.location.length() > 0 ? " · " + e.location : ""); - n.setLatestEventInfo(c, e.title.length() > 0 ? e.title : "(no title)", body, pi); + n.setLatestEventInfo(c, e.title.length() > 0 ? e.title : L.t("(no title)"), body, pi); n.flags |= 16; // FLAG_AUTO_CANCEL n.defaults = -1; // DEFAULT_ALL: sound, vibrate, lights nm.notify((e.href + e.start).hashCode(), n); diff --git a/android/org/gingercal/SettingsActivity.java b/android/org/gingercal/SettingsActivity.java index e44b5d3..9f2b1ab 100644 --- a/android/org/gingercal/SettingsActivity.java +++ b/android/org/gingercal/SettingsActivity.java @@ -6,6 +6,7 @@ import android.app.Activity; import android.app.AlertDialog; import android.app.ProgressDialog; import android.content.DialogInterface; +import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; import android.view.View; @@ -20,33 +21,33 @@ import org.gingercal.Model.Cal; /** Account, TLS pin, sync interval and reminder settings. */ public class SettingsActivity extends Activity { EditText url, user, pass, pin, ahead; - Button intervalBtn, remindBtn; + Button intervalBtn, remindBtn, langBtn; int interval, remind; @Override protected void onCreate(Bundle b) { super.onCreate(b); - setTitle("GingerCal settings"); + setTitle(L.t("GingerCal settings")); LinearLayout f = App.vbox(this); int p = App.dp(this, 8); f.setPadding(p, p, p, p); - f.addView(App.text(this, "Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your " - + "legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home " - + "Wi-Fi. Use an app password, not your real one.", 12, 0xFFB0BEC5)); - url = field(f, "Server address", "https://cloud.example.com:8443", 1 | 0x10 /* URI */); - user = field(f, "User name", "", 1 | 0x80000 /* no suggestions */); - pass = field(f, "App password", "", 1 | 0x80 /* password */); - pin = field(f, "Pinned certificate SHA-256 (filled in for you)", "optional", 1 | 0x80000); - ahead = field(f, "Days ahead to sync", "60", 2 /* number */); + f.addView(App.text(this, L.t("Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home Wi-Fi. Use an app password, not your real one."), 12, 0xFFB0BEC5)); + url = field(f, L.t("Server address"), "https://cloud.example.com:8443", 1 | 0x10 /* URI */); + user = field(f, L.t("User name"), "", 1 | 0x80000 /* no suggestions */); + pass = field(f, L.t("App password"), "", 1 | 0x80 /* password */); + pin = field(f, L.t("Pinned certificate SHA-256 (filled in for you)"), L.t("optional"), 1 | 0x80000); + ahead = field(f, L.t("Days ahead to sync"), "60", 2 /* number */); intervalBtn = new Button(this); f.addView(intervalBtn, new LinearLayout.LayoutParams(-1, -2)); remindBtn = new Button(this); f.addView(remindBtn, new LinearLayout.LayoutParams(-1, -2)); + langBtn = new Button(this); + f.addView(langBtn, new LinearLayout.LayoutParams(-1, -2)); Button test = new Button(this); - test.setText("Save and test connection"); + test.setText(L.t("Save and test connection")); f.addView(test, new LinearLayout.LayoutParams(-1, -2)); Button resync = new Button(this); - resync.setText("Re-download everything"); + resync.setText(L.t("Re-download everything")); f.addView(resync, new LinearLayout.LayoutParams(-1, -2)); ScrollView sv = new ScrollView(this); sv.addView(f); @@ -65,7 +66,7 @@ public class SettingsActivity extends Activity { public void onClick(View v) { final String[] l = new String[App.INTERVALS.length]; for (int i = 0; i < l.length; i++) l[i] = intervalLabel(App.INTERVALS[i]); - new AlertDialog.Builder(SettingsActivity.this).setTitle("Background sync") + new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("Background sync")) .setItems(l, new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { interval = App.INTERVALS[w]; @@ -78,8 +79,8 @@ public class SettingsActivity extends Activity { public void onClick(View v) { final int[] opts = {-1, 0, 5, 10, 15, 30, 60}; String[] l = new String[opts.length]; - for (int i = 0; i < l.length; i++) l[i] = opts[i] < 0 ? "Off" : App.alarmLabel(opts[i]); - new AlertDialog.Builder(SettingsActivity.this).setTitle("Phone notification") + for (int i = 0; i < l.length; i++) l[i] = opts[i] < 0 ? L.t("Off") : App.alarmLabel(opts[i]); + new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("Phone notification")) .setItems(l, new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { remind = opts[w]; @@ -88,6 +89,22 @@ public class SettingsActivity extends Activity { }).show(); } }); + langBtn.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + new AlertDialog.Builder(SettingsActivity.this).setTitle("Lingua / Language") + .setItems(new String[] {"Galego", "English"}, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface d, int w) { + boolean toGl = w == 0; + if (toGl == L.gl) return; + save(); + App.prefs(SettingsActivity.this).edit().putString("lang", toGl ? "gl" : "en").commit(); + L.gl = toGl; + finish(); // reopen so every label is redrawn in the new language + startActivity(new Intent(SettingsActivity.this, SettingsActivity.class)); + } + }).show(); + } + }); test.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { confirmAndTest(); @@ -98,7 +115,7 @@ public class SettingsActivity extends Activity { save(); Db.get(SettingsActivity.this).forgetSyncState(); App.requestSync(SettingsActivity.this, true, true); - App.toast(SettingsActivity.this, "Re-downloading in the background"); + App.toast(SettingsActivity.this, L.t("Re-downloading in the background")); finish(); } }); @@ -117,12 +134,13 @@ public class SettingsActivity extends Activity { } static String intervalLabel(int h) { - return h == 0 ? "Manual only" : h == 1 ? "Every hour" : "Every " + h + " hours"; + return h == 0 ? L.t("Manual only") : h == 1 ? L.t("Every hour") : L.f("Every %d hours", h); } void labels() { - intervalBtn.setText("Background sync: " + intervalLabel(interval)); - remindBtn.setText("Notify: " + (remind < 0 ? "off" : App.alarmLabel(remind))); + intervalBtn.setText(L.f("Background sync: %s", intervalLabel(interval))); + remindBtn.setText(L.f("Notification: %s", remind < 0 ? L.t("off") : App.alarmLabel(remind))); + langBtn.setText(L.f("Language: %s", L.gl ? "Galego" : "English")); } static String txt(TextView t) { @@ -155,7 +173,8 @@ public class SettingsActivity extends Activity { .commit(); if (!oldAccount.equals(u + "|" + txt(user))) { Db.get(this).clearAll(); - App.prefs(this).edit().putLong("lastsync", 0).putString("status", "").commit(); + App.prefs(this).edit().putLong("lastsync", 0).putLong("win_from", 0).putLong("win_to", 0) + .putString("status", "").commit(); } App.scheduleSync(this); Reminders.schedule(this); @@ -164,20 +183,19 @@ public class SettingsActivity extends Activity { void confirmAndTest() { String u = txt(url); if (u.length() == 0 || txt(user).length() == 0) { - App.toast(this, "Server address and user name are required."); + App.toast(this, L.t("Server address and user name are required.")); return; } if (Dav.normalize(u).startsWith("http://") && !App.isLan(u)) { - new AlertDialog.Builder(this).setTitle("Unencrypted connection") - .setMessage("http:// sends your password in clear text over the internet. Use the legacy-TLS " - + "bridge instead, or only use http:// to a LAN address.") - .setPositiveButton("Use anyway", new DialogInterface.OnClickListener() { + new AlertDialog.Builder(this).setTitle(L.t("Unencrypted connection")) + .setMessage(L.t("http:// sends your password in clear text over the internet. Use the legacy-TLS bridge instead, or only use http:// to a LAN address.")) + .setPositiveButton(L.t("Use anyway"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { save(); new TestTask().execute(); } }) - .setNegativeButton("Cancel", null).show(); + .setNegativeButton(L.t("Cancel"), null).show(); return; } save(); @@ -189,7 +207,7 @@ public class SettingsActivity extends Activity { @Override protected void onPreExecute() { - pd = ProgressDialog.show(SettingsActivity.this, null, "Connecting…"); + pd = ProgressDialog.show(SettingsActivity.this, null, L.t("Connecting\u2026")); } @Override @@ -223,16 +241,16 @@ public class SettingsActivity extends Activity { return; } if (r instanceof Throwable) { - new AlertDialog.Builder(SettingsActivity.this).setTitle("Connection failed") - .setMessage(App.describe((Throwable) r)).setPositiveButton("OK", null).show(); + new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("Connection failed")) + .setMessage(App.describe((Throwable) r)).setPositiveButton(L.t("OK"), null).show(); return; } List cals = (List) r; - StringBuilder s = new StringBuilder("Connected. Found " + cals.size() + " calendar(s):\n"); - for (Cal c : cals) s.append("\n• ").append(c.name).append(c.writable ? "" : " (read-only)"); - new AlertDialog.Builder(SettingsActivity.this).setTitle("It works") + StringBuilder s = new StringBuilder(L.f("Connected. Calendars found: %d\n", cals.size())); + for (Cal c : cals) s.append("\n\u2022 ").append(c.name).append(c.writable ? "" : " " + L.t("(read-only)")); + new AlertDialog.Builder(SettingsActivity.this).setTitle(L.t("It works")) .setMessage(s.toString()) - .setPositiveButton("Sync now", new DialogInterface.OnClickListener() { + .setPositiveButton(L.t("Sync now"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { App.requestSync(SettingsActivity.this, true, false); finish(); diff --git a/android/org/gingercal/SyncService.java b/android/org/gingercal/SyncService.java index cf1d44f..9ea5058 100644 --- a/android/org/gingercal/SyncService.java +++ b/android/org/gingercal/SyncService.java @@ -26,8 +26,9 @@ public class SyncService extends IntentService { send("start", null, null); dav = App.dav(this); long now = System.currentTimeMillis(); - Syncer.Result r = Syncer.sync(dav, Db.get(this), now, 1, App.ahead(this), force); - p.edit().putLong("lastsync", now).putString("status", r.summary()).putBoolean("error", false).commit(); + Syncer.Result r = Syncer.sync(dav, Db.get(this), now, App.BACK_DAYS, App.ahead(this), force); + p.edit().putLong("lastsync", now).putString("status", r.summary()).putBoolean("error", false) + .putLong("win_from", r.from).putLong("win_to", r.to).commit(); send("done", r.summary(), null); } catch (DavException.UntrustedCert u) { p.edit().putString("status", u.getMessage()).putBoolean("error", true).commit(); diff --git a/bridge/make-cert.sh b/bridge/make-cert.sh index c8c7e27..f88c259 100755 --- a/bridge/make-cert.sh +++ b/bridge/make-cert.sh @@ -1,15 +1,17 @@ #!/bin/sh -# Creates the bridge's self-signed certificate (10 years) and prints the SHA-256 +# Creates the legacy endpoint's self-signed certificate (10 years) and prints the SHA-256 # fingerprint to compare with what the phone shows when you tap "Trust". +# ./make-cert.sh [name] [output-dir] +# e.g. for Nginx Proxy Manager: ./make-cert.sh nxc.anosanube.ovh /opt/npm/data/legacy-caldav set -eu -cd "$(dirname "$0")" -mkdir -p certs NAME=${1:-caldav-bridge} -if [ ! -f certs/bridge.key ]; then +DIR=${2:-"$(dirname "$0")/certs"} +mkdir -p "$DIR" +if [ ! -f "$DIR/bridge.key" ]; then openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ - -keyout certs/bridge.key -out certs/bridge.crt -subj "/CN=$NAME" 2>/dev/null - chmod 600 certs/bridge.key - echo "Created certs/bridge.crt" + -keyout "$DIR/bridge.key" -out "$DIR/bridge.crt" -subj "/CN=$NAME" 2>/dev/null + chmod 600 "$DIR/bridge.key" + echo "Created $DIR/bridge.crt" fi echo "SHA-256 fingerprint (must match the phone):" -openssl x509 -in certs/bridge.crt -noout -fingerprint -sha256 | cut -d= -f2 +openssl x509 -in "$DIR/bridge.crt" -noout -fingerprint -sha256 | cut -d= -f2 diff --git a/bridge/npm/http.conf b/bridge/npm/http.conf new file mode 100644 index 0000000..c29fab7 --- /dev/null +++ b/bridge/npm/http.conf @@ -0,0 +1,65 @@ +# Legacy-TLS CalDAV endpoint for Android 2.3, inside Nginx Proxy Manager. +# +# Install: copy to /nginx/custom/http.conf (NPM includes it at the end of its +# http block; if that file already exists, append this server block to it). Then publish +# port 8443 on the NPM container and forward it on the router. See README.md. +# +# Phone -> nxc.anosanube.ovh:8443 (TLS 1.0, self-signed cert pinned by the app) +# -> this block -> NPM's own https proxy host for nxc.anosanube.ovh -> Nextcloud +# Going back through NPM's existing proxy host reuses its Nextcloud settings, so you don't +# need to repeat the backend address here. + +server { + listen 8443 ssl; + listen [::]:8443 ssl; # remove if NPM runs with DISABLE_IPV6 + server_name _; + + set $nc_host nxc.anosanube.ovh; + + # Long-lived self-signed certificate (bridge/make-cert.sh). Not NPM's Let's Encrypt one: + # Android 2.3 doesn't trust ISRG Root X1, and pinning a cert that renews every 60 days + # would trip the app's "certificate changed" warning each time. + ssl_certificate /data/legacy-caldav/bridge.crt; + ssl_certificate_key /data/legacy-caldav/bridge.key; + + # Android 2.3: TLS 1.0, AES-CBC-SHA. Only this port; NPM's own hosts on 443 are untouched. + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers "ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-SHA:AES256-SHA:@SECLEVEL=0"; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:legacy_caldav:1m; + ssl_session_timeout 1h; + + absolute_redirect off; + client_max_body_size 5m; + access_log /data/logs/legacy-caldav_access.log; + error_log /data/logs/legacy-caldav_error.log warn; + + # HSTS from the proxy host is meant for https://nxc.anosanube.ovh, not this self-signed port. + proxy_hide_header Strict-Transport-Security; + + location /remote.php/dav/ { + # NPM's own HTTPS listener, matched by SNI to the nxc.anosanube.ovh proxy host. + proxy_pass https://127.0.0.1:443; + proxy_ssl_server_name on; + proxy_ssl_name $nc_host; + proxy_ssl_verify off; # loopback to ourselves + + proxy_set_header Host $nc_host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 120s; + proxy_buffering off; + proxy_redirect ~^https?://[^/]+(/.*)$ $1; + } + + location = /.well-known/caldav { + return 301 /remote.php/dav/; + } + + location / { + return 404; + } +} diff --git a/build.sh b/build.sh index 6b80d3a..9e04f38 100755 --- a/build.sh +++ b/build.sh @@ -21,6 +21,9 @@ VERSION_NAME=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' AndroidManife rm -rf "$OUT" mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex" "$OUT/apk/res/drawable" "$OUT/apk/res/drawable-ldpi" +# 0. every interface string must have its Galician translation +python3 tools/check_i18n.py . + # 1. compile if [ -n "${ANDROID_JAR:-}" ]; then CP="$ANDROID_JAR" diff --git a/src/org/gingercal/Dav.java b/src/org/gingercal/Dav.java index 3490646..68fbdae 100644 --- a/src/org/gingercal/Dav.java +++ b/src/org/gingercal/Dav.java @@ -109,41 +109,34 @@ public class Dav { continue; } if (r.code == 401) { - throw new DavException(401, "Login refused (401). Check the user name and use a " - + "Nextcloud app password (Settings > Security > Devices & sessions)."); + throw new DavException(401, L.t("Login refused (401). Check the user name and use a Nextcloud app password (Settings > Security > Devices & sessions).")); } if (r.code == 403 && body != null && method.equals("PUT")) { - throw new DavException(403, "Not allowed to write to this calendar (403). It may be read-only/shared."); + throw new DavException(403, L.t("Not allowed to write to this calendar (403). It may be read-only/shared.")); } return r; } - throw new DavException(DavException.REDIRECT, "Too many redirects from the server."); + throw new DavException(DavException.REDIRECT, L.t("Too many redirects from the server.")); } static void checkRedirect(String from, String to, List visited, int code) throws DavException { String a = originKey(from), b = originKey(to); if (a.startsWith("http:") && b.startsWith("https:")) { - throw new DavException(DavException.REDIRECT, "The server redirected http:// to " - + origin(to) + " (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: " - + "use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS')."); + throw new DavException(DavException.REDIRECT, L.f("The server redirected http:// to %s (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').", origin(to))); } if (!a.equals(b)) { - throw new DavException(DavException.REDIRECT, "The server redirected to " + origin(to) - + ", not the address set in the app. Nextcloud is sending its public URL; check the " - + "bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS')."); + throw new DavException(DavException.REDIRECT, L.f("The server redirected to %s, not the address set in the app. Nextcloud is sending its public URL; check the bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').", origin(to))); } if (visited.contains(to)) { - throw new DavException(DavException.REDIRECT, "Redirect loop (HTTP " + code + " back to the same " - + "address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the " - + "bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS')."); + throw new DavException(DavException.REDIRECT, L.f("Redirect loop (HTTP %d back to the same address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS').", code)); } } List multistatus(String method, String target, String depth, String body) throws IOException { Http.Resp r = send(method, target, depth, body, null); if (r.code != 207) { - throw new DavException(r.code, method + " " + URI.create(target).getPath() + " failed: HTTP " + r.code - + (r.code == 404 ? " (not found)" : r.code == 405 ? " (not a WebDAV URL?)" : "")); + throw new DavException(r.code, L.f("%s %s failed: HTTP %d%s", method, URI.create(target).getPath(), r.code, + r.code == 404 ? L.t(" (not found)") : r.code == 405 ? L.t(" (not a WebDAV URL?)") : "")); } Node ms = Xml.parse(r.body); List out = new ArrayList(); @@ -206,7 +199,7 @@ public class Dav { } } if (principal == null) { - throw last != null ? last : new DavException(0, "Server did not report a CalDAV principal"); + throw last != null ? last : new DavException(0, L.t("Server did not report a CalDAV principal")); } List rs = multistatus("PROPFIND", principal, "0", propfind("")); for (Response r : rs) { @@ -326,7 +319,7 @@ public class Dav { /** GET a single object. Returns {ics, etag}. */ public String[] get(String href) throws IOException { Http.Resp r = send("GET", href, null, null, null); - if (r.code != 200) throw new DavException(r.code, "Could not load event: HTTP " + r.code); + if (r.code != 200) throw new DavException(r.code, L.f("Could not load event: HTTP %d", r.code)); return new String[] {r.body, r.etag}; } @@ -334,25 +327,25 @@ public class Dav { String href = abs(calHref.endsWith("/") ? calHref : calHref + "/", uid + ".ics"); Http.Resp r = send("PUT", href, null, ics, new String[] {"If-None-Match", "*"}); if (r.code != 201 && r.code != 204 && r.code != 200) { - throw new DavException(r.code, "Saving failed: HTTP " + r.code); + throw new DavException(r.code, L.f("Saving failed: HTTP %d", r.code)); } } public void update(String href, String etag, String ics) throws IOException { String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag}; Http.Resp r = send("PUT", href, null, ics, h); - if (r.code == 412) throw new DavException(412, "Event was changed on the server meanwhile. Sync and try again."); + if (r.code == 412) throw new DavException(412, L.t("Event was changed on the server meanwhile. Sync and try again.")); if (r.code != 201 && r.code != 204 && r.code != 200) { - throw new DavException(r.code, "Saving failed: HTTP " + r.code); + throw new DavException(r.code, L.f("Saving failed: HTTP %d", r.code)); } } public void delete(String href, String etag) throws IOException { String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag}; Http.Resp r = send("DELETE", href, null, null, h); - if (r.code == 412) throw new DavException(412, "Event was changed on the server meanwhile. Sync and try again."); + if (r.code == 412) throw new DavException(412, L.t("Event was changed on the server meanwhile. Sync and try again.")); if (r.code != 204 && r.code != 200 && r.code != 404) { - throw new DavException(r.code, "Delete failed: HTTP " + r.code); + throw new DavException(r.code, L.f("Delete failed: HTTP %d", r.code)); } } diff --git a/src/org/gingercal/DavException.java b/src/org/gingercal/DavException.java index c73699c..313bed0 100644 --- a/src/org/gingercal/DavException.java +++ b/src/org/gingercal/DavException.java @@ -20,8 +20,8 @@ public class DavException extends IOException { public UntrustedCert(String fingerprint, String subject, String issuer, String expires, boolean pinMismatch) { - super(pinMismatch ? "Server certificate changed (does not match the pinned fingerprint)" - : "Server certificate is not trusted by this phone"); + super(pinMismatch ? L.t("Server certificate changed (does not match the pinned fingerprint)") + : L.t("Server certificate is not trusted by this phone")); this.fingerprint = fingerprint; this.subject = subject; this.issuer = issuer; diff --git a/src/org/gingercal/ICal.java b/src/org/gingercal/ICal.java index 3c5fcfd..7a2c1e1 100644 --- a/src/org/gingercal/ICal.java +++ b/src/org/gingercal/ICal.java @@ -296,6 +296,13 @@ public class ICal { return c.getTimeInMillis(); } + /** Whether [start, end) covers part of the local day starting at `day`; zero-length counts at its start. */ + public static boolean onDay(long start, long end, long day) { + long next = addDays(day, 1); + if (end <= start) return start >= day && start < next; + return start < next && end > day; + } + public static long startOfDay(long t) { Calendar c = Calendar.getInstance(); c.setTimeInMillis(t); diff --git a/src/org/gingercal/L.java b/src/org/gingercal/L.java new file mode 100644 index 0000000..070310f --- /dev/null +++ b/src/org/gingercal/L.java @@ -0,0 +1,231 @@ +package org.gingercal; + +import java.text.DateFormatSymbols; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Interface language. English text is the key; Galician comes from the table below. + * Android 2.3 ships no Galician locale data, so month and weekday names live here too. + * tools/check_i18n.py checks that every L.t()/L.f() key has a translation with matching + * placeholders. + */ +public final class L { + private L() {} + + /** Galician on (the app default); the desktop tests run in English. */ + public static volatile boolean gl; + + private static final Map GL = new HashMap(); + + public static String t(String en) { + if (!gl) return en; + String v = GL.get(en); + return v == null ? en : v; + } + + public static String f(String en, Object... args) { + return String.format(Locale.US, t(en), args); + } + + // ------------------------------------------------------------------ dates + + static final String[] GL_MONTHS = {"xaneiro", "febreiro", "marzo", "abril", "maio", "xuño", "xullo", + "agosto", "setembro", "outubro", "novembro", "decembro"}; + static final String[] GL_MONTHS_SHORT = {"xan", "feb", "mar", "abr", "mai", "xuñ", "xul", "ago", "set", + "out", "nov", "dec"}; + static final String[] GL_DAYS = {"", "domingo", "luns", "martes", "mércores", "xoves", "venres", "sábado"}; + static final String[] GL_DAYS_SHORT = {"", "dom", "lun", "mar", "mér", "xov", "ven", "sáb"}; + + public static DateFormatSymbols symbols() { + if (!gl) return new DateFormatSymbols(Locale.getDefault()); + DateFormatSymbols s = new DateFormatSymbols(Locale.US); + s.setMonths(GL_MONTHS); + s.setShortMonths(GL_MONTHS_SHORT); + s.setWeekdays(GL_DAYS); + s.setShortWeekdays(GL_DAYS_SHORT); + s.setAmPmStrings(new String[] {"a.m.", "p.m."}); + return s; + } + + public static SimpleDateFormat df(String pattern) { + return new SimpleDateFormat(pattern, symbols()); + } + + /** "Outubro de 2026" / "October 2026". */ + public static String monthTitle(long t) { + String s = df(gl ? "MMMM 'de' yyyy" : "MMMM yyyy").format(new Date(t)); + return s.length() > 0 ? Character.toUpperCase(s.charAt(0)) + s.substring(1) : s; + } + + public static int firstDayOfWeek() { + return gl ? Calendar.MONDAY : Calendar.getInstance().getFirstDayOfWeek(); + } + + // ------------------------------------------------------------ translations + + private static void put(String en, String gal) { + GL.put(en, gal); + } + + static { + // main screen + put("Not set up yet: press Menu > Settings.", "Aínda sen configurar: preme Menú > Configuración."); + put("Syncing…", "Sincronizando…"); + put("Loading %s from the server…", "Cargando %s do servidor…"); + put("Couldn't load this month: %s", "Non se puido cargar este mes: %s"); + put("never", "nunca"); + put("Sync failed: %s\nLast good sync: %s", "Erro de sincronización: %s\nÚltima sincronización correcta: %s"); + put("Synced %s", "Sincronizado: %s"); + put("No upcoming events. Menu > New event, or Menu > Sync.", "Non hai eventos próximos. Menú > Novo evento ou Menú > Sincronizar."); + put("Nothing on %s. Long-press a day to add an event.", "Non hai nada o %s. Mantén premido un día para engadir un evento."); + put("View", "Vista"); + put("Agenda", "Axenda"); + put("Month", "Mes"); + put("New event", "Novo evento"); + put("Sync", "Sincronizar"); + put("Today", "Hoxe"); + put("Calendars", "Calendarios"); + put("Settings", "Configuración"); + put("Sync first: no writable calendar yet.", "Sincroniza primeiro: aínda non hai ningún calendario con permiso de escritura."); + put("Calendar: %s", "Calendario: %s"); + put("Repeating event: edit the series in Nextcloud. Delete removes the whole series.", "Evento periódico: edita a serie en Nextcloud. Eliminar borra a serie completa."); + put("(no title)", "(sen título)"); + put("(repeats)", "(repítese)"); + put("Close", "Pechar"); + put("Edit", "Editar"); + put("Delete", "Eliminar"); + put("Delete event?", "Eliminar o evento?"); + put("This deletes EVERY occurrence of \"%s\".", "Isto elimina TODAS as repeticións de «%s»."); + put("\"%s\" will be deleted on the server.", "«%s» eliminarase no servidor."); + put("Cancel", "Cancelar"); + put("Deleting…", "Eliminando…"); + put("Delete failed", "Non se puido eliminar"); + put("OK", "Aceptar"); + + // dates and times + put("Today · %s", "Hoxe · %s"); + put("Tomorrow · %s", "Mañá · %s"); + put("All day", "Todo o día"); + put("All day, until %s", "Todo o día, ata o %s"); + put("No reminder", "Sen aviso"); + put("At start time", "Á hora de comezo"); + put("%d min before", "%d min antes"); + put("%d h before", "%d h antes"); + put("%d day before", "%d día antes"); + + // event editor + put("Edit event", "Editar evento"); + put("Title", "Título"); + put("Starts", "Comeza"); + put("Ends", "Remata"); + put("Location", "Lugar"); + put("Notes", "Notas"); + put("Save", "Gardar"); + put("Alarm: %s", "Aviso: %s"); + put("Alarm (saved in the event)", "Aviso (gárdase no evento)"); + put("Calendar", "Calendario"); + put("The end is before the start.", "O final é anterior ao comezo."); + put("No writable calendar. Sync first.", "Non hai ningún calendario con permiso de escritura. Sincroniza primeiro."); + put("Loading…", "Cargando…"); + put("Can't load event", "Non se puido cargar o evento"); + put("Repeating event", "Evento periódico"); + put("Repeating events can't be edited on the phone yet. Edit the series in Nextcloud.", "Os eventos periódicos aínda non se poden editar no teléfono. Edita a serie en Nextcloud."); + put("Can't edit", "Non se pode editar"); + put("The server returned no event data.", "O servidor non devolveu os datos do evento."); + put("Saving…", "Gardando…"); + put("Not saved", "Non se gardou"); + put("Saved", "Gardado"); + + // calendars + put("No calendars yet. Set up the account in Settings and sync.", "Aínda non hai calendarios. Configura a conta en Configuración e sincroniza."); + put("(read-only)", "(só lectura)"); + put("first writable", "o primeiro con escritura"); + put("New events go to: %s", "Os eventos novos gárdanse en: %s"); + put("Default calendar", "Calendario predeterminado"); + + // settings + put("GingerCal settings", "Configuración de GingerCal"); + put("Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home Wi-Fi. Use an app password, not your real one.", + "Servidor Nextcloud. Android 2.3 non admite TLS 1.2, así que usa a túa ponte TLS antiga (https://cloud.example.com:8443) ou http:// ao servidor na wifi da casa. Usa un contrasinal de aplicación, non o teu contrasinal real."); + put("Server address", "Enderezo do servidor"); + put("User name", "Usuario"); + put("App password", "Contrasinal de aplicación"); + put("Pinned certificate SHA-256 (filled in for you)", "SHA-256 do certificado fixado (énchese automaticamente)"); + put("optional", "opcional"); + put("Days ahead to sync", "Días futuros que sincronizar"); + put("Save and test connection", "Gardar e probar a conexión"); + put("Re-download everything", "Descargar todo de novo"); + put("Background sync", "Sincronización automática"); + put("Background sync: %s", "Sincronización automática: %s"); + put("Manual only", "Só manual"); + put("Every hour", "Cada hora"); + put("Every %d hours", "Cada %d horas"); + put("Phone notification", "Notificación no teléfono"); + put("Notification: %s", "Notificación: %s"); + put("Off", "Desactivada"); + put("off", "desactivada"); + put("Language: %s", "Lingua: %s"); + put("Re-downloading in the background", "Descargando todo de novo en segundo plano"); + put("Server address and user name are required.", "O enderezo do servidor e o usuario son obrigatorios."); + put("Unencrypted connection", "Conexión sen cifrar"); + put("http:// sends your password in clear text over the internet. Use the legacy-TLS bridge instead, or only use http:// to a LAN address.", + "Con http:// o contrasinal viaxa sen cifrar por internet. Usa a ponte TLS antiga ou usa http:// só cun enderezo da rede local."); + put("Use anyway", "Usar igualmente"); + put("Connecting…", "Conectando…"); + put("Connection failed", "Erro de conexión"); + put("Connected. Calendars found: %d\n", "Conectado. Calendarios atopados: %d\n"); + put("It works", "Funciona"); + put("Sync now", "Sincronizar agora"); + + // certificate trust + put("WARNING: the server certificate changed since you trusted it. That happens when you regenerate the bridge certificate - or when someone is intercepting the connection.\n\n", + "AVISO: o certificado do servidor cambiou desde que confiaches nel. Isto pasa se rexeneras o certificado da ponte, ou se alguén está a interceptar a conexión.\n\n"); + put("This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, or for Let's Encrypt on Android 2.3).\n\n", + "Este teléfono non confía no certificado do servidor (é normal coa ponte antiga autoasinada ou con Let's Encrypt en Android 2.3).\n\n"); + put("Subject: %s\nIssuer: %s\nExpires: %s\n\nSHA-256:\n%s\n\nOnly trust it if this matches the fingerprint printed on your server.", + "Suxeito: %s\nEmisor: %s\nCaduca: %s\n\nSHA-256:\n%s\n\nConfía nel só se coincide coa pegada dixital que mostra o teu servidor."); + put("Certificate changed!", "O certificado cambiou!"); + put("Trust this certificate?", "Confiar neste certificado?"); + put("Trust", "Confiar"); + put("Server certificate changed (does not match the pinned fingerprint)", "O certificado do servidor cambiou (non coincide coa pegada fixada)"); + put("Server certificate is not trusted by this phone", "Este teléfono non confía no certificado do servidor"); + + // errors + put("Can't resolve %s. No connection, or a typo in the server address.", "Non se pode resolver %s. Non hai conexión ou hai un erro no enderezo do servidor."); + put("Can't connect to the server (%s). Wrong port, or the bridge is down?", "Non se pode conectar co servidor (%s). Porto incorrecto ou ponte caída?"); + put("The server took too long to answer.", "O servidor tardou de máis en responder."); + put("TLS error: %s", "Erro TLS: %s"); + put("Network error: %s", "Erro de rede: %s"); + put("Out of memory. Lower 'Days ahead' in Settings.", "Sen memoria. Reduce «Días futuros» na configuración."); + put("Error: %s", "Erro: %s"); + put("Login refused (401). Check the user name and use a Nextcloud app password (Settings > Security > Devices & sessions).", + "Acceso rexeitado (401). Comproba o usuario e usa un contrasinal de aplicación de Nextcloud (Configuración > Seguranza > Dispositivos e sesións)."); + put("Not allowed to write to this calendar (403). It may be read-only/shared.", "Non tes permiso para escribir neste calendario (403). Pode ser de só lectura ou compartido."); + put("Too many redirects from the server.", "O servidor fixo demasiadas redireccións."); + put("The server redirected http:// to %s (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').", + "O servidor redirixiu http:// a %s (Nextcloud obriga a usar HTTPS / HSTS). Android 2.3 non pode seguir esa redirección: usa o enderezo da ponte TLS antiga (consulta o README, «Forced HTTPS / HSTS»)."); + put("The server redirected to %s, not the address set in the app. Nextcloud is sending its public URL; check the bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').", + "O servidor redirixiu a %s, que non é o enderezo configurado na aplicación. Nextcloud está a enviar o seu URL público; revisa a liña proxy_redirect da ponte (README, «Forced HTTPS / HSTS»)."); + put("Redirect loop (HTTP %d back to the same address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS').", + "Bucle de redirección (HTTP %d ao mesmo enderezo). Nextcloud obriga a usar HTTPS pero a ponte conecta con el por HTTP: cambia a ponte a proxy_pass https:// (README, «Forced HTTPS / HSTS»)."); + put("%s %s failed: HTTP %d%s", "%s %s fallou: HTTP %d%s"); + put(" (not found)", " (non atopado)"); + put(" (not a WebDAV URL?)", " (non é un URL WebDAV?)"); + put("Server did not report a CalDAV principal", "O servidor non informou de ningún principal CalDAV"); + put("Could not load event: HTTP %d", "Non se puido cargar o evento: HTTP %d"); + put("Saving failed: HTTP %d", "Non se puido gardar: HTTP %d"); + put("Event was changed on the server meanwhile. Sync and try again.", "O evento cambiou no servidor mentres tanto. Sincroniza e téntao de novo."); + put("Delete failed: HTTP %d", "Non se puido eliminar: HTTP %d"); + put("TLS handshake failed before any certificate was sent. The server most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the legacy-TLS bridge (see README) or use http:// on your LAN. (%s)", + "A negociación TLS fallou antes de recibir ningún certificado. Seguramente o servidor esixe TLS 1.2 ou superior, que Android 2.3 non admite. Configura a aplicación coa ponte TLS antiga (consulta o README) ou usa http:// na rede local. (%s)"); + put("Bad XML from server: %s", "XML incorrecto do servidor: %s"); + put("Empty XML from server", "XML baleiro do servidor"); + put("%d calendars: %d refreshed (%d events)", "Calendarios: %d, actualizados: %d (eventos: %d)"); + put(", %d unchanged", ", sen cambios: %d"); + } +} diff --git a/src/org/gingercal/Syncer.java b/src/org/gingercal/Syncer.java index c938726..39f3dfc 100644 --- a/src/org/gingercal/Syncer.java +++ b/src/org/gingercal/Syncer.java @@ -16,10 +16,12 @@ public class Syncer { public static class Result { public int calendars, fetched, skipped, events; + /** The time window now cached locally. */ + public long from, to; public String summary() { - return calendars + " calendars: " + fetched + " refreshed (" + events + " events)" - + (skipped > 0 ? ", " + skipped + " unchanged" : ""); + return L.f("%d calendars: %d refreshed (%d events)", calendars, fetched, events) + + (skipped > 0 ? L.f(", %d unchanged", skipped) : ""); } } @@ -32,6 +34,8 @@ public class Syncer { Set seen = new HashSet(); long from = ICal.addDays(ICal.startOfDay(now), -pastDays); long to = ICal.addDays(ICal.startOfDay(now), futureDays + 1); + res.from = from; + res.to = to; for (Cal r : remote) { seen.add(r.href); diff --git a/src/org/gingercal/Tls.java b/src/org/gingercal/Tls.java index cdac908..0f455c7 100644 --- a/src/org/gingercal/Tls.java +++ b/src/org/gingercal/Tls.java @@ -101,9 +101,7 @@ public final class Tls { return new DavException.UntrustedCert(seenFp, seenSubject, seenIssuer, seenExpiry, mismatch); } if (e instanceof SSLException && seenFp == null) { - return new DavException(0, "TLS handshake failed before any certificate was sent. The server " - + "most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the " - + "legacy-TLS bridge (see README) or use http:// on your LAN. (" + e.getMessage() + ")"); + return new DavException(0, L.f("TLS handshake failed before any certificate was sent. The server most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the legacy-TLS bridge (see README) or use http:// on your LAN. (%s)", e.getMessage())); } return e; } diff --git a/src/org/gingercal/Xml.java b/src/org/gingercal/Xml.java index 4abf34f..0d0f622 100644 --- a/src/org/gingercal/Xml.java +++ b/src/org/gingercal/Xml.java @@ -106,9 +106,9 @@ public class Xml { } catch (IOException e) { throw e; } catch (Exception e) { - throw new IOException("Bad XML from server: " + e.getMessage()); + throw new IOException(L.f("Bad XML from server: %s", e.getMessage())); } - if (root.kids.isEmpty()) throw new IOException("Empty XML from server"); + if (root.kids.isEmpty()) throw new IOException(L.t("Empty XML from server")); return root.kids.get(0); } diff --git a/stubs/android/app/Activity.java b/stubs/android/app/Activity.java index 084da57..78a551c 100644 --- a/stubs/android/app/Activity.java +++ b/stubs/android/app/Activity.java @@ -12,4 +12,7 @@ public class Activity extends android.view.ContextThemeWrapper { public boolean onOptionsItemSelected(android.view.MenuItem item) { throw new RuntimeException("Stub!"); } public android.content.Intent getIntent() { throw new RuntimeException("Stub!"); } public void finish() { throw new RuntimeException("Stub!"); } + public final boolean requestWindowFeature(int featureId) { throw new RuntimeException("Stub!"); } + public void onConfigurationChanged(android.content.res.Configuration newConfig) { throw new RuntimeException("Stub!"); } + public boolean onPrepareOptionsMenu(android.view.Menu menu) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/app/Application.java b/stubs/android/app/Application.java new file mode 100644 index 0000000..73f1b0c --- /dev/null +++ b/stubs/android/app/Application.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.app; + +public class Application extends android.content.ContextWrapper { + public Application() { super(null); throw new RuntimeException("Stub!"); } + public void onCreate() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/content/res/Configuration.java b/stubs/android/content/res/Configuration.java new file mode 100644 index 0000000..9b5e503 --- /dev/null +++ b/stubs/android/content/res/Configuration.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content.res; + +public final class Configuration { public Configuration() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/graphics/Canvas.java b/stubs/android/graphics/Canvas.java new file mode 100644 index 0000000..ad8eb77 --- /dev/null +++ b/stubs/android/graphics/Canvas.java @@ -0,0 +1,9 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.graphics; + +public class Canvas { + public Canvas() { throw new RuntimeException("Stub!"); } + public void drawRect(float left, float top, float right, float bottom, Paint paint) { throw new RuntimeException("Stub!"); } + public void drawText(String text, float x, float y, Paint paint) { throw new RuntimeException("Stub!"); } + public void drawLine(float startX, float startY, float stopX, float stopY, Paint paint) { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/graphics/Paint.java b/stubs/android/graphics/Paint.java new file mode 100644 index 0000000..570fa8d --- /dev/null +++ b/stubs/android/graphics/Paint.java @@ -0,0 +1,17 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.graphics; + +public class Paint { + public Paint() { throw new RuntimeException("Stub!"); } + public Paint(int flags) { throw new RuntimeException("Stub!"); } + public void setColor(int color) { throw new RuntimeException("Stub!"); } + public void setTextSize(float textSize) { throw new RuntimeException("Stub!"); } + public void setTextAlign(Align align) { throw new RuntimeException("Stub!"); } + public void setFakeBoldText(boolean fakeBoldText) { throw new RuntimeException("Stub!"); } + public void setStyle(Style style) { throw new RuntimeException("Stub!"); } + public void setStrokeWidth(float width) { throw new RuntimeException("Stub!"); } + public float ascent() { throw new RuntimeException("Stub!"); } + public float descent() { throw new RuntimeException("Stub!"); } + public enum Align { LEFT, CENTER, RIGHT } + public enum Style { FILL, STROKE, FILL_AND_STROKE } +} diff --git a/stubs/android/util/DisplayMetrics.java b/stubs/android/util/DisplayMetrics.java index ab3a675..d7e876e 100644 --- a/stubs/android/util/DisplayMetrics.java +++ b/stubs/android/util/DisplayMetrics.java @@ -1,4 +1,4 @@ // Compile-only stub (API level 10 signatures). Never packaged into the APK. package android.util; -public class DisplayMetrics { public float density; public DisplayMetrics() { throw new RuntimeException("Stub!"); } } +public class DisplayMetrics { public float density; public int widthPixels; public int heightPixels; public DisplayMetrics() { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/view/GestureDetector.java b/stubs/android/view/GestureDetector.java new file mode 100644 index 0000000..86bdd81 --- /dev/null +++ b/stubs/android/view/GestureDetector.java @@ -0,0 +1,32 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public class GestureDetector { + public GestureDetector(android.content.Context context, OnGestureListener listener) { throw new RuntimeException("Stub!"); } + public boolean onTouchEvent(MotionEvent ev) { throw new RuntimeException("Stub!"); } + public interface OnGestureListener { + boolean onDown(MotionEvent e); + void onShowPress(MotionEvent e); + boolean onSingleTapUp(MotionEvent e); + boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY); + void onLongPress(MotionEvent e); + boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY); + } + public interface OnDoubleTapListener { + boolean onSingleTapConfirmed(MotionEvent e); + boolean onDoubleTap(MotionEvent e); + boolean onDoubleTapEvent(MotionEvent e); + } + public static class SimpleOnGestureListener implements OnGestureListener, OnDoubleTapListener { + public SimpleOnGestureListener() { throw new RuntimeException("Stub!"); } + public boolean onSingleTapUp(MotionEvent e) { throw new RuntimeException("Stub!"); } + public void onLongPress(MotionEvent e) { throw new RuntimeException("Stub!"); } + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { throw new RuntimeException("Stub!"); } + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { throw new RuntimeException("Stub!"); } + public void onShowPress(MotionEvent e) { throw new RuntimeException("Stub!"); } + public boolean onDown(MotionEvent e) { throw new RuntimeException("Stub!"); } + public boolean onDoubleTap(MotionEvent e) { throw new RuntimeException("Stub!"); } + public boolean onDoubleTapEvent(MotionEvent e) { throw new RuntimeException("Stub!"); } + public boolean onSingleTapConfirmed(MotionEvent e) { throw new RuntimeException("Stub!"); } + } +} diff --git a/stubs/android/view/Menu.java b/stubs/android/view/Menu.java index 1f20bcc..696797b 100644 --- a/stubs/android/view/Menu.java +++ b/stubs/android/view/Menu.java @@ -1,4 +1,4 @@ // Compile-only stub (API level 10 signatures). Never packaged into the APK. package android.view; -public interface Menu { MenuItem add(int groupId, int itemId, int order, CharSequence title); } +public interface Menu { MenuItem add(int groupId, int itemId, int order, CharSequence title); MenuItem findItem(int id); } diff --git a/stubs/android/view/MenuItem.java b/stubs/android/view/MenuItem.java index f50359f..70d195a 100644 --- a/stubs/android/view/MenuItem.java +++ b/stubs/android/view/MenuItem.java @@ -1,4 +1,4 @@ // Compile-only stub (API level 10 signatures). Never packaged into the APK. package android.view; -public interface MenuItem { int getItemId(); MenuItem setIcon(int iconRes); } +public interface MenuItem { int getItemId(); MenuItem setIcon(int iconRes); MenuItem setTitle(CharSequence title); } diff --git a/stubs/android/view/MotionEvent.java b/stubs/android/view/MotionEvent.java new file mode 100644 index 0000000..21c9849 --- /dev/null +++ b/stubs/android/view/MotionEvent.java @@ -0,0 +1,8 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public final class MotionEvent { + MotionEvent() { throw new RuntimeException("Stub!"); } + public final float getX() { throw new RuntimeException("Stub!"); } + public final float getY() { throw new RuntimeException("Stub!"); } +} diff --git a/stubs/android/view/View.java b/stubs/android/view/View.java index 86d5c13..bf724df 100644 --- a/stubs/android/view/View.java +++ b/stubs/android/view/View.java @@ -13,4 +13,22 @@ public class View { public Object getTag() { throw new RuntimeException("Stub!"); } public void setTag(Object tag) { throw new RuntimeException("Stub!"); } public void setEnabled(boolean enabled) { throw new RuntimeException("Stub!"); } + public final ViewParent getParent() { throw new RuntimeException("Stub!"); } + public final int getWidth() { throw new RuntimeException("Stub!"); } + public final int getHeight() { throw new RuntimeException("Stub!"); } + public void invalidate() { throw new RuntimeException("Stub!"); } + protected void onDraw(android.graphics.Canvas canvas) { throw new RuntimeException("Stub!"); } + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { throw new RuntimeException("Stub!"); } + protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) { throw new RuntimeException("Stub!"); } + public boolean onTouchEvent(MotionEvent event) { throw new RuntimeException("Stub!"); } + public boolean performHapticFeedback(int feedbackConstant) { throw new RuntimeException("Stub!"); } + public static class MeasureSpec { + public static final int UNSPECIFIED = 0; + public static final int EXACTLY = 1073741824; + public static final int AT_MOST = -2147483648; + public MeasureSpec() { throw new RuntimeException("Stub!"); } + public static int getMode(int measureSpec) { throw new RuntimeException("Stub!"); } + public static int getSize(int measureSpec) { throw new RuntimeException("Stub!"); } + } + public void setBackgroundResource(int resid) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/view/ViewGroup.java b/stubs/android/view/ViewGroup.java index 51ed939..52d4d85 100644 --- a/stubs/android/view/ViewGroup.java +++ b/stubs/android/view/ViewGroup.java @@ -5,6 +5,7 @@ public abstract class ViewGroup extends View { public ViewGroup(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } public void addView(View child) { throw new RuntimeException("Stub!"); } public void addView(View child, LayoutParams params) { throw new RuntimeException("Stub!"); } + public void removeView(View view) { throw new RuntimeException("Stub!"); } public static class LayoutParams { public LayoutParams(int width, int height) { throw new RuntimeException("Stub!"); } } public static class MarginLayoutParams extends LayoutParams { public MarginLayoutParams(int width, int height) { super(width, height); throw new RuntimeException("Stub!"); } diff --git a/stubs/android/view/ViewParent.java b/stubs/android/view/ViewParent.java new file mode 100644 index 0000000..3330b94 --- /dev/null +++ b/stubs/android/view/ViewParent.java @@ -0,0 +1,4 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.view; + +public interface ViewParent {} diff --git a/stubs/android/widget/ListView.java b/stubs/android/widget/ListView.java index 73e6929..d39acc5 100644 --- a/stubs/android/widget/ListView.java +++ b/stubs/android/widget/ListView.java @@ -4,4 +4,5 @@ package android.widget; public class ListView extends AbsListView { public ListView(android.content.Context context) { super(context); throw new RuntimeException("Stub!"); } public void setAdapter(ListAdapter adapter) { throw new RuntimeException("Stub!"); } + public void setSelection(int position) { throw new RuntimeException("Stub!"); } } diff --git a/stubs/android/widget/TextView.java b/stubs/android/widget/TextView.java index f2b11eb..a78f5dc 100644 --- a/stubs/android/widget/TextView.java +++ b/stubs/android/widget/TextView.java @@ -11,4 +11,5 @@ public class TextView extends android.view.View { public void setInputType(int type) { throw new RuntimeException("Stub!"); } public void setSingleLine(boolean singleLine) { throw new RuntimeException("Stub!"); } public void setMinLines(int minlines) { throw new RuntimeException("Stub!"); } + public void setGravity(int gravity) { throw new RuntimeException("Stub!"); } } diff --git a/test/org/gingercal/CoreTest.java b/test/org/gingercal/CoreTest.java index cb59407..bd0c6e8 100644 --- a/test/org/gingercal/CoreTest.java +++ b/test/org/gingercal/CoreTest.java @@ -157,6 +157,20 @@ public class CoreTest { for (Ev e : fe) { if (e.title.equals("Standup")) fs++; if (e.title.equals("Standup (moved)")) fm++; } check(fs == 4 && fm == 1, "client-side RRULE/EXDATE/override expansion (" + fs + "+" + fm + ")"); + // --- which days an event shows on in the month grid + long d0m = ICal.startOfDay(now), d1m = ICal.addDays(d0m, 1), d2m = ICal.addDays(d0m, 2); + check(ICal.onDay(d0m + 10 * ICal.HOUR, d0m + 11 * ICal.HOUR, d0m) && !ICal.onDay(d0m + 10 * ICal.HOUR, d0m + 11 * ICal.HOUR, d1m), "timed event on its day only"); + check(ICal.onDay(d0m, d2m, d0m) && ICal.onDay(d0m, d2m, d1m) && !ICal.onDay(d0m, d2m, d2m), "2-day all-day spans exactly 2 days"); + check(ICal.onDay(d0m + 22 * ICal.HOUR, d1m, d0m) && !ICal.onDay(d0m + 22 * ICal.HOUR, d1m, d1m), "ending at midnight stays on first day"); + check(ICal.onDay(d0m + 23 * ICal.HOUR, d1m + 2 * ICal.HOUR, d1m), "overnight event shows on both days"); + check(ICal.onDay(d1m, d1m, d1m) && !ICal.onDay(d1m, d1m, d0m), "zero-length event at midnight on its day"); + java.util.TimeZone saved = java.util.TimeZone.getDefault(); + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("Europe/Madrid")); + java.util.Calendar dst = java.util.Calendar.getInstance(); dst.clear(); dst.set(2026, 9, 25); // 25 h day + long dstDay = dst.getTimeInMillis(), dstNext = ICal.addDays(dstDay, 1); + check(dstNext - dstDay == 25 * ICal.HOUR && ICal.onDay(dstDay + 24 * ICal.HOUR, dstDay + 24 * ICal.HOUR + 1, dstDay), "DST-end day is 25 h long and handled"); + java.util.TimeZone.setDefault(saved); + // --- folding String longLine = ICal.fold("DESCRIPTION:" + "ñ".repeat(60)); boolean okFold = true; diff --git a/test/org/gingercal/I18nTest.java b/test/org/gingercal/I18nTest.java new file mode 100644 index 0000000..be997f7 --- /dev/null +++ b/test/org/gingercal/I18nTest.java @@ -0,0 +1,55 @@ +package org.gingercal; + +import java.util.Calendar; +import java.util.TimeZone; + +/** Galician output: date names, month titles, first weekday, formatted and server messages. */ +public class I18nTest { + static int fails; + + static void check(String got, String want) { + boolean ok = got.equals(want); + System.out.println((ok ? " ok " : " FAIL ") + got + (ok ? "" : " (want: " + want + ")")); + if (!ok) fails++; + } + + public static void main(String[] a) throws Exception { + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Madrid")); + Calendar c = Calendar.getInstance(); + c.clear(); + c.set(2026, Calendar.OCTOBER, 15, 9, 30); + long t = c.getTimeInMillis(); + + L.gl = true; + check(L.df("EEE d MMM").format(c.getTime()), "xov 15 out"); + check(L.df("EEEE d 'de' MMMM").format(c.getTime()), "xoves 15 de outubro"); + check(L.monthTitle(t), "Outubro de 2026"); + c.set(Calendar.MONTH, Calendar.JUNE); + check(L.monthTitle(c.getTimeInMillis()), "Xuño de 2026"); + check(String.valueOf(L.firstDayOfWeek() == Calendar.MONDAY), "true"); + check(L.f("Today · %s", "xov 15 out"), "Hoxe · xov 15 out"); + check(L.f("%d min before", 15), "15 min antes"); + check(L.f("Every %d hours", 3), "Cada 3 horas"); + Syncer.Result r = new Syncer.Result(); + r.calendars = 2; + r.fetched = 1; + r.events = 7; + r.skipped = 1; + check(r.summary(), "Calendarios: 2, actualizados: 1 (eventos: 7), sen cambios: 1"); + check(new DavException.UntrustedCert("AA", "s", "i", "e", false).getMessage(), + "Este teléfono non confía no certificado do servidor"); + try { + Dav.checkRedirect("http://192.168.1.20/remote.php/dav/", "https://nxc.anosanube.ovh/remote.php/dav/", + new java.util.ArrayList(), 301); + } catch (DavException e) { + check(e.getMessage().substring(0, 58), "O servidor redirixiu http:// a https://nxc.anosanube.ovh ("); + } + check(L.t("not in the table"), "not in the table"); // unknown text falls back to English + + L.gl = false; + check(L.monthTitle(t), "October 2026"); + check(r.summary(), "2 calendars: 1 refreshed (7 events), 1 unchanged"); + System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); + System.exit(fails == 0 ? 0 : 1); + } +} diff --git a/tools/check_i18n.py b/tools/check_i18n.py new file mode 100644 index 0000000..542f094 --- /dev/null +++ b/tools/check_i18n.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Translation check (run by build.sh): + 1. every L.t("...") / L.f("...") key in the code has a Galician entry in L.java, + 2. English and Galician use the same placeholders in the same order, + 3. no English text reaches the screen without going through L (setText/setTitle/... literals), + 4. no unused entries. +usage: check_i18n.py """ +import os +import re +import sys + +root = sys.argv[1] if len(sys.argv) > 1 else "." +LIT = r'"((?:[^"\\]|\\.)*)"' + + +def unescape(s): + out, i = [], 0 + while i < len(s): + c = s[i] + if c == "\\" and i + 1 < len(s): + n = s[i + 1] + if n == "u": + out.append(chr(int(s[i + 2:i + 6], 16))) + i += 6 + continue + out.append({"n": "\n", "t": "\t", "r": "\r", '"': '"', "'": "'", "\\": "\\"}.get(n, n)) + i += 2 + continue + out.append(c) + i += 1 + return "".join(out) + + +def placeholders(s): + return re.findall(r"%[sdn%]", s) + + +lsrc = open(os.path.join(root, "src/org/gingercal/L.java"), encoding="utf-8").read() +table = {} +for m in re.finditer(r"put\(\s*" + LIT + r"\s*,\s*" + LIT + r"\s*\);", lsrc, re.S): + en, gal = unescape(m.group(1)), unescape(m.group(2)) + if en in table: + print("DUPLICATE key:", en[:60]) + table[en] = gal + +problems, used = [], set() +UI_CALL = re.compile(r"\b(setText|setTitle|setMessage|setHint|setPositiveButton|setNegativeButton|setNeutralButton|" + r"toast\([^,]+,|ProgressDialog\.show\([^,]+,[^,]+,|Rows\.add|rows\.add|Rows\.add|" + r"new DavException\([^,]+,|new IOException\(|label\(|field\([^,]+,)\s*\(?\s*" + LIT) +for d in ("src", "android"): + for dirpath, _, files in os.walk(os.path.join(root, d)): + for f in files: + if not f.endswith(".java") or f == "L.java": + continue + path = os.path.join(dirpath, f) + text = open(path, encoding="utf-8").read() + for m in re.finditer(r"\bL\.[tf]\(\s*" + LIT, text): + key = unescape(m.group(1)) + used.add(key) + if key not in table: + problems.append("%s: no Galician for %r" % (f, key[:70])) + elif placeholders(key) != placeholders(table[key]): + problems.append("%s: placeholders differ for %r" % (f, key[:70])) + for line_no, line in enumerate(text.splitlines(), 1): + for m in UI_CALL.finditer(line): + s = unescape(m.group(2)) + if re.search(r"[A-Za-z]{2,}", s) and s not in ("Galego", "English", "Lingua / Language"): + problems.append("%s:%d: untranslated UI text %r" % (f, line_no, s[:60])) + for m in re.finditer(r"\.add\(\s*" + LIT, line): + s = unescape(m.group(1)) + if " " in s and re.search(r"[A-Za-z]{3,} [A-Za-z]{3,}", s): + problems.append("%s:%d: untranslated list text %r" % (f, line_no, s[:60])) + +for key in sorted(set(table) - used): + problems.append("unused entry in L.java: %r" % key[:70]) +for p in problems: + print("I18N:", p) +print("translations: %d entries, %d keys used, %s" % (len(table), len(used), "OK" if not problems else "PROBLEMS")) +sys.exit(1 if problems else 0)