GingerCal 1.3: month view, Galician interface, Nginx Proxy Manager bridge option

This commit is contained in:
xocko committed 2026-10-15 17:42:46 +02:00
1 parent f44ecf4871
commit 42c3d6da91
39 files changed
+1408 -219

No files matched your search

+6 -6
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.gingercal"
android:versionCode="2"
android:versionName="1.1">
android:versionCode="4"
android:versionName="1.3">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
@@ -12,7 +12,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<application android:label="@string/app_name" android:icon="@drawable/icon">
<application android:name=".GingerApp" android:label="@string/app_name" android:icon="@drawable/icon">
<activity android:name=".MainActivity" android:label="@string/app_name"
android:configChanges="keyboardHidden|orientation">
@@ -22,15 +22,15 @@
</intent-filter>
</activity>
<activity android:name=".SettingsActivity" android:label="Settings"
<activity android:name=".SettingsActivity" android:label="@string/app_name"
android:configChanges="keyboardHidden|orientation"
android:windowSoftInputMode="stateHidden|adjustResize" />
<activity android:name=".EditActivity" android:label="Event"
<activity android:name=".EditActivity" android:label="@string/app_name"
android:configChanges="keyboardHidden|orientation"
android:windowSoftInputMode="stateHidden|adjustResize" />
<activity android:name=".CalendarsActivity" android:label="Calendars"
<activity android:name=".CalendarsActivity" android:label="@string/app_name"
android:configChanges="keyboardHidden|orientation" />
<service android:name=".SyncService" android:exported="false" />
+34 -4
View File
@@ -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 <newt address>;`,
`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 <npm>/data/legacy-caldav`, and note the fingerprint.
2. Copy `bridge/npm/http.conf` to `<npm>/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://<nextcloud-LAN-IP>` 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
+28 -28
View File
@@ -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
+6 -6
View File
@@ -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();
+68 -27
View File
@@ -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<ICal.VEvent> 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();
}
}
+12
View File
@@ -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"));
}
}
+386 -70
View File
@@ -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<Object> rows = new ArrayList<Object>();
final Agenda adapter = new Agenda();
boolean askedSetup;
LinearLayout root, body;
TextView status, monthTitle;
ListView agendaList, dayList;
MonthView monthView;
final List<Object> agendaRows = new ArrayList<Object>(), dayRows = new ArrayList<Object>();
final Rows agendaAdapter = new Rows(agendaRows), dayAdapter = new Rows(dayRows);
boolean askedSetup, monthMode, uiGl;
long selectedDay;
List<Ev> monthEvents = new ArrayList<Ev>();
/** Months outside the synced window, fetched from the server on demand (key: grid start). */
final Map<Long, List<Ev>> fetched = new HashMap<Long, List<Ev>>();
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<Object> 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<Ev> 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 (rows.isEmpty() && App.configured(this)) rows.add("No upcoming events. Menu > New event, or Menu > Sync.");
adapter.notifyDataSetChanged();
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<Ev> 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<Ev> evs) {
monthEvents = evs;
Map<Long, List<Integer>> marks = new HashMap<Long, List<Integer>>();
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<Integer> l = marks.get(Long.valueOf(d));
if (l == null) {
l = new ArrayList<Integer>();
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<Void, Void, Object> {
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<Ev> out = new ArrayList<Ev>();
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<Ev>() {
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<Ev>) r);
if (monthMode && monthView.gridStart() == gs) showMonthEvents((List<Ev>) r);
}
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<Cal> writableCalendars() {
List<Cal> out = new ArrayList<Cal>();
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<Ev> keep = new ArrayList<Ev>();
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<Object> rows;
Rows(List<Object> 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;
+215
View File
@@ -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<Long, List<Integer>> marks = new HashMap<Long, List<Integer>>();
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<Long, List<Integer>> 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<Integer> 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);
}
}
+1 -1
View File
@@ -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);
+51 -33
View File
@@ -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<Cal> cals = (List<Cal>) 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();
+3 -2
View File
@@ -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();
+10 -8
View File
@@ -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
+65
View File
@@ -0,0 +1,65 @@
# Legacy-TLS CalDAV endpoint for Android 2.3, inside Nginx Proxy Manager.
#
# Install: copy to <npm data dir>/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;
}
}
+3
View File
@@ -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"
+15 -22
View File
@@ -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<String> 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<Response> 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<Response> out = new ArrayList<Response>();
@@ -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<Response> rs = multistatus("PROPFIND", principal, "0", propfind("<c:calendar-home-set/>"));
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));
}
}
+2 -2
View File
@@ -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;
+7
View File
@@ -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);
+231
View File
@@ -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<String, String> GL = new HashMap<String, String>();
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");
}
}
+6 -2
View File
@@ -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<String> seen = new HashSet<String>();
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);
+1 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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);
}
+3
View File
@@ -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!"); }
}
+7
View File
@@ -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!"); }
}
@@ -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!"); } }
+9
View File
@@ -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!"); }
}
+17
View File
@@ -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 }
}
+1 -1
View File
@@ -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!"); } }
+32
View File
@@ -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!"); }
}
}
+1 -1
View File
@@ -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); }
+1 -1
View File
@@ -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); }
+8
View File
@@ -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!"); }
}
+18
View File
@@ -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!"); }
}
+1
View File
@@ -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!"); }
+4
View File
@@ -0,0 +1,4 @@
// Compile-only stub (API level 10 signatures). Never packaged into the APK.
package android.view;
public interface ViewParent {}
+1
View File
@@ -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!"); }
}
+1
View File
@@ -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!"); }
}
+14
View File
@@ -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;
+55
View File
@@ -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<String>(), 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);
}
}
+79
View File
@@ -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 <project dir>"""
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)