Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c3d6da91 | ||
|
|
f44ecf4871 |
No files matched your search
+6
-6
@@ -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="1"
|
||||
android:versionName="1.0">
|
||||
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" />
|
||||
|
||||
@@ -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,21 +130,31 @@ 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
|
||||
`adb`, `adb install GingerCal.apk` prints the exact reason (`INSTALL_FAILED_...`).
|
||||
|
||||
## Building
|
||||
|
||||
Needs JDK 11+, Python 3 with Pillow, and `d8` (Android SDK build-tools). No Gradle, no aapt:
|
||||
`tools/mkapk.py` writes the binary manifest and resource table itself.
|
||||
Needs JDK 11+, Python 3 with Pillow, `d8` and `apksigner.jar` (Android SDK build-tools), and
|
||||
`apktool.jar` (2.9+, which bundles Google's `aapt` and smali). No Gradle.
|
||||
|
||||
```sh
|
||||
./build.sh # uses ./stubs to compile
|
||||
ANDROID_JAR=$SDK/platforms/android-10/android.jar ./build.sh # or the real platform jar
|
||||
D8=d8 APKTOOL=~/apktool.jar APKSIGNER=$SDK/build-tools/34.0.0/lib/apksigner.jar ./build.sh
|
||||
# add ANDROID_JAR=$SDK/platforms/android-10/android.jar to compile against the real platform
|
||||
```
|
||||
|
||||
`gingercal.keystore` (password `android`) is the key the supplied APK was signed with; keep
|
||||
building with it so updates install over the existing app. A different key means uninstalling
|
||||
first. Signing uses SHA-1 because Android before 4.3 rejects anything else.
|
||||
Pipeline: javac → d8 → baksmali/smali (clean dex 035) → `aapt` v1 for manifest and resources →
|
||||
`apksigner` v1 signature → zipalign. The script ends by running `apksigner verify` for API 8–10
|
||||
and checking the dex header, and fails rather than produce an APK that 2.3 would reject.
|
||||
|
||||
Don't sign with `jarsigner`: modern versions add PKCS#7 signed attributes, which Android only
|
||||
accepts from 4.4, so the phone refuses to install it. That was the bug in the first build.
|
||||
`gingercal.keystore` (password `android`) is the signing key; keep using it so updates install
|
||||
over the existing app.
|
||||
|
||||
Tests (desktop JVM, need a CalDAV server such as Radicale on :5232 with user `xose`/`secret`):
|
||||
|
||||
@@ -126,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
|
||||
@@ -136,8 +174,10 @@ 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
|
||||
- `tools/` APK packer, icon, zipalign, d8 workaround, API checker
|
||||
- `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
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 (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();
|
||||
}
|
||||
if (rows.isEmpty() && App.configured(this)) rows.add("No upcoming events. Menu > New event, or Menu > Sync.");
|
||||
adapter.notifyDataSetChanged();
|
||||
showStatus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- menu
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu m) {
|
||||
icon(m.add(0, M_SYNC, 0, "Sync"), "ic_menu_refresh");
|
||||
icon(m.add(0, M_NEW, 1, "New event"), "ic_menu_add");
|
||||
icon(m.add(0, M_CALS, 2, "Calendars"), "ic_menu_agenda");
|
||||
icon(m.add(0, M_SETTINGS, 3, "Settings"), "ic_menu_preferences");
|
||||
icon(m.add(0, M_VIEW, 0, L.t("View")), "ic_menu_month");
|
||||
icon(m.add(0, M_NEW, 1, L.t("New event")), "ic_menu_add");
|
||||
icon(m.add(0, M_SYNC, 2, L.t("Sync")), "ic_menu_refresh");
|
||||
icon(m.add(0, M_TODAY, 3, L.t("Today")), "ic_menu_today");
|
||||
icon(m.add(0, M_CALS, 4, L.t("Calendars")), "ic_menu_agenda");
|
||||
icon(m.add(0, M_SETTINGS, 5, L.t("Settings")), "ic_menu_preferences");
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareOptionsMenu(Menu m) {
|
||||
MenuItem v = m.findItem(M_VIEW);
|
||||
if (v != null) {
|
||||
v.setTitle(monthMode ? L.t("Agenda") : L.t("Month"));
|
||||
icon(v, monthMode ? "ic_menu_agenda" : "ic_menu_month");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void icon(MenuItem i, String name) {
|
||||
int id = App.sysDrawable(this, name);
|
||||
if (id == 0) id = App.sysDrawable(this, "ic_menu_my_calendar");
|
||||
if (id != 0) i.setIcon(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case M_VIEW:
|
||||
monthMode = !monthMode;
|
||||
App.prefs(this).edit().putString("view", monthMode ? "month" : "agenda").commit();
|
||||
buildBody();
|
||||
reload(true);
|
||||
return true;
|
||||
case M_TODAY:
|
||||
if (monthMode) goToday();
|
||||
else agendaList.setSelection(0);
|
||||
return true;
|
||||
case M_SYNC:
|
||||
if (App.configured(this)) App.requestSync(this, true, false);
|
||||
else startActivity(new Intent(this, SettingsActivity.class));
|
||||
if (App.configured(this)) {
|
||||
fetched.clear();
|
||||
App.requestSync(this, true, false);
|
||||
} else {
|
||||
startActivity(new Intent(this, SettingsActivity.class));
|
||||
}
|
||||
return true;
|
||||
case M_NEW:
|
||||
if (writableCalendars().isEmpty()) {
|
||||
App.toast(this, "Sync first: no writable calendar yet.");
|
||||
} else {
|
||||
startActivity(new Intent(this, EditActivity.class));
|
||||
}
|
||||
newEvent(monthMode ? selectedDay : -1);
|
||||
return true;
|
||||
case M_CALS:
|
||||
startActivity(new Intent(this, CalendarsActivity.class));
|
||||
@@ -188,6 +480,17 @@ public class MainActivity extends Activity {
|
||||
return false;
|
||||
}
|
||||
|
||||
void newEvent(long day) {
|
||||
if (writableCalendars().isEmpty()) {
|
||||
App.toast(this, L.t("Sync first: no writable calendar yet."));
|
||||
return;
|
||||
}
|
||||
Intent i = new Intent(this, EditActivity.class);
|
||||
if (day >= 0) i.putExtra("day", day);
|
||||
fetched.clear();
|
||||
startActivity(i);
|
||||
}
|
||||
|
||||
List<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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
TASK FOR CLAUDE CODE (on the Pangolin server, 141.227.129.182)
|
||||
Add a raw TCP resource on port 8443 so the GingerCal app (Android 2.3) can reach Nextcloud CalDAV
|
||||
==================================================================================================
|
||||
|
||||
CONTEXT
|
||||
-------
|
||||
- This server runs Pangolin (Pangolin + Gerbil + Traefik in Docker). Traefik terminates HTTPS for
|
||||
every public site with Let's Encrypt certificates, TLS 1.2 minimum, routed by SNI.
|
||||
- Android 2.3 can't use that: TLS 1.0 only, no ISRG Root X1, unreliable SNI. So the TLS for this
|
||||
app is NOT terminated here. The home server (172.30.1.123, site connector "newt") runs a
|
||||
container "caldav-bridge" (nginx) on 172.30.1.123:8443. It speaks TLS 1.0 with a self-signed
|
||||
certificate the app pins, and forwards only /remote.php/dav/ to Nextcloud (nxc.anosanube.ovh).
|
||||
That part is already done and tested.
|
||||
- This server only has to pass raw TCP on port 8443 through the newt tunnel to that bridge:
|
||||
|
||||
phone --TLS 1.0--> VPS :8443 (gerbil/traefik, raw TCP) --WireGuard--> newt
|
||||
--> 172.30.1.123:8443 (caldav-bridge) --HTTP--> nextcloud-aio-apache:11000
|
||||
|
||||
- Certificate fingerprint the bridge serves (SHA-256):
|
||||
56:70:CE:E5:41:6D:B1:31:E8:74:51:52:66:8C:70:27:96:AE:28:8F:69:3E:AB:EE:4A:FD:FD:6B:6C:21:17:65
|
||||
|
||||
RULES
|
||||
-----
|
||||
- Read the existing config before changing anything. Don't assume paths or service names; find them.
|
||||
- Back up every file before editing it (cp file file.bak-YYYYMMDD-HHMM).
|
||||
- Show the user the exact diff and get their OK before restarting any container or editing
|
||||
docker-compose.yml.
|
||||
- Don't change TLS options, routers, entrypoints or certificates used by any existing site.
|
||||
- Don't touch Pangolin's database.
|
||||
|
||||
STEPS
|
||||
-----
|
||||
1. Find the Pangolin install directory (docker-compose.yml defining pangolin, gerbil, traefik).
|
||||
Note the Pangolin version (raw TCP/UDP resources need Pangolin 1.4 or later; if it's older,
|
||||
stop and tell the user).
|
||||
Check that nothing on the VPS already listens on 8443:
|
||||
ss -ltnp | grep ':8443 '
|
||||
If 8443 is taken, stop and tell the user; another public port works too (any port, as long as
|
||||
steps 2-5 use the same number), and the phone address changes to match.
|
||||
|
||||
2. config/config.yml: enable raw resources. MERGE into an existing "flags:" key if there is one;
|
||||
never create a duplicate top-level key.
|
||||
flags:
|
||||
allow_raw_resources: true
|
||||
|
||||
3. docker-compose.yml, the "gerbil" service (Traefik shares gerbil's network namespace, so
|
||||
public ports are published on gerbil): add to its ports list
|
||||
- 8443:8443
|
||||
|
||||
4. config/traefik/traefik_config.yml, "entryPoints": add (the name MUST be tcp-<port>; Pangolin
|
||||
generates its TCP routers against that name)
|
||||
entryPoints:
|
||||
tcp-8443:
|
||||
address: ":8443/tcp"
|
||||
|
||||
5. Firewall: allow inbound TCP 8443.
|
||||
- On the host: e.g. `ufw allow 8443/tcp` if ufw is active (check `ufw status`), or the
|
||||
equivalent for nftables/iptables if that's what's in use.
|
||||
- The cloud provider's firewall / security group, if there is one: tell the user to open
|
||||
TCP 8443 there; you probably can't do that from the shell.
|
||||
|
||||
6. Validate the YAML files:
|
||||
python3 -c 'import yaml,sys;[yaml.safe_load(open(f)) for f in sys.argv[1:]]' \
|
||||
config/config.yml config/traefik/traefik_config.yml docker-compose.yml
|
||||
Show the diff of all three files and ask the user before applying. Then:
|
||||
docker compose down && docker compose up -d
|
||||
(a plain restart won't pick up the new gerbil port). Check
|
||||
docker compose ps
|
||||
docker logs --since 2m traefik 2>&1 | grep -iE 'error|tcp-8443'
|
||||
docker logs --since 2m pangolin 2>&1 | grep -iE 'error'
|
||||
and confirm every existing site still loads.
|
||||
|
||||
7. Pangolin dashboard (the user does this, or guide them through it):
|
||||
- Org of the home server, Resources, Add Resource, type "Raw TCP/UDP resource".
|
||||
- Name: GingerCal CalDAV. Protocol: TCP. Public port: 8443.
|
||||
- Site: the site whose newt runs on the home server 172.30.1.123 (most likely the "casa" site).
|
||||
- Target: IP 172.30.1.123, port 8443. Leave "Enable Proxy Protocol" OFF (the bridge doesn't
|
||||
expect it; enabling only one side breaks every connection).
|
||||
- Pangolin SSO doesn't apply to raw TCP resources. That's expected: Nextcloud asks for its
|
||||
own login (the phone will use a Nextcloud app password).
|
||||
|
||||
8. VERIFY from the VPS (or anywhere with OpenSSL 3):
|
||||
# TLS 1.0 with a plain-RSA cipher (what Android 2.3 uses) must work:
|
||||
echo | openssl s_client -connect 141.227.129.182:8443 -tls1 -cipher 'AES128-SHA:@SECLEVEL=0' 2>&1 \
|
||||
| grep -E 'Protocol *:|Cipher is|alert'
|
||||
# expect: Protocol: TLSv1 / Cipher is AES128-SHA
|
||||
# It must be the bridge's certificate (not a Let's Encrypt one):
|
||||
echo | openssl s_client -connect 141.227.129.182:8443 2>/dev/null | openssl x509 -noout -fingerprint -sha256
|
||||
# expect: 56:70:CE:E5:41:6D:B1:31:E8:74:51:52:66:8C:70:27:96:AE:28:8F:69:3E:AB:EE:4A:FD:FD:6B:6C:21:17:65
|
||||
# Nextcloud must answer CalDAV through it:
|
||||
curl -sk -o /dev/null -w '%{http_code}\n' -X PROPFIND -H 'Depth: 0' https://141.227.129.182:8443/remote.php/dav/
|
||||
# expect: 401 (and "/" should give 404: the web UI is deliberately not exposed)
|
||||
# Existing sites must be unaffected, e.g.:
|
||||
curl -sI https://nxc.anosanube.ovh | head -1
|
||||
If the TLS test times out: check firewall (step 5), the gerbil port (step 3), and that the
|
||||
resource's site/target are right (step 7). If it connects but shows a different certificate,
|
||||
the target is wrong.
|
||||
|
||||
9. ROLLBACK if anything breaks: restore the .bak files, `docker compose down && docker compose up -d`,
|
||||
delete the resource in the dashboard.
|
||||
|
||||
10. REPORT to the user: files changed (with diff), backup paths, firewall changes, output of every
|
||||
check in step 8, and anything left to do (e.g. the cloud firewall).
|
||||
|
||||
PHONE SETUP (after step 8 passes)
|
||||
---------------------------------
|
||||
GingerCal, Menu, Settings:
|
||||
Server: https://141.227.129.182:8443 (any *.oxose.org name works too, e.g.
|
||||
https://cal-old.oxose.org:8443: raw TCP ignores the hostname and the app pins the
|
||||
certificate, not the name)
|
||||
User: your Nextcloud user
|
||||
Password: a Nextcloud app password (Nextcloud, Settings, Security, Devices & sessions)
|
||||
Save and test connection. Tap Trust only if the fingerprint shown is the one above.
|
||||
@@ -1,46 +1,90 @@
|
||||
#!/bin/sh
|
||||
# Builds GingerCal.apk for Android 2.3 (API 10) without Gradle or aapt.
|
||||
# Builds GingerCal.apk for Android 2.3 (API 10) without Gradle.
|
||||
#
|
||||
# Needs: JDK 11+ (javac, keytool, jarsigner), python3 with Pillow (icon), and d8.
|
||||
# D8 command that runs d8 (default: "d8" from Android SDK build-tools on PATH)
|
||||
# ANDROID_JAR optional: a real platforms/android-10/android.jar; otherwise ./stubs is used
|
||||
# KEYSTORE optional: signing keystore (created on first build; keep it to allow upgrades)
|
||||
# Needs: JDK 11+ (javac, keytool), python3 with Pillow (icon), and three tools:
|
||||
# D8 command that runs d8 (Android SDK build-tools; default "d8" on PATH)
|
||||
# APKTOOL path to apktool.jar (https://apktool.org; bundles aapt and smali)
|
||||
# APKSIGNER path to apksigner.jar (Android SDK build-tools/<ver>/lib/apksigner.jar)
|
||||
# Optional:
|
||||
# ANDROID_JAR a real platforms/android-10/android.jar; otherwise ./stubs is used to compile
|
||||
# KEYSTORE signing keystore (created on first build; keep it so updates install over)
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
OUT=build
|
||||
D8=${D8:-d8}
|
||||
APKTOOL=${APKTOOL:?set APKTOOL=/path/to/apktool.jar}
|
||||
APKSIGNER=${APKSIGNER:?set APKSIGNER=/path/to/apksigner.jar}
|
||||
KS=${KEYSTORE:-gingercal.keystore}
|
||||
VERSION_CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml)
|
||||
VERSION_NAME=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' AndroidManifest.xml)
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex"
|
||||
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"
|
||||
else
|
||||
javac -nowarn -Xlint:none --release 8 -d "$OUT/stubs" $(find stubs -name "*.java") 2>/dev/null
|
||||
CP="$OUT/stubs"
|
||||
fi
|
||||
|
||||
# Java 8 bytecode, Java 6-era APIs only (Android 2.3 runs a Harmony class library).
|
||||
javac --release 8 -encoding UTF-8 -Xlint:-options -cp "$CP" -d "$OUT/classes" $(find src android -name '*.java')
|
||||
|
||||
python3 tools/strip_method_params.py $(find "$OUT/classes" -name '*.class')
|
||||
|
||||
# dex format 035 for Dalvik
|
||||
# (d8 warns about library classes it can't see; harmless since nothing needs desugaring)
|
||||
# 2. dex (format 035 for Dalvik), then disassemble so smali re-assembles a clean dex
|
||||
$D8 --min-api 9 --release --output "$OUT/dex" $(find "$OUT/classes" -name '*.class') >"$OUT/d8.log" 2>&1 \
|
||||
|| { cat "$OUT/d8.log"; exit 1; }
|
||||
mkdir -p "$OUT/tools"
|
||||
javac -nowarn -cp "$APKTOOL" -d "$OUT/tools" tools/Disassemble.java
|
||||
java -cp "$APKTOOL:$OUT/tools" Disassemble "$OUT/dex/classes.dex" "$OUT/apk/smali" 8
|
||||
|
||||
python3 tools/mkicon.py "$OUT/icon.png"
|
||||
python3 tools/mkapk.py AndroidManifest.xml "$OUT/dex/classes.dex" "$OUT/icon.png" "$OUT/unsigned.apk"
|
||||
# 3. resources + manifest, compiled by real aapt (v1, the tool of the Gingerbread era)
|
||||
cp AndroidManifest.xml "$OUT/apk/"
|
||||
cp -r res/values "$OUT/apk/res/"
|
||||
python3 tools/mkicon.py "$OUT/apk/res/drawable/icon.png" 48
|
||||
python3 tools/mkicon.py "$OUT/apk/res/drawable-ldpi/icon.png" 36
|
||||
cat > "$OUT/apk/apktool.yml" <<EOF
|
||||
version: 2.9.3
|
||||
apkFileName: GingerCal.apk
|
||||
isFrameworkApk: false
|
||||
usesFramework:
|
||||
ids:
|
||||
- 1
|
||||
tag: null
|
||||
sdkInfo:
|
||||
minSdkVersion: 8
|
||||
targetSdkVersion: 10
|
||||
packageInfo:
|
||||
forcedPackageId: 127
|
||||
renameManifestPackage: null
|
||||
versionInfo:
|
||||
versionCode: $VERSION_CODE
|
||||
versionName: $VERSION_NAME
|
||||
resourcesAreCompressed: false
|
||||
sharedLibrary: false
|
||||
sparseResources: false
|
||||
doNotCompress:
|
||||
- resources.arsc
|
||||
- png
|
||||
EOF
|
||||
java -jar "$APKTOOL" -q b --use-aapt1 -api 8 -f -o "$OUT/unsigned.apk" "$OUT/apk"
|
||||
|
||||
# 4. sign. Android < 4.3 needs a plain v1 (JAR) signature with SHA-1 and no PKCS#7 signed
|
||||
# attributes; apksigner picks exactly that for --min-sdk-version 8. (jarsigner does not.)
|
||||
if [ ! -f "$KS" ]; then
|
||||
keytool -genkeypair -keystore "$KS" -storetype PKCS12 -storepass android -keypass android \
|
||||
-alias gingercal -keyalg RSA -keysize 2048 -sigalg SHA1withRSA -validity 10000 -dname "CN=GingerCal" 2>/dev/null
|
||||
fi
|
||||
cp "$OUT/unsigned.apk" "$OUT/signed.apk"
|
||||
# Android < 4.3 only verifies SHA-1 JAR signatures.
|
||||
jarsigner -keystore "$KS" -storepass android -digestalg SHA1 -sigalg SHA1withRSA \
|
||||
"$OUT/signed.apk" gingercal >/dev/null
|
||||
java -jar "$APKSIGNER" sign --ks "$KS" --ks-key-alias gingercal --ks-pass pass:android --key-pass pass:android \
|
||||
--min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \
|
||||
--v4-signing-enabled false --out "$OUT/signed.apk" "$OUT/unsigned.apk"
|
||||
python3 tools/zipalign.py "$OUT/signed.apk" GingerCal.apk
|
||||
|
||||
# 5. refuse to ship anything Android 2.3 would reject
|
||||
java -jar "$APKSIGNER" verify --min-sdk-version 8 --max-sdk-version 10 GingerCal.apk
|
||||
python3 tools/check_dex.py GingerCal.apk
|
||||
ls -l GingerCal.apk
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">GingerCal</string>
|
||||
</resources>
|
||||
+15
-22
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!"); }
|
||||
}
|
||||
@@ -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!"); } }
|
||||
@@ -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!"); }
|
||||
}
|
||||
@@ -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,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!"); } }
|
||||
@@ -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,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,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); }
|
||||
@@ -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!"); }
|
||||
}
|
||||
@@ -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!"); }
|
||||
}
|
||||
@@ -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!"); }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Compile-only stub (API level 10 signatures). Never packaged into the APK.
|
||||
package android.view;
|
||||
|
||||
public interface ViewParent {}
|
||||
@@ -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!"); }
|
||||
}
|
||||
@@ -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!"); }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import java.io.File;
|
||||
|
||||
import com.android.tools.smali.baksmali.Baksmali;
|
||||
import com.android.tools.smali.baksmali.BaksmaliOptions;
|
||||
import com.android.tools.smali.dexlib2.DexFileFactory;
|
||||
import com.android.tools.smali.dexlib2.Opcodes;
|
||||
import com.android.tools.smali.dexlib2.iface.DexFile;
|
||||
import com.android.tools.smali.dexlib2.iface.MultiDexContainer;
|
||||
|
||||
/**
|
||||
* Disassembles d8's classes.dex to smali with the baksmali bundled in apktool.jar, so that
|
||||
* apktool re-assembles it with dexlib2 (clean dex 035, correct checksums).
|
||||
* usage: java -cp apktool.jar:. Disassemble classes.dex out-dir api-level
|
||||
*/
|
||||
public class Disassemble {
|
||||
public static void main(String[] a) throws Exception {
|
||||
int api = Integer.parseInt(a[2]);
|
||||
MultiDexContainer<? extends DexFile> c = DexFileFactory.loadDexContainer(new File(a[0]), Opcodes.forApi(api));
|
||||
if (c.getDexEntryNames().size() != 1) throw new IllegalStateException("expected one dex, got " + c.getDexEntryNames());
|
||||
DexFile dex = c.getEntry(c.getDexEntryNames().get(0)).getDexFile();
|
||||
BaksmaliOptions o = new BaksmaliOptions();
|
||||
o.apiLevel = api;
|
||||
o.debugInfo = true; // keep line numbers for readable stack traces
|
||||
o.localsDirective = true;
|
||||
o.sequentialLabels = true;
|
||||
if (!Baksmali.disassembleDexFile(dex, new File(a[1]), 1, o)) {
|
||||
throw new IllegalStateException("baksmali reported errors");
|
||||
}
|
||||
System.out.println("disassembled " + dex.getClasses().size() + " classes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Checks the dex inside an APK the way Dalvik's dexopt does at install time:
|
||||
format version, file size, Adler-32 checksum, SHA-1 signature, and no post-035 sections.
|
||||
usage: check_dex.py app.apk"""
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
d = zipfile.ZipFile(sys.argv[1]).read("classes.dex")
|
||||
problems = []
|
||||
if d[:8] != b"dex\n035\x00":
|
||||
problems.append("dex version %r (Android 2.3 needs 035)" % d[:8])
|
||||
if struct.unpack("<I", d[32:36])[0] != len(d):
|
||||
problems.append("file_size field wrong")
|
||||
if zlib.adler32(d[12:]) & 0xFFFFFFFF != struct.unpack("<I", d[8:12])[0]:
|
||||
problems.append("Adler-32 checksum wrong")
|
||||
if hashlib.sha1(d[32:]).digest() != d[12:32]:
|
||||
problems.append("SHA-1 signature wrong")
|
||||
map_off = struct.unpack("<I", d[52:56])[0]
|
||||
for i in range(struct.unpack("<I", d[map_off:map_off + 4])[0]):
|
||||
t = struct.unpack("<H", d[map_off + 4 + i * 12:map_off + 6 + i * 12])[0]
|
||||
if t in (7, 8):
|
||||
problems.append("contains call_site/method_handle sections (needs Android 8+)")
|
||||
for p in problems:
|
||||
print("DEX PROBLEM:", p)
|
||||
print("classes.dex: %d bytes, %s" % (len(d), "OK" if not problems else "REJECTED"))
|
||||
sys.exit(1 if problems else 0)
|
||||
@@ -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)
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Packs an APK without aapt: binary AndroidManifest.xml, a tiny resources.arsc
|
||||
(icon + app name), classes.dex. Output is unsigned; build.sh signs and aligns it.
|
||||
|
||||
usage: mkapk.py AndroidManifest.xml classes.dex icon.png out.apk
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
|
||||
# android.R.attr ids (stable since API 1-4)
|
||||
ATTR_IDS = {
|
||||
"theme": 0x01010000, "label": 0x01010001, "icon": 0x01010002, "name": 0x01010003,
|
||||
"permission": 0x01010006, "enabled": 0x0101000e, "exported": 0x01010010,
|
||||
"configChanges": 0x0101001f, "minSdkVersion": 0x0101020c, "versionCode": 0x0101021b,
|
||||
"versionName": 0x0101021c, "windowSoftInputMode": 0x0101022b,
|
||||
"targetSdkVersion": 0x01010270, "installLocation": 0x010102b7,
|
||||
}
|
||||
INT_ATTRS = {"versionCode", "minSdkVersion", "targetSdkVersion"}
|
||||
BOOL_ATTRS = {"exported", "enabled"}
|
||||
FLAGS = {
|
||||
"configChanges": {"mcc": 1, "mnc": 2, "locale": 4, "touchscreen": 8, "keyboard": 0x10,
|
||||
"keyboardHidden": 0x20, "navigation": 0x40, "orientation": 0x80,
|
||||
"screenLayout": 0x100, "uiMode": 0x200, "fontScale": 0x40000000},
|
||||
"windowSoftInputMode": {"stateUnspecified": 0, "stateHidden": 2, "stateAlwaysHidden": 3,
|
||||
"adjustResize": 0x10, "adjustPan": 0x20},
|
||||
"installLocation": {"auto": 0, "internalOnly": 1, "preferExternal": 2},
|
||||
}
|
||||
|
||||
T_REF, T_STRING, T_INT_DEC, T_INT_HEX, T_BOOL = 0x01, 0x03, 0x10, 0x11, 0x12
|
||||
|
||||
# Our own resources: type id -> (type name, [(key, value)])
|
||||
RES_TYPES = [("drawable", [("icon", "res/drawable/icon.png")]),
|
||||
("string", [("app_name", "GingerCal")])]
|
||||
|
||||
|
||||
def res_id(ref):
|
||||
typ, key = ref.lstrip("@").split("/")
|
||||
for ti, (tname, entries) in enumerate(RES_TYPES):
|
||||
for ei, (k, _) in enumerate(entries):
|
||||
if tname == typ and k == key:
|
||||
return 0x7F000000 | ((ti + 1) << 16) | ei
|
||||
raise SystemExit("unknown resource " + ref)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- string pool
|
||||
|
||||
def string_pool(strings):
|
||||
"""UTF-16 ResStringPool (understood by every Android version)."""
|
||||
offsets, data = [], b""
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
u = s.encode("utf-16-le")
|
||||
n = len(u) // 2
|
||||
if n > 0x7FFF:
|
||||
raise SystemExit("string too long")
|
||||
data += struct.pack("<H", n) + u + b"\0\0"
|
||||
while len(data) % 4:
|
||||
data += b"\0"
|
||||
header = 28
|
||||
strings_start = header + 4 * len(strings)
|
||||
size = strings_start + len(data)
|
||||
return (struct.pack("<HHIIIIII", 0x0001, header, size, len(strings), 0, 0, strings_start, 0)
|
||||
+ b"".join(struct.pack("<I", o) for o in offsets) + data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- AXML
|
||||
|
||||
def axml(manifest_path):
|
||||
tree = ET.parse(manifest_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# collect strings: attribute names with resource ids first (resource map order)
|
||||
attr_names, other = [], []
|
||||
|
||||
def walk(e):
|
||||
for k in e.attrib:
|
||||
ns, local = split(k)
|
||||
if ns == ANDROID_NS:
|
||||
if local not in ATTR_IDS:
|
||||
raise SystemExit("add resource id for android:" + local)
|
||||
if local not in attr_names:
|
||||
attr_names.append(local)
|
||||
for c in e:
|
||||
walk(c)
|
||||
|
||||
def split(k):
|
||||
if k.startswith("{"):
|
||||
ns, local = k[1:].split("}")
|
||||
return ns, local
|
||||
return None, k
|
||||
|
||||
walk(root)
|
||||
attr_names.sort(key=lambda n: ATTR_IDS[n])
|
||||
strings = list(attr_names)
|
||||
|
||||
def idx(s):
|
||||
if s not in strings:
|
||||
strings.append(s)
|
||||
return strings.index(s)
|
||||
|
||||
idx("android")
|
||||
idx(ANDROID_NS)
|
||||
|
||||
events = [] # built after strings are known; strings may grow while encoding
|
||||
|
||||
def encode_value(local, ns, v):
|
||||
if v.startswith("@") and "/" in v and not v.startswith("@android:"):
|
||||
return 0xFFFFFFFF, T_REF, res_id(v)
|
||||
if ns == ANDROID_NS and local in INT_ATTRS:
|
||||
return 0xFFFFFFFF, T_INT_DEC, int(v)
|
||||
if ns == ANDROID_NS and local in BOOL_ATTRS:
|
||||
return 0xFFFFFFFF, T_BOOL, 0xFFFFFFFF if v == "true" else 0
|
||||
if ns == ANDROID_NS and local in FLAGS:
|
||||
n = 0
|
||||
for part in v.split("|"):
|
||||
n |= FLAGS[local][part.strip()]
|
||||
return 0xFFFFFFFF, T_INT_HEX, n
|
||||
si = idx(v)
|
||||
return si, T_STRING, si
|
||||
|
||||
def element(e, line):
|
||||
ns, local = split(e.tag)
|
||||
attrs = []
|
||||
for k, v in e.attrib.items():
|
||||
ans, alocal = split(k)
|
||||
raw, typ, data = encode_value(alocal, ans, v)
|
||||
attrs.append((ATTR_IDS.get(alocal, 0) if ans == ANDROID_NS else 0,
|
||||
idx(ANDROID_NS) if ans == ANDROID_NS else 0xFFFFFFFF, idx(alocal), raw, typ, data))
|
||||
attrs.sort(key=lambda a: a[0]) # Android looks attributes up in resource-id order
|
||||
name = idx(local)
|
||||
body = struct.pack("<IIHHHHHH", 0xFFFFFFFF, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for _, ans, an, raw, typ, data in attrs:
|
||||
body += struct.pack("<IIIHBBI", ans, an, raw, 8, 0, typ, data & 0xFFFFFFFF)
|
||||
events.append(struct.pack("<HHIII", 0x0102, 16, 16 + len(body), line, 0xFFFFFFFF) + body)
|
||||
for c in e:
|
||||
line = element(c, line + 1)
|
||||
events.append(struct.pack("<HHIIIII", 0x0103, 16, 24, line, 0xFFFFFFFF, 0xFFFFFFFF, name))
|
||||
return line + 1
|
||||
|
||||
ns_chunk = lambda t: struct.pack("<HHIIIII", t, 16, 24, 1, 0xFFFFFFFF, idx("android"), idx(ANDROID_NS))
|
||||
events.append(ns_chunk(0x0100))
|
||||
element(root, 2)
|
||||
events.append(ns_chunk(0x0101))
|
||||
|
||||
pool = string_pool(strings)
|
||||
resmap = struct.pack("<HHI", 0x0180, 8, 8 + 4 * len(attr_names)) + b"".join(
|
||||
struct.pack("<I", ATTR_IDS[n]) for n in attr_names)
|
||||
body = pool + resmap + b"".join(events)
|
||||
return struct.pack("<HHI", 0x0003, 8, 8 + len(body)) + body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ARSC
|
||||
|
||||
def arsc(package):
|
||||
values = [v for _, entries in RES_TYPES for _, v in entries]
|
||||
global_pool = string_pool(values)
|
||||
type_pool = string_pool([t for t, _ in RES_TYPES])
|
||||
key_pool = string_pool([k for _, entries in RES_TYPES for k, _ in entries])
|
||||
|
||||
chunks = b""
|
||||
vi = 0
|
||||
ki = 0
|
||||
for ti, (tname, entries) in enumerate(RES_TYPES):
|
||||
tid = ti + 1
|
||||
n = len(entries)
|
||||
spec = struct.pack("<HHIBBHI", 0x0202, 16, 16 + 4 * n, tid, 0, 0, n) + b"\0\0\0\0" * n
|
||||
config = struct.pack("<I", 32) + b"\0" * 28 # default configuration
|
||||
header = 20 + len(config)
|
||||
entry_data = b""
|
||||
offsets = b""
|
||||
for _ in entries:
|
||||
offsets += struct.pack("<I", len(entry_data))
|
||||
entry_data += struct.pack("<HHI", 8, 0, ki) + struct.pack("<HBBI", 8, 0, T_STRING, vi)
|
||||
ki += 1
|
||||
vi += 1
|
||||
entries_start = header + 4 * n
|
||||
typ = struct.pack("<HHIBBHII", 0x0201, header, entries_start + len(entry_data), tid, 0, 0, n,
|
||||
entries_start) + config + offsets + entry_data
|
||||
chunks += spec + typ
|
||||
|
||||
pkg_header = 284 # pre-Lollipop ResTable_package (no typeIdOffset)
|
||||
name = package.encode("utf-16-le")[:254].ljust(256, b"\0")
|
||||
type_off = pkg_header
|
||||
key_off = type_off + len(type_pool)
|
||||
pkg_size = key_off + len(key_pool) + len(chunks)
|
||||
pkg = struct.pack("<HHII", 0x0200, pkg_header, pkg_size, 0x7F) + name + struct.pack(
|
||||
"<IIII", type_off, len(RES_TYPES), key_off, ki) + type_pool + key_pool + chunks
|
||||
assert len(struct.pack("<HHII", 0, 0, 0, 0) + name + struct.pack("<IIII", 0, 0, 0, 0)) == pkg_header
|
||||
body = global_pool + pkg
|
||||
return struct.pack("<HHII", 0x0002, 12, 12 + len(body), 1) + body
|
||||
|
||||
|
||||
def main():
|
||||
manifest, dex, icon, out = sys.argv[1:5]
|
||||
package = ET.parse(manifest).getroot().attrib["package"]
|
||||
with zipfile.ZipFile(out, "w") as z:
|
||||
z.writestr(zipfile.ZipInfo("AndroidManifest.xml", (2026, 1, 1, 0, 0, 0)), axml(manifest),
|
||||
compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr(zipfile.ZipInfo("classes.dex", (2026, 1, 1, 0, 0, 0)), open(dex, "rb").read(),
|
||||
compress_type=zipfile.ZIP_DEFLATED)
|
||||
z.writestr(zipfile.ZipInfo("resources.arsc", (2026, 1, 1, 0, 0, 0)), arsc(package),
|
||||
compress_type=zipfile.ZIP_STORED)
|
||||
z.writestr(zipfile.ZipInfo("res/drawable/icon.png", (2026, 1, 1, 0, 0, 0)), open(icon, "rb").read(),
|
||||
compress_type=zipfile.ZIP_STORED)
|
||||
print("wrote", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draws the 48x48 (mdpi) launcher icon: a little calendar page in ginger tones."""
|
||||
"""Draws the launcher icon: a little calendar page in ginger tones.
|
||||
usage: mkicon.py out.png [size] (48 = mdpi, 36 = ldpi like the Galaxy Mini)"""
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
@@ -27,5 +28,6 @@ for row in range(3):
|
||||
d.rounded_rectangle(box, radius=1.5 * S, fill=(0, 130, 201, 255)) # Nextcloud blue
|
||||
else:
|
||||
d.rounded_rectangle(box, radius=1.5 * S, fill=(200, 188, 172, 255))
|
||||
img = img.resize((48, 48), Image.LANCZOS)
|
||||
size = int(sys.argv[2]) if len(sys.argv) > 2 else 48
|
||||
img = img.resize((size, size), Image.LANCZOS)
|
||||
img.save(sys.argv[1] if len(sys.argv) > 1 else "icon.png", optimize=True)
|
||||
+9
-5
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal zipalign: stored entries start on 4-byte boundaries. Keeps v1 (JAR) signatures valid,
|
||||
since those cover file contents, not the zip layout. usage: zipalign.py in.apk out.apk"""
|
||||
"""Minimal zipalign: uncompressed entries start on 4-byte boundaries (like the SDK's
|
||||
`zipalign 4`). Safe after v1 (JAR) signing, which covers entry contents, not zip layout.
|
||||
usage: zipalign.py in.apk out.apk"""
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
@@ -16,9 +18,11 @@ with zipfile.ZipFile(src) as zin, open(dst, "wb") as f:
|
||||
header_end = f.tell() + 30 + len(info.filename.encode("utf-8"))
|
||||
out.extra = b"\0" * ((4 - header_end % 4) % 4)
|
||||
zout.writestr(out, data)
|
||||
with zipfile.ZipFile(dst) as z:
|
||||
|
||||
with open(dst, "rb") as raw, zipfile.ZipFile(dst) as z:
|
||||
for info in z.infolist():
|
||||
if info.compress_type == zipfile.ZIP_STORED:
|
||||
start = info.header_offset + 30 + len(info.filename.encode("utf-8")) + len(info.extra)
|
||||
assert start % 4 == 0, info.filename
|
||||
raw.seek(info.header_offset + 26)
|
||||
n, m = struct.unpack("<HH", raw.read(4))
|
||||
assert (info.header_offset + 30 + n + m) % 4 == 0, info.filename
|
||||
print("aligned", dst)
|
||||
Reference in new issue
Block a user