From 04405b028c1e590ef44eba03b3cd906e2e1aaa53 Mon Sep 17 00:00:00 2001 From: Grishka Date: Fri, 11 Mar 2022 20:13:11 +0300 Subject: [PATCH] Local database --- mastodon/build.gradle | 2 +- .../android/api/CacheController.java | 238 ++++++++++++++++++ .../android/api/session/AccountSession.java | 8 + .../api/session/AccountSessionManager.java | 2 + .../fragments/HomeTimelineFragment.java | 16 +- .../fragments/NotificationsListFragment.java | 30 ++- 6 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java diff --git a/mastodon/build.gradle b/mastodon/build.gradle index de0a7b6f8..d46f405d8 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -10,7 +10,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 31 - versionCode 8 + versionCode 9 versionName "0.1" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java new file mode 100644 index 000000000..62122d557 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -0,0 +1,238 @@ +package org.joinmastodon.android.api; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.api.requests.notifications.GetNotifications; +import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.Status; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import androidx.annotation.Nullable; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.utils.WorkerThread; + +public class CacheController{ + private static final String TAG="CacheController"; + private static final int DB_VERSION=1; + private static final WorkerThread databaseThread=new WorkerThread("databaseThread"); + private static final Handler uiHandler=new Handler(Looper.getMainLooper()); + + private final String accountID; + private DatabaseHelper db; + private final Runnable databaseCloseRunnable=this::closeDatabase; + + static{ + databaseThread.start(); + } + + public CacheController(String accountID){ + this.accountID=accountID; + } + + public void getHomeTimeline(String maxID, int count, Callback> callback){ + cancelDelayedClose(); + databaseThread.postRunnable(()->{ + try{ + SQLiteDatabase db=getOrOpenDatabase(); + try(Cursor cursor=db.query("home_timeline", new String[]{"json"}, maxID==null ? null : "`id`>?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){ + if(cursor.getCount()==count){ + ArrayList result=new ArrayList<>(); + cursor.moveToFirst(); + do{ + Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); + status.postprocess(); + result.add(status); + }while(cursor.moveToNext()); + uiHandler.post(()->callback.onSuccess(result)); + return; + } + }catch(IOException x){ + Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); + } + new GetHomeTimeline(maxID, null, count) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + callback.onSuccess(result); + putHomeTimeline(result, maxID==null); + } + + @Override + public void onError(ErrorResponse error){ + callback.onError(error); + } + }) + .exec(accountID); + }catch(SQLiteException x){ + Log.w(TAG, x); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage()))); + }finally{ + closeDelayed(); + } + }, 0); + } + + private void putHomeTimeline(List posts, boolean clear){ + cancelDelayedClose(); + databaseThread.postRunnable(()->{ + try{ + SQLiteDatabase db=getOrOpenDatabase(); + if(clear) + db.delete("home_timeline", null, null); + ContentValues values=new ContentValues(2); + for(Status s:posts){ + values.put("id", s.id); + values.put("json", MastodonAPIController.gson.toJson(s)); + db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + }catch(SQLiteException x){ + Log.w(TAG, x); + }finally{ + closeDelayed(); + } + }, 0); + } + + public void getNotifications(String maxID, int count, boolean onlyMentions, Callback> callback){ + cancelDelayedClose(); + databaseThread.postRunnable(()->{ + try{ + SQLiteDatabase db=getOrOpenDatabase(); + try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id`>?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){ + if(cursor.getCount()==count){ + ArrayList result=new ArrayList<>(); + cursor.moveToFirst(); + do{ + Notification ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), Notification.class); + ntf.postprocess(); + result.add(ntf); + }while(cursor.moveToNext()); + uiHandler.post(()->callback.onSuccess(result)); + return; + } + }catch(IOException x){ + Log.w(TAG, "getNotifications: corrupted notification object in database", x); + } + new GetNotifications(maxID, count, onlyMentions ? EnumSet.complementOf(EnumSet.of(Notification.Type.MENTION)): null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + callback.onSuccess(result); + putNotifications(result, onlyMentions, maxID==null); + } + + @Override + public void onError(ErrorResponse error){ + callback.onError(error); + } + }) + .exec(accountID); + }catch(SQLiteException x){ + Log.w(TAG, x); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage()))); + }finally{ + closeDelayed(); + } + }, 0); + } + + private void putNotifications(List notifications, boolean onlyMentions, boolean clear){ + cancelDelayedClose(); + databaseThread.postRunnable(()->{ + try{ + SQLiteDatabase db=getOrOpenDatabase(); + String table=onlyMentions ? "notifications_mentions" : "notifications_all"; + if(clear) + db.delete(table, null, null); + ContentValues values=new ContentValues(3); + for(Notification n:notifications){ + values.put("id", n.id); + values.put("json", MastodonAPIController.gson.toJson(n)); + values.put("type", n.type.ordinal()); + db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + }catch(SQLiteException x){ + Log.w(TAG, x); + }finally{ + closeDelayed(); + } + }, 0); + } + + private void closeDelayed(){ + databaseThread.postRunnable(databaseCloseRunnable, 10_000); + } + + public void closeDatabase(){ + if(db!=null){ + if(BuildConfig.DEBUG) + Log.d(TAG, "closeDatabase"); + db.close(); + db=null; + } + } + + private void cancelDelayedClose(){ + if(db!=null){ + databaseThread.handler.removeCallbacks(databaseCloseRunnable); + } + } + + private SQLiteDatabase getOrOpenDatabase(){ + if(db==null) + db=new DatabaseHelper(); + return db.getWritableDatabase(); + } + + private class DatabaseHelper extends SQLiteOpenHelper{ + + public DatabaseHelper(){ + super(MastodonApp.context, accountID+".db", null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db){ + db.execSQL(""" + CREATE TABLE `home_timeline` ( + `id` VARCHAR(25) NOT NULL PRIMARY KEY, + `json` TEXT NOT NULL, + `flags` INTEGER NOT NULL DEFAULT 0 + )"""); + db.execSQL(""" + CREATE TABLE `notifications_all` ( + `id` VARCHAR(25) NOT NULL PRIMARY KEY, + `json` TEXT NOT NULL, + `flags` INTEGER NOT NULL DEFAULT 0, + `type` INTEGER NOT NULL + )"""); + db.execSQL(""" + CREATE TABLE `notifications_mentions` ( + `id` VARCHAR(25) NOT NULL PRIMARY KEY, + `json` TEXT NOT NULL, + `flags` INTEGER NOT NULL DEFAULT 0, + `type` INTEGER NOT NULL + )"""); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){ + + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index fecfd9a69..6d8b64f8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.api.session; +import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.StatusInteractionController; import org.joinmastodon.android.model.Account; @@ -19,6 +20,7 @@ public class AccountSession{ public boolean activated=true; private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController; + private transient CacheController cacheController; AccountSession(Token token, Account self, Application app, String domain, int tootCharLimit, Instance instance, boolean activated){ this.token=token; @@ -48,4 +50,10 @@ public class AccountSession{ statusInteractionController=new StatusInteractionController(getID()); return statusInteractionController; } + + public CacheController getCacheController(){ + if(cacheController==null) + cacheController=new CacheController(getID()); + return cacheController; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index bd7b98a40..0263f7b2c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -143,6 +143,8 @@ public class AccountSessionManager{ public void removeAccount(String id){ AccountSession session=getAccount(id); + session.getCacheController().closeDatabase(); + MastodonApp.context.deleteDatabase(id+".db"); sessions.remove(id); if(lastActiveAccountID.equals(id)){ if(sessions.isEmpty()) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 5bce9bf57..2de9dc3b2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -58,14 +58,22 @@ public class HomeTimelineFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetHomeTimeline(offset>0 ? getMaxID() : null, null, count) - .setCallback(new SimpleCallback<>(this){ +// currentRequest=new GetHomeTimeline(offset>0 ? getMaxID() : null, null, count) +// .setCallback(new SimpleCallback<>(this){ +// @Override +// public void onSuccess(List result){ +// onDataLoaded(result, !result.isEmpty()); +// } +// }) +// .exec(accountID); + AccountSessionManager.getInstance() + .getAccount(accountID).getCacheController() + .getHomeTimeline(offset>0 ? getMaxID() : null, count, new SimpleCallback<>(this){ @Override public void onSuccess(List result){ onDataLoaded(result, !result.isEmpty()); } - }) - .exec(accountID); + }); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index de2cc7e3f..3826d2d77 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -10,6 +10,7 @@ import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.notifications.GetNotifications; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Status; @@ -36,15 +37,13 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.V; public class NotificationsListFragment extends BaseStatusListFragment{ - private EnumSet types; + private boolean onlyMentions; @Override public void onAttach(Activity activity){ super.onAttach(activity); setTitle(R.string.notifications); - if(getArguments().getBoolean("onlyMentions", false)){ - types=EnumSet.complementOf(EnumSet.of(Notification.Type.MENTION)); - } + onlyMentions=getArguments().getBoolean("onlyMentions", false); } @Override @@ -79,8 +78,24 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? getMaxID() : null, count, types) - .setCallback(new SimpleCallback<>(this){ +// new GetNotifications(offset>0 ? getMaxID() : null, count, types) +// .setCallback(new SimpleCallback<>(this){ +// @Override +// public void onSuccess(List result){ +// if(refreshing) +// relationships.clear(); +// onDataLoaded(result, !result.isEmpty()); +// Set needRelationships=result.stream() +// .filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id)) +// .map(ntf->ntf.account.id) +// .collect(Collectors.toSet()); +// loadRelationships(needRelationships); +// } +// }) +// .exec(accountID); + AccountSessionManager.getInstance() + .getAccount(accountID).getCacheController() + .getNotifications(offset>0 ? getMaxID() : null, count, onlyMentions, new SimpleCallback>(this){ @Override public void onSuccess(List result){ if(refreshing) @@ -92,8 +107,7 @@ public class NotificationsListFragment extends BaseStatusListFragment