diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java b/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java index 4d55723a2266..4a8d69464273 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Preferences.java @@ -54,6 +54,8 @@ import com.ichi2.compat.CompatHelper; import com.ichi2.libanki.Collection; import com.ichi2.libanki.Utils; +import com.ichi2.libanki.backend.exception.BackendNotSupportedException; +import com.ichi2.libanki.sched.AbstractSched; import com.ichi2.preferences.NumberRangePreference; import com.ichi2.themes.Themes; import com.ichi2.ui.AppCompatPreferenceActivity; @@ -586,6 +588,14 @@ private void initPreference(android.preference.Preference pref) { Calendar calendar = col.crtGregorianCalendar(); ((SeekBarPreference)pref).setValue(calendar.get(Calendar.HOUR_OF_DAY)); break; + case "newTimezoneHandling": + android.preference.CheckBoxPreference checkBox = (android.preference.CheckBoxPreference) pref; + checkBox.setChecked(col.getSched()._new_timezone_enabled()); + if (col.schedVer() <= 1 || !col.isUsingRustBackend()) { + Timber.d("Disabled 'newTimezoneHandling' box"); + checkBox.setEnabled(false); + } + break; case "schedVer": ((android.preference.CheckBoxPreference)pref).setChecked(conf.optInt("schedVer", 1) == 2); } @@ -722,6 +732,25 @@ private void updatePreference(SharedPreferences prefs, String key, PreferenceCon pm.setComponentEnabledSetting(providerName, state, PackageManager.DONT_KILL_APP); break; } + case "newTimezoneHandling" : { + if (getCol().schedVer() != 1 && getCol().isUsingRustBackend()) { + AbstractSched sched = getCol().getSched(); + boolean was_enabled = sched._new_timezone_enabled(); + boolean is_enabled = ((android.preference.CheckBoxPreference) pref).isChecked(); + if (was_enabled != is_enabled) { + if (is_enabled) { + try { + sched.set_creation_offset(); + } catch (BackendNotSupportedException e) { + throw e.alreadyUsingRustBackend(); + } + } else { + sched.clear_creation_offset(); + } + } + } + break; + } case "schedVer": { boolean wantNew = ((android.preference.CheckBoxPreference) pref).isChecked(); boolean haveNew = getCol().schedVer() == 2; diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.java b/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.java index f7cf92631a9f..97732be5a6df 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/Collection.java @@ -23,7 +23,6 @@ import android.content.res.Resources; import android.database.Cursor; import android.database.sqlite.SQLiteDatabaseLockedException; -import android.os.Build; import android.text.TextUtils; import android.util.Pair; @@ -37,6 +36,7 @@ import com.ichi2.libanki.backend.DroidBackend; import com.ichi2.async.ProgressSender; import com.ichi2.async.TaskManager; +import com.ichi2.libanki.backend.exception.BackendNotSupportedException; import com.ichi2.libanki.exception.NoSuchDeckException; import com.ichi2.libanki.exception.UnknownDatabaseVersionException; import com.ichi2.libanki.hooks.ChessFilter; @@ -246,6 +246,13 @@ private void _loadScheduler() { mSched = new Sched(this); } else if (ver == 2) { mSched = new SchedV2(this); + if (!getServer() && isUsingRustBackend()) { + try { + getConf().put("localOffset", getSched()._current_timezone_offset()); + } catch (BackendNotSupportedException e) { + throw e.alreadyUsingRustBackend(); + } + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/Consts.java b/AnkiDroid/src/main/java/com/ichi2/libanki/Consts.java index 2936d4579672..c71f54be93c7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/Consts.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/Consts.java @@ -15,6 +15,8 @@ ****************************************************************************************/ package com.ichi2.libanki; +import net.ankiweb.rsdroid.RustCleanup; + import java.lang.annotation.Retention; import androidx.annotation.IntDef; @@ -124,7 +126,9 @@ public class Consts { public static final int SYNC_ZIP_COUNT = 25; public static final String SYNC_BASE = "https://sync%s.ankiweb.net/"; public static final Integer DEFAULT_HOST_NUM = null; - public static final int SYNC_VER = 9; + /* Note: 10 if using Rust backend, 9 if using Java. Set in BackendFactory.getInstance */ + @RustCleanup("Use 10") + public static int SYNC_VER = 9; public static final String HELP_SITE = "http://ankisrs.net/docs/manual.html"; diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackend.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackend.java index 15f766879050..c731ad6bf833 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackend.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackend.java @@ -17,6 +17,8 @@ package com.ichi2.libanki.backend; import com.ichi2.libanki.DB; +import com.ichi2.libanki.backend.exception.BackendNotSupportedException; +import com.ichi2.libanki.backend.model.SchedTimingToday; import androidx.annotation.VisibleForTesting; @@ -34,4 +36,27 @@ public interface DroidBackend { @VisibleForTesting(otherwise = VisibleForTesting.NONE) void debugEnsureNoOpenPointers(); + + /** + * Obtains Timing information for the current day. + * + * @param createdSecs A UNIX timestamp of the collection creation time + * @param createdMinsWest The offset west of UTC at the time of creation (eg UTC+10 hours is -600) + * @param nowSecs timestamp of the current time + * @param nowMinsWest The current offset west of UTC + * @param rolloverHour The hour of the day the rollover happens (eg 4 for 4am) + * @return Timing information for the current day. See {@link SchedTimingToday}. + */ + SchedTimingToday sched_timing_today(long createdSecs, int createdMinsWest, long nowSecs, int nowMinsWest, int rolloverHour) throws BackendNotSupportedException; + + /** + * For the given timestamp, return minutes west of UTC in the local timezone.

+ * + * eg, Australia at +10 hours is -600.
+ * Includes the daylight savings offset if applicable. + * + * @param timestampSeconds The timestamp in seconds + * @return minutes west of UTC in the local timezone + */ + int local_minutes_west(long timestampSeconds) throws BackendNotSupportedException; } diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackendFactory.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackendFactory.java index adfc4b58bb03..1b8720406bc5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackendFactory.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/DroidBackendFactory.java @@ -17,9 +17,11 @@ package com.ichi2.libanki.backend; import com.ichi2.anki.AnkiDroidApp; +import com.ichi2.libanki.Consts; import net.ankiweb.rsdroid.BackendFactory; import net.ankiweb.rsdroid.RustBackendFailedException; +import net.ankiweb.rsdroid.RustCleanup; import androidx.annotation.Nullable; import timber.log.Timber; @@ -36,6 +38,7 @@ private DroidBackendFactory() { * Obtains an instance of a {@link DroidBackend}. * Each call to this method will generate a separate instance which can handle a new Anki collection */ + @RustCleanup("Change back to a constant SYNC_VER") public static DroidBackend getInstance(boolean useBackend) { BackendFactory backendFactory = null; @@ -47,7 +50,11 @@ public static DroidBackend getInstance(boolean useBackend) { AnkiDroidApp.sendExceptionReport(e, "DroidBackendFactory::getInstance"); } } - return getInstance(backendFactory); + + DroidBackend instance = getInstance(backendFactory); + // Update the Sync version if we can load the Rust + Consts.SYNC_VER = backendFactory == null ? 9 : 10; + return instance; } private static DroidBackend getInstance(@Nullable BackendFactory backendFactory) { diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/JavaDroidBackend.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/JavaDroidBackend.java index 4fcede2d4acd..83379a90d43e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/JavaDroidBackend.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/JavaDroidBackend.java @@ -17,6 +17,8 @@ package com.ichi2.libanki.backend; import com.ichi2.libanki.DB; +import com.ichi2.libanki.backend.exception.BackendNotSupportedException; +import com.ichi2.libanki.backend.model.SchedTimingToday; import net.ankiweb.rsdroid.RustCleanup; @@ -56,4 +58,16 @@ public boolean isUsingRustBackend() { public void debugEnsureNoOpenPointers() { // no-op } + + + @Override + public SchedTimingToday sched_timing_today(long createdSecs, int createdMinsWest, long nowSecs, int nowMinsWest, int rolloverHour) throws BackendNotSupportedException { + throw new BackendNotSupportedException(); + } + + + @Override + public int local_minutes_west(long timestampSeconds) throws BackendNotSupportedException { + throw new BackendNotSupportedException(); + } } diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/RustDroidBackend.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/RustDroidBackend.java index 131e103e7bb2..34cc312fd598 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/RustDroidBackend.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/RustDroidBackend.java @@ -17,12 +17,12 @@ package com.ichi2.libanki.backend; import com.ichi2.libanki.DB; +import com.ichi2.libanki.backend.model.SchedTimingToday; +import com.ichi2.libanki.backend.model.SchedTimingTodayProto; import net.ankiweb.rsdroid.BackendFactory; -import net.ankiweb.rsdroid.BackendUtils; import BackendProto.AdBackend; -import timber.log.Timber; /** The Backend in Rust */ public class RustDroidBackend implements DroidBackend { @@ -69,4 +69,16 @@ public void debugEnsureNoOpenPointers() { throw new IllegalStateException("Contained unclosed sequence numbers: " + numbers); } } + + @Override + public SchedTimingToday sched_timing_today(long createdSecs, int createdMinsWest, long nowSecs, int nowMinsWest, int rolloverHour) { + AdBackend.SchedTimingTodayOut2 res = mBackend.getBackend().schedTimingTodayLegacy(createdSecs, createdMinsWest, nowSecs, nowMinsWest, rolloverHour); + return new SchedTimingTodayProto(res); + } + + + @Override + public int local_minutes_west(long timestampSeconds) { + return mBackend.getBackend().localMinutesWest(timestampSeconds).getVal(); + } } diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/exception/BackendNotSupportedException.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/exception/BackendNotSupportedException.java new file mode 100644 index 000000000000..e89da97ab26d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/exception/BackendNotSupportedException.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.libanki.backend.exception; + +import net.ankiweb.rsdroid.RustCleanup; + +@RustCleanup("All of these should be removed when JavaBackend is removed") +public class BackendNotSupportedException extends Exception { + public RuntimeException alreadyUsingRustBackend() { + return new RuntimeException("A BackendNotSupportedException should not occur when using Rust backend", this); + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/model/SchedTimingToday.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/model/SchedTimingToday.java new file mode 100644 index 000000000000..4f6a9a920fde --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/model/SchedTimingToday.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.libanki.backend.model; + +public interface SchedTimingToday { + /** The number of days that have passed since the collection was created.
+ * uint32 upstream */ + int days_elapsed(); + /** + * Timestamp of the next day rollover.
+ * int64 upstream */ + long next_day_at(); +} + diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/backend/model/SchedTimingTodayProto.java b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/model/SchedTimingTodayProto.java new file mode 100644 index 000000000000..d4bef522758a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/backend/model/SchedTimingTodayProto.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.libanki.backend.model; + +import BackendProto.AdBackend; + +/** + * Adapter for SchedTimingTodayOut2 result from Rust + */ +public class SchedTimingTodayProto implements SchedTimingToday { + + private final AdBackend.SchedTimingTodayOut2 mData; + + public SchedTimingTodayProto(AdBackend.SchedTimingTodayOut2 data) { + mData = data; + } + + + @Override + public int days_elapsed() { + return mData.getDaysElapsed(); + } + + + @Override + public long next_day_at() { + return mData.getNextDayAt(); + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/sched/AbstractSched.java b/AnkiDroid/src/main/java/com/ichi2/libanki/sched/AbstractSched.java index 57f9fbee0e97..760447b6a6d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/sched/AbstractSched.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/sched/AbstractSched.java @@ -14,6 +14,7 @@ import com.ichi2.libanki.Deck; import com.ichi2.libanki.DeckConfig; import com.ichi2.libanki.Collection; +import com.ichi2.libanki.backend.exception.BackendNotSupportedException; import java.lang.ref.WeakReference; import java.util.List; @@ -23,8 +24,6 @@ import androidx.annotation.VisibleForTesting; import timber.log.Timber; -import androidx.annotation.NonNull; - /** * In this documentation, I will call "normal use" the fact that between two successive calls to `getCard`, either the * result of the first `getCard` is sent to `answerCard` or the scheduler is reset (through `reset` or `defer @@ -518,4 +517,15 @@ protected static void leech(@NonNull Card card, @Nullable Activity activity) { * @return The number of revlog in the collection */ public abstract int logCount(); + + public abstract int _current_timezone_offset() throws BackendNotSupportedException; + + public abstract boolean _new_timezone_enabled(); + /** + * Save the UTC west offset at the time of creation into the DB. + * Once stored, this activates the new timezone handling code. + */ + public abstract void set_creation_offset() throws BackendNotSupportedException; + + public abstract void clear_creation_offset(); } diff --git a/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java b/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java index 2e7c55ca26ea..ea0bd0ccc508 100644 --- a/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java +++ b/AnkiDroid/src/main/java/com/ichi2/libanki/sched/SchedV2.java @@ -44,6 +44,8 @@ import com.ichi2.libanki.Deck; import com.ichi2.libanki.DeckConfig; +import com.ichi2.libanki.backend.exception.BackendNotSupportedException; +import com.ichi2.libanki.backend.model.SchedTimingToday; import com.ichi2.libanki.utils.Time; import com.ichi2.utils.Assert; import com.ichi2.utils.JSONArray; @@ -51,6 +53,8 @@ import com.ichi2.utils.JSONObject; import com.ichi2.utils.SyncStatus; +import net.ankiweb.rsdroid.RustCleanup; + import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Calendar; @@ -2173,12 +2177,23 @@ private int _previewDelay(@NonNull Card card) { */ /* Overriden: other way to count time*/ + @RustCleanup("remove timing == null check once JavaBackend is removed") public void _updateCutoff() { int oldToday = mToday == null ? 0 : mToday; - // days since col created - mToday = _daysSinceCreation(); - // end of day cutoff - mDayCutoff = _dayCutoff(); + + SchedTimingToday timing = _timingToday(); + + if (timing == null) { + mToday = _daysSinceCreation(); + mDayCutoff = _dayCutoff(); + } else if (_new_timezone_enabled()) { + mToday = timing.days_elapsed(); + mDayCutoff = timing.next_day_at(); + } else { + mToday = _daysSinceCreation(); + mDayCutoff = _dayCutoff(); + } + if (oldToday != mToday) { mCol.log(mToday, mDayCutoff); } @@ -2217,7 +2232,7 @@ private long _dayCutoff() { private int _daysSinceCreation() { Calendar c = mCol.crtCalendar(); - c.set(Calendar.HOUR, mCol.getConf().optInt("rollover", 4)); + c.set(Calendar.HOUR, _rolloverHour()); c.set(Calendar.MINUTE, 0); c.set(Calendar.SECOND, 0); c.set(Calendar.MILLISECOND, 0); @@ -2225,6 +2240,60 @@ private int _daysSinceCreation() { return (int) (((getTime().intTimeMS() - c.getTimeInMillis()) / 1000) / SECONDS_PER_DAY); } + private int _rolloverHour() { + return getCol().getConf().optInt("rollover", 4); + } + + // New timezone handling + ////////////////////////////////////////////////////////////////////////// + + @Override + public boolean _new_timezone_enabled() { + JSONObject conf = getCol().getConf(); + return conf.has("creationOffset") && !conf.isNull("creationOffset"); + } + + @Nullable + private SchedTimingToday _timingToday() { + try { + return getCol().getBackend().sched_timing_today( + getCol().getCrt(), + _creation_timezone_offset(), + getTime().intTime(), + _current_timezone_offset(), + _rolloverHour()); + } catch (BackendNotSupportedException e) { + return null; + } + } + + @Override + public int _current_timezone_offset() throws BackendNotSupportedException { + if (getCol().getServer()) { + return getCol().getConf().optInt("localOffset", 0); + } else { + return getCol().getBackend().local_minutes_west(getTime().intTime()); + } + } + + private int _creation_timezone_offset() { + return getCol().getConf().optInt("creationOffset", 0); + } + + @Override + public void set_creation_offset() throws BackendNotSupportedException { + int mins_west = getCol().getBackend().local_minutes_west(getCol().getCrt()); + getCol().getConf().put("creationOffset", mins_west); + getCol().setMod(); + } + + @Override + public void clear_creation_offset() { + if (getCol().getConf().has("creationOffset")) { + getCol().getConf().remove("creationOffset"); + getCol().setMod(); + } + } protected void update(@NonNull Deck g) { for (String t : new String[] { "new", "rev", "lrn", "time" }) { diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 4c2f5c8f5b14..54c27a9656a6 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -154,6 +154,7 @@ Learn ahead limit Timebox time limit XXX min + New timezone handling Start of next day XXX hours past midnight Experimental V2 scheduler diff --git a/AnkiDroid/src/main/res/xml/preferences_reviewing.xml b/AnkiDroid/src/main/res/xml/preferences_reviewing.xml index a1273b28f8b5..45e9ba4bd455 100644 --- a/AnkiDroid/src/main/res/xml/preferences_reviewing.xml +++ b/AnkiDroid/src/main/res/xml/preferences_reviewing.xml @@ -50,6 +50,11 @@ android:title="@string/time_limit" app:max="9999" app:min="0" /> + +