From 10e3d2122f3ffaab0919bd58d5b7918afb0573ad Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 16 Nov 2023 20:32:15 +0100 Subject: [PATCH 01/12] wip --- app/build.gradle | 7 ++++ .../stores/db/ManagedDBIndex.kt | 7 +++- .../stores/db/ManagedDBStore.kt | 38 ++++++++++++++++++- app/src/unstable/assets/sources/youtube | 2 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e4b0d364..3dc122a3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,7 @@ plugins { id 'org.ajoberstar.grgit' version '1.7.2' id 'com.google.protobuf' id 'kotlin-parcelize' + id 'kotlin-kapt' } ext { @@ -194,6 +195,12 @@ dependencies { implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0' + //Database + implementation("androidx.room:room-runtime:2.6.0") + annotationProcessor("androidx.room:room-compiler:2.6.0") + kapt("androidx.room:room-compiler:2.6.0") + implementation("androidx.room:room-ktx:2.6.0") + //Payment implementation 'com.stripe:stripe-android:20.28.3' diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt index d67bde96..6a06104c 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt @@ -1,5 +1,8 @@ package com.futo.platformplayer.stores.db -class ManagedDBIndex { +import androidx.room.PrimaryKey -} \ No newline at end of file +open class ManagedDBIndex( + @PrimaryKey(true) + val id: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 9d24c04f..7fb8e56c 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -1,5 +1,41 @@ package com.futo.platformplayer.stores.db -class ManagedDBStore { +import com.futo.platformplayer.assume +import com.futo.platformplayer.stores.v2.ManagedStore +import com.futo.platformplayer.stores.v2.ReconstructStore +import com.futo.platformplayer.stores.v2.StoreSerializer +import java.io.File +import kotlin.reflect.KClass +import kotlin.reflect.KType + +class ManagedDBStore { + private val _class: KType; + private val _name: String; + private val _serializer: StoreSerializer; + + + private var _isLoaded = false; + + private var _withUnique: ((I) -> Any)? = null; + + val className: String? get() = _class.classifier?.assume>()?.simpleName; + + val name: String; + + constructor(name: String, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { + _name = name; + this.name = niceName ?: name.let { + if(it.isNotEmpty()) + return@let it[0].uppercase() + it.substring(1); + return@let name; + }; + _serializer = serializer; + _class = clazz; + } + + fun load() { + throw NotImplementedError(); + _isLoaded = true; + } } \ No newline at end of file diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 8f10daba..37fd342a 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 8f10daba1ef9cbcd99f3c640d86808f8c94aa84a +Subproject commit 37fd342a760be6351b042732a4052bd54d723eb0 From 99c06c516ff4951a6c42099876138bdd6196c52a Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 17 Nov 2023 22:17:49 +0100 Subject: [PATCH 02/12] WIP Store/testing --- app/build.gradle | 2 +- .../futo/platformplayer/states/StateApp.kt | 81 ++++++++-- .../platformplayer/states/StatePlaylists.kt | 10 ++ .../stores/FragmentedStorage.kt | 2 + .../stores/db/ManagedDBContext.kt | 27 ++++ .../stores/db/ManagedDBContextPaged.kt | 11 ++ .../stores/db/ManagedDBDAOBase.kt | 33 +++++ .../stores/db/ManagedDBDatabase.kt | 7 + .../stores/db/ManagedDBDescriptor.kt | 16 ++ .../stores/db/ManagedDBIndex.kt | 14 +- .../stores/db/ManagedDBIndexOnly.kt | 11 ++ .../stores/db/ManagedDBStore.kt | 140 ++++++++++++++++-- .../stores/db/types/DBChannelCache.kt | 36 +++++ .../stores/db/types/DBHistory.kt | 69 +++++++++ .../views/behavior/GestureControlView.kt | 9 +- 15 files changed, 439 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContext.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContextPaged.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDatabase.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndexOnly.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt diff --git a/app/build.gradle b/app/build.gradle index 3dc122a3..8881e28b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -39,7 +39,7 @@ protobuf { android { namespace 'com.futo.platformplayer' - compileSdk 33 + compileSdk 34 flavorDimensions "buildType" productFlavors { stable { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index c30a4311..8a098137 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -1,24 +1,18 @@ package com.futo.platformplayer.states -import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager import android.media.AudioManager import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.Uri -import android.os.Environment import android.provider.DocumentsContract import android.util.DisplayMetrics -import androidx.activity.ComponentActivity -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope @@ -28,10 +22,9 @@ import com.futo.platformplayer.R import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient -import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.casting.StateCasting @@ -43,20 +36,20 @@ import com.futo.platformplayer.logging.AndroidLogConsumer import com.futo.platformplayer.logging.FileLogConsumer import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.receivers.AudioNoisyReceiver import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.ManagedStore -import com.stripe.android.core.utils.encodeToJson import kotlinx.coroutines.* -import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import java.io.File import java.time.OffsetDateTime import java.util.* import java.util.concurrent.TimeUnit import kotlin.system.measureTimeMillis -import kotlin.time.measureTime /*** * This class contains global context for unconventional cases where obtaining context is hard. @@ -545,7 +538,73 @@ class StateApp { StateAnnouncement.instance.registerDidYouKnow(); Logger.i(TAG, "MainApp Started: Finished"); + + + if(true) { + Logger.i(TAG, "TEST:--------(200)---------"); + testHistoryDB(200); + Logger.i(TAG, "TEST:--------(1000)---------"); + testHistoryDB(1000); + Logger.i(TAG, "TEST:--------(2000)---------"); + testHistoryDB(2000); + Logger.i(TAG, "TEST:--------(4000)---------"); + testHistoryDB(4000); + Logger.i(TAG, "TEST:--------(6000)---------"); + testHistoryDB(6000); + } } + fun testHistoryDB(count: Int) { + Logger.i(TAG, "TEST: Starting tests"); + StatePlaylists.instance._historyDBStore.deleteAll(); + + val testHistoryItem = StatePlaylists.instance.getHistory().first(); + val testItemJson = StatePlaylists.instance.getHistory().first().video.toJson(); + val now = OffsetDateTime.now(); + + val testSet = (0..count).map { HistoryVideo(Json.decodeFromString(testItemJson.replace(testHistoryItem.video.url, UUID.randomUUID().toString())), it.toLong(), now.minusHours(it.toLong())) } + + + Logger.i(TAG, "TEST: Inserting (${testSet.size})"); + val insertMS = measureTimeMillis { + for(item in testSet) + StatePlaylists.instance._historyDBStore.insert(item); + }; + Logger.i(TAG, "TEST: Inserting in ${insertMS}ms"); + + var fetched: List? = null; + val fetchMS = measureTimeMillis { + fetched = StatePlaylists.instance._historyDBStore.getAll(); + Logger.i(TAG, "TEST: Fetched: ${fetched?.size}"); + }; + Logger.i(TAG, "TEST: Fetch speed ${fetchMS}MS"); + val deserializeMS = measureTimeMillis { + val deserialized = StatePlaylists.instance._historyDBStore.convertObjects(fetched!!); + Logger.i(TAG, "TEST: Deserialized: ${deserialized.size}"); + }; + Logger.i(TAG, "TEST: Deserialize speed ${deserializeMS}MS"); + + var fetchedIndex: List? = null; + val fetchIndexMS = measureTimeMillis { + fetchedIndex = StatePlaylists.instance._historyDBStore.getAllIndexes(); + Logger.i(TAG, "TEST: Fetched Index: ${fetchedIndex!!.size}"); + }; + Logger.i(TAG, "TEST: Fetched Index speed ${fetchIndexMS}ms"); + val fetchFromIndex = measureTimeMillis { + for(preItem in testSet) { + val item = StatePlaylists.instance.historyIndex[preItem.video.url]; + if(item == null) + throw IllegalStateException("Missing item [${preItem.video.url}]"); + if(item.url != preItem.video.url) + throw IllegalStateException("Mismatch item [${preItem.video.url}]"); + } + }; + Logger.i(TAG, "TEST: Index Lookup speed ${fetchFromIndex}ms"); + + val page1 = StatePlaylists.instance._historyDBStore.getPage(0, 20); + val page2 = StatePlaylists.instance._historyDBStore.getPage(1, 20); + val page3 = StatePlaylists.instance._historyDBStore.getPage(2, 20); + } + fun mainAppStartedWithExternalFiles(context: Context) { if(!Settings.instance.didFirstStart) { if(StateBackup.hasAutomaticBackup()) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index b5086399..6a309ad7 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.states import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.R import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException @@ -19,6 +20,8 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.db.ManagedDBStore +import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.ReconstructStore import kotlinx.serialization.encodeToString @@ -26,6 +29,8 @@ import kotlinx.serialization.json.Json import java.io.File import java.time.OffsetDateTime import java.time.temporal.ChronoUnit +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap /*** * Used to maintain playlists @@ -50,6 +55,11 @@ class StatePlaylists { .withRestore(PlaylistBackup()) .load(); + val historyIndex: ConcurrentMap = ConcurrentHashMap(); + val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) + .withIndex({ it.url }, historyIndex) + .load(); + val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); var onHistoricVideoChanged = Event2(); diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt index a996a9c8..d439594e 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -2,6 +2,8 @@ package com.futo.platformplayer.stores import com.futo.platformplayer.Settings import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.stores.db.ManagedDBIndex +import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.ManagedStore import com.futo.platformplayer.stores.v2.StoreSerializer diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContext.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContext.kt new file mode 100644 index 00000000..bcbf9cfa --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContext.kt @@ -0,0 +1,27 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update + +/* +@Dao +class ManagedDBContext> { + + fun get(id: Int): I; + fun gets(vararg id: Int): List; + fun getAll(): List; + + @Insert + fun insert(index: I); + @Insert + fun insertAll(vararg indexes: I) + + @Update + fun update(index: I); + + @Delete + fun delete(index: I); +}*/ \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContextPaged.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContextPaged.kt new file mode 100644 index 00000000..3449d3c7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBContextPaged.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Update + +@Dao +interface ManagedDBContextPaged> { + fun getPaged(page: Int, pageSize: Int): List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt new file mode 100644 index 00000000..a679bb16 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt @@ -0,0 +1,33 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Update +import androidx.sqlite.db.SupportSQLiteQuery + + +@Dao +interface ManagedDBDAOBase> { + + @RawQuery + fun get(query: SupportSQLiteQuery): I; + @RawQuery + fun getMultiple(query: SupportSQLiteQuery): List; + + @RawQuery + fun action(query: SupportSQLiteQuery): Int + + @Insert + fun insert(index: I): Long; + @Insert + fun insertAll(vararg indexes: I) + + @Update + fun update(index: I); + + @Delete + fun delete(index: I); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDatabase.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDatabase.kt new file mode 100644 index 00000000..b1b73513 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDatabase.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.RoomDatabase + +abstract class ManagedDBDatabase, D: ManagedDBDAOBase>: RoomDatabase() { + abstract fun base(): D; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt new file mode 100644 index 00000000..7800d649 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt @@ -0,0 +1,16 @@ +package com.futo.platformplayer.stores.db + +import androidx.sqlite.db.SimpleSQLiteQuery +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.db.types.DBHistory + + +abstract class ManagedDBDescriptor, D: ManagedDBDatabase, DA: ManagedDBDAOBase> { + abstract fun dbClass(): Class; + abstract fun create(obj: T): I; + + open val ordered: String? = null; + + open fun sqlIndexOnly(tableName: String): SimpleSQLiteQuery? = null; + open fun sqlPage(tableName: String, page: Int, length: Int): SimpleSQLiteQuery? = null; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt index 6a06104c..78a30abf 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt @@ -1,8 +1,14 @@ package com.futo.platformplayer.stores.db +import androidx.room.ColumnInfo +import androidx.room.Ignore import androidx.room.PrimaryKey +import com.futo.platformplayer.api.media.Serializer -open class ManagedDBIndex( - @PrimaryKey(true) - val id: Int? = null -) \ No newline at end of file +interface ManagedDBIndex { + var id: Long? + var serialized: ByteArray? + + @get:Ignore + var obj: T?; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndexOnly.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndexOnly.kt new file mode 100644 index 00000000..0795555d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndexOnly.kt @@ -0,0 +1,11 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Update + +@Dao +interface ManagedDBIndexOnly> { + fun getIndex(): List; +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 7fb8e56c..98eda24f 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -1,28 +1,40 @@ package com.futo.platformplayer.stores.db +import androidx.room.Room +import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.assume -import com.futo.platformplayer.stores.v2.ManagedStore -import com.futo.platformplayer.stores.v2.ReconstructStore +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer -import java.io.File +import kotlinx.serialization.KSerializer +import java.util.concurrent.ConcurrentMap import kotlin.reflect.KClass import kotlin.reflect.KType -class ManagedDBStore { +class ManagedDBStore, T, D: ManagedDBDatabase, DA: ManagedDBDAOBase> { private val _class: KType; private val _name: String; private val _serializer: StoreSerializer; + private var _db: ManagedDBDatabase? = null; + private var _dbDaoBase: ManagedDBDAOBase? = null; + val dbDaoBase: ManagedDBDAOBase get() = _dbDaoBase ?: throw IllegalStateException("Not initialized db [${name}]"); - private var _isLoaded = false; + private var _dbDescriptor: ManagedDBDescriptor; - private var _withUnique: ((I) -> Any)? = null; + private val _sqlAll: SimpleSQLiteQuery; + private val _sqlDeleteAll: SimpleSQLiteQuery; + private var _sqlIndexed: SimpleSQLiteQuery? = null; val className: String? get() = _class.classifier?.assume>()?.simpleName; val name: String; - constructor(name: String, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { + private val _indexes: ArrayListAny, ConcurrentMap>> = arrayListOf(); + + + constructor(name: String, descriptor: ManagedDBDescriptor, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { + _dbDescriptor = descriptor; _name = name; this.name = niceName ?: name.let { if(it.isNotEmpty()) @@ -31,11 +43,119 @@ class ManagedDBStore { }; _serializer = serializer; _class = clazz; + + _sqlAll = SimpleSQLiteQuery("SELECT * FROM $_name" + if(descriptor.ordered.isNullOrEmpty()) "" else " ${descriptor.ordered}"); + _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${_name}"); + _sqlIndexed = descriptor.sqlIndexOnly(_name); } - fun load() { - throw NotImplementedError(); - _isLoaded = true; + fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap): ManagedDBStore { + if(_sqlIndexed == null) + throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented"); + _indexes.add(Pair(keySelector, indexContainer)); + + return this; } + + fun load(): ManagedDBStore { + _db = Room.databaseBuilder(StateApp.instance.context, _dbDescriptor.dbClass(), _name) + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + _dbDaoBase = _db!!.base() as ManagedDBDAOBase; + if(_indexes.any()) { + val allItems = _dbDaoBase!!.getMultiple(_sqlIndexed!!); + for(index in _indexes) + index.second.putAll(allItems.associateBy(index.first)); + } + + return this; + } + + fun insert(obj: T) { + val newIndex = _dbDescriptor.create(obj); + newIndex.serialized = serialize(obj); + newIndex.id = dbDaoBase.insert(newIndex); + newIndex.serialized = null; + + if(!_indexes.isEmpty()) { + for (index in _indexes) { + val key = index.first(newIndex); + index.second.put(key, newIndex); + } + } + } + fun update(id: Long, obj: T) { + val newIndex = _dbDescriptor.create(obj); + newIndex.id = id; + newIndex.serialized = serialize(obj); + dbDaoBase.update(newIndex); + newIndex.serialized = null; + + if(!_indexes.isEmpty()) { + for (index in _indexes) { + val key = index.first(newIndex); + index.second.put(key, newIndex); + } + } + } + + fun getAllIndexes(): List { + if(_sqlIndexed == null) + throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented"); + return dbDaoBase.getMultiple(_sqlIndexed!!); + } + + fun getAllObjects(): List = convertObjects(getAll()); + fun getAll(): List { + return dbDaoBase.getMultiple(_sqlAll); + } + + fun getObject(id: Long) = convertObject(get(id)); + fun get(id: Long): I { + return dbDaoBase.get(SimpleSQLiteQuery("SELECT * FROM $_name WHERE id = ?", arrayOf(id))); + } + + fun getAllObjects(vararg id: Long): List = convertObjects(getAll(*id)); + fun getAll(vararg id: Long): List { + return dbDaoBase.getMultiple(SimpleSQLiteQuery("SELECT * FROM $_name WHERE id IN (?)", arrayOf(id))); + } + + fun getPageObjects(page: Int, length: Int): List = convertObjects(getPage(page, length)); + fun getPage(page: Int, length: Int): List { + val query = _dbDescriptor.sqlPage(_name, page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}"); + return dbDaoBase.getMultiple(query); + } + fun delete(item: I) { + dbDaoBase.delete(item); + + for(index in _indexes) + index.second.remove(index.first(item)); + } + fun deleteAll() { + dbDaoBase.action(_sqlDeleteAll); + + for(index in _indexes) + index.second.clear(); + } + + + fun convertObject(index: ManagedDBIndex): T? { + return index.serialized?.let { + _serializer.deserialize(_class, it); + }; + } + fun convertObjects(indexes: List>): List { + return indexes.mapNotNull { convertObject(it) }; + } + + fun serialize(obj: T): ByteArray { + return _serializer.serialize(_class, obj); + } + + companion object { + inline fun , D: ManagedDBDatabase, DA: ManagedDBDAOBase> create(name: String, descriptor: ManagedDBDescriptor, serializer: KSerializer? = null) + = ManagedDBStore(name, descriptor, kotlin.reflect.typeOf(), JsonStoreSerializer.create(serializer)); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt new file mode 100644 index 00000000..d6463ec2 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt @@ -0,0 +1,36 @@ +package com.futo.platformplayer.stores.db.types + +import androidx.room.ColumnInfo +import androidx.room.Ignore +import androidx.room.PrimaryKey +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.db.ManagedDBIndex + +class DBChannelCache { + companion object { + const val TABLE_NAME = "channelCache"; + } + + class Index: ManagedDBIndex { + @PrimaryKey(true) + override var id: Long? = null; + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + override var serialized: ByteArray? = null; + + @Ignore + override var obj: SerializedPlatformContent? = null; + + var feedType: String? = null; + var channelUrl: String? = null; + + + constructor() {} + constructor(sCache: SerializedPlatformContent) { + id = null; + serialized = null; + obj = sCache; + channelUrl = sCache.author.url; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt new file mode 100644 index 00000000..2b0d3301 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt @@ -0,0 +1,69 @@ +package com.futo.platformplayer.stores.db.types + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.sqlite.db.SimpleSQLiteQuery +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.db.ManagedDBDAOBase +import com.futo.platformplayer.stores.db.ManagedDBDatabase +import com.futo.platformplayer.stores.db.ManagedDBDescriptor +import com.futo.platformplayer.stores.db.ManagedDBIndex +import com.futo.platformplayer.stores.db.ManagedDBStore +import kotlin.reflect.KType + +class DBHistory { + companion object { + const val TABLE_NAME = "history"; + } + + @Dao + interface DBDAO: ManagedDBDAOBase {} + @Database(entities = [Index::class], version = 2) + abstract class DB: ManagedDBDatabase() { + abstract override fun base(): DBDAO; + } + + class Descriptor: ManagedDBDescriptor() { + override fun create(obj: HistoryVideo): Index = Index(obj); + override fun dbClass(): Class = DB::class.java; + + //Optional + override fun sqlIndexOnly(tableName: String): SimpleSQLiteQuery = SimpleSQLiteQuery("SELECT id, url, position, date FROM $TABLE_NAME"); + override fun sqlPage(tableName: String, page: Int, length: Int): SimpleSQLiteQuery = SimpleSQLiteQuery("SELECT * FROM $TABLE_NAME ORDER BY date DESC, id DESC LIMIT ? OFFSET ?", arrayOf(length, page * length)); + } + + @Entity(TABLE_NAME) + class Index: ManagedDBIndex { + @PrimaryKey(true) + override var id: Long? = null; + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + override var serialized: ByteArray? = null; + + @Ignore + override var obj: HistoryVideo? = null; + + var url: String; + var position: Long; + var date: Long; + + constructor() { + url = ""; + position = 0; + date = 0; + } + constructor(historyVideo: HistoryVideo) { + id = null; + serialized = null; + url = historyVideo.video.url; + position = historyVideo.position; + date = historyVideo.date.toEpochSecond(); + obj = historyVideo; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt index c4990dce..35e65329 100644 --- a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt @@ -92,7 +92,11 @@ class GestureControlView : LinearLayout { override fun onDown(p0: MotionEvent): Boolean { return false; } override fun onShowPress(p0: MotionEvent) = Unit; override fun onSingleTapUp(p0: MotionEvent): Boolean { return false; } - override fun onScroll(p0: MotionEvent, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + override fun onFling(p0: MotionEvent?, p1: MotionEvent, p2: Float, p3: Float): Boolean { return false; } + override fun onScroll(p0: MotionEvent?, p1: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + if(p0 == null) + return false; + if (_isFullScreen && _adjustingBrightness) { val adjustAmount = (distanceY * 2) / height; _brightnessFactor = (_brightnessFactor + adjustAmount).coerceAtLeast(0.0f).coerceAtMost(1.0f); @@ -132,8 +136,7 @@ class GestureControlView : LinearLayout { return true; } - override fun onLongPress(p0: MotionEvent) = Unit; - override fun onFling(p0: MotionEvent, p1: MotionEvent, p2: Float, p3: Float): Boolean { return false; } + override fun onLongPress(p0: MotionEvent) = Unit }); gestureController.setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener { From b65fc594dcdf61dfe6932c06765b100d145912f1 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 20 Nov 2023 21:27:27 +0100 Subject: [PATCH 03/12] Working history DB implementation --- .../api/media/structures/AdhocPager.kt | 31 +++ .../mainactivity/main/VideoDetailView.kt | 60 ++++-- .../futo/platformplayer/states/StateApp.kt | 22 +- .../platformplayer/states/StatePlaylists.kt | 194 ++++++++++++------ .../platformplayer/stores/db/ColumnIndex.kt | 7 + .../platformplayer/stores/db/ColumnOrdered.kt | 5 + .../stores/db/ManagedDBDescriptor.kt | 9 +- .../stores/db/ManagedDBIndex.kt | 13 +- .../stores/db/ManagedDBStore.kt | 149 ++++++++++++-- .../stores/db/types/DBChannelCache.kt | 5 - .../stores/db/types/DBHistory.kt | 23 ++- .../views/adapters/HistoryListAdapter.kt | 24 ++- 12 files changed, 392 insertions(+), 150 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt create mode 100644 app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt new file mode 100644 index 00000000..7e1554f7 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/AdhocPager.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.structures + +class AdhocPager: IPager { + private var _page = 0; + private val _nextPage: (Int) -> List; + private var _currentResults: List = listOf(); + private var _hasMore = true; + + constructor(nextPage: (Int) -> List, initialResults: List? = null){ + _nextPage = nextPage; + if(initialResults != null) + _currentResults = initialResults; + else + nextPage(); + } + + override fun hasMorePages(): Boolean { + return _hasMore; + } + + override fun nextPage() { + val newResults = _nextPage(++_page); + if(newResults.isEmpty()) + _hasMore = false; + _currentResults = newResults; + } + + override fun getResults(): List { + return _currentResults; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 5a366550..66a8685a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -75,6 +75,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.states.* import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage +import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.views.MonetizationView import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout import com.futo.platformplayer.views.casting.CastView @@ -125,6 +126,7 @@ class VideoDetailView : ConstraintLayout { var video: IPlatformVideoDetails? = null private set; private var _playbackTracker: IPlaybackTracker? = null; + private var _historyIndex: DBHistory.Index? = null; val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url; @@ -772,6 +774,15 @@ class VideoDetailView : ConstraintLayout { } } } + suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){ + val current = _historyIndex; + if(current == null || current.url != video.url) { + val index = StatePlaylists.instance.getHistoryByVideo(video, true); + _historyIndex = index; + return@withContext index; + } + return@withContext current; + } //Lifecycle @@ -1248,24 +1259,30 @@ class VideoDetailView : ConstraintLayout { updateQueueState(); - _historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, false, (toResume.toFloat() / 1000.0f).toLong()); - Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); - if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { - _layoutResume.visibility = View.VISIBLE; - _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; + fragment.lifecycleScope.launch(Dispatchers.IO) { + val historyItem = getHistoryIndex(videoDetail); - _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { - try { - delay(8000); + withContext(Dispatchers.Main) { + _historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); + Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); + if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { + _layoutResume.visibility = View.VISIBLE; + _textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"; + + _jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) { + try { + delay(8000); + _layoutResume.visibility = View.GONE; + _textResume.text = ""; + } catch (e: Throwable) { + Logger.e(TAG, "Failed to set resume changes.", e); + } + } + } else { _layoutResume.visibility = View.GONE; _textResume.text = ""; - } catch (e: Throwable) { - Logger.e(TAG, "Failed to set resume changes.", e); } } - } else { - _layoutResume.visibility = View.GONE; - _textResume.text = ""; } @@ -1568,7 +1585,7 @@ class VideoDetailView : ConstraintLayout { if(localVideoSources?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video", - *localVideoSources.stream() + *localVideoSources .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it, { handleSelectVideoTrack(it) }); @@ -1576,7 +1593,7 @@ class VideoDetailView : ConstraintLayout { else null, if(localAudioSource?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio", - *localAudioSource.stream() + *localAudioSource .map { SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, { handleSelectAudioTrack(it) }); @@ -1592,7 +1609,7 @@ class VideoDetailView : ConstraintLayout { else null, if(liveStreamVideoFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", - *liveStreamVideoFormats.stream() + *liveStreamVideoFormats .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, { _player.selectVideoTrack(it.height) }); @@ -1600,7 +1617,7 @@ class VideoDetailView : ConstraintLayout { else null, if(liveStreamAudioFormats?.isEmpty() == false) SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", - *liveStreamAudioFormats.stream() + *liveStreamAudioFormats .map { SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it, { _player.selectAudioTrack(it.bitrate) }); @@ -1609,7 +1626,7 @@ class VideoDetailView : ConstraintLayout { if(bestVideoSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", - *bestVideoSources.stream() + *bestVideoSources .map { SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, { handleSelectVideoTrack(it) }); @@ -1617,7 +1634,7 @@ class VideoDetailView : ConstraintLayout { else null, if(bestAudioSources.isNotEmpty()) SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", - *bestAudioSources.stream() + *bestAudioSources .map { SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, { handleSelectAudioTrack(it) }); @@ -2049,7 +2066,10 @@ class VideoDetailView : ConstraintLayout { val v = video ?: return; val currentTime = System.currentTimeMillis(); if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { - StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + fragment.lifecycleScope.launch(Dispatchers.IO) { + val history = getHistoryIndex(v); + StatePlaylists.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + } _lastPositionSaveTime = currentTime; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 8a098137..08e02159 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -59,20 +59,6 @@ import kotlin.system.measureTimeMillis class StateApp { val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active - /* - private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay"); - - fun getExternalRootDirectory(): File? { - if(!externalRootDirectory.exists()) { - val result = externalRootDirectory.mkdirs(); - if(!result) - return null; - return externalRootDirectory; - } - else - return externalRootDirectory; - }*/ - fun getExternalGeneralDirectory(context: Context): DocumentFile? { val generalUri = Settings.instance.storage.getStorageGeneralUri(); if(isValidStorageUri(context, generalUri)) @@ -539,8 +525,14 @@ class StateApp { StateAnnouncement.instance.registerDidYouKnow(); Logger.i(TAG, "MainApp Started: Finished"); + StatePlaylists.instance.toMigrateCheck(); - if(true) { + StatePlaylists.instance._historyDBStore.deleteAll(); + if(StatePlaylists.instance.shouldMigrateLegacyHistory()) + StatePlaylists.instance.migrateLegacyHistory(); + + + if(false) { Logger.i(TAG, "TEST:--------(200)---------"); testHistoryDB(200); Logger.i(TAG, "TEST:--------(1000)---------"); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index 6a309ad7..43ff1bed 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException @@ -58,6 +59,7 @@ class StatePlaylists { val historyIndex: ConcurrentMap = ConcurrentHashMap(); val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) .withIndex({ it.url }, historyIndex) + .withUnique({ it.url }, historyIndex) .load(); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); @@ -69,6 +71,137 @@ class StatePlaylists { return listOf(playlistStore, _watchlistStore, _historyStore); } + fun shouldMigrateLegacyHistory(): Boolean { + return _historyDBStore.count() == 0 && _historyStore.count() > 0; + } + fun migrateLegacyHistory() { + Logger.i(TAG, "Migrating legacy history"); + _historyDBStore.deleteAll(); + val allHistory = _historyStore.getItems(); + Logger.i(TAG, "Migrating legacy history (${allHistory.size}) items"); + for(item in allHistory) { + _historyDBStore.insert(item); + } + } + + + fun getHistoryPosition(url: String): Long { + return historyIndex[url]?.position ?: 0; + } + fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long { + val pos = if(position < 0) 0 else position; + if(index.obj == null) throw IllegalStateException("Can only update history with a deserialized db item"); + val historyVideo = index.obj!!; + + val positionBefore = historyVideo.position; + if (updateExisting) { + var shouldUpdate = false; + if (positionBefore < 30) { + shouldUpdate = true; + } else { + if (position > 30) { + shouldUpdate = true; + } + } + + if (shouldUpdate) { + + //A unrecovered item + if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) + historyVideo.video = SerializedPlatformVideo.fromVideo(liveObj); + + historyVideo.position = pos; + historyVideo.date = OffsetDateTime.now(); + _historyDBStore.update(index.id!!, historyVideo); + onHistoricVideoChanged.emit(liveObj, pos); + } + + return positionBefore; + } + + return positionBefore; + } + /* + fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long { + val pos = if(position < 0) 0 else position; + val historyVideo = _historyStore.findItem { it.video.url == video.url }; + if (historyVideo != null) { + val positionBefore = historyVideo.position; + if (updateExisting) { + var shouldUpdate = false; + if (positionBefore < 30) { + shouldUpdate = true; + } else { + if (position > 30) { + shouldUpdate = true; + } + } + + if (shouldUpdate) { + + //A unrecovered item + if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) + historyVideo.video = SerializedPlatformVideo.fromVideo(video); + + historyVideo.position = pos; + historyVideo.date = OffsetDateTime.now(); + _historyStore.saveAsync(historyVideo); + onHistoricVideoChanged.emit(video, pos); + } + } + + return positionBefore; + } else { + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now()); + _historyStore.saveAsync(newHistItem); + return 0; + } + } +*/ + fun getHistory() : List { + return _historyDBStore.getAllObjects(); + //return _historyStore.getItems().sortedByDescending { it.date }; + } + fun getHistoryPager(): IPager { + return _historyDBStore.getObjectPager(); + } + fun getHistoryIndexByUrl(url: String): DBHistory.Index? { + return historyIndex[url]; + } + fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false): DBHistory.Index { + val existing = historyIndex[video.url]; + if(existing != null) + return _historyDBStore.get(existing.id!!); + else { + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, OffsetDateTime.now()); + val id = _historyDBStore.insert(newHistItem); + return _historyDBStore.get(id); + } + } + + fun removeHistory(url: String) { + val hist = getHistoryIndexByUrl(url); + if(hist != null) + _historyDBStore.delete(hist.id!!); + /* + val hist = _historyStore.findItem { it.video.url == url }; + if(hist != null) + _historyStore.delete(hist);*/ + } + + fun removeHistoryRange(minutesToDelete: Long) { + val now = OffsetDateTime.now().toEpochSecond(); + val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.date) < minutesToDelete * 60 }; + for(item in toDelete) + _historyDBStore.delete(item); + /* + val now = OffsetDateTime.now(); + val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; + + for(item in toDelete) + _historyStore.delete(item);*/ + } + fun getWatchLater() : List { synchronized(_watchlistStore) { return _watchlistStore.getItems(); @@ -109,6 +242,7 @@ class StatePlaylists { return playlistStore.findItem { it.id == id }; } + fun didPlay(playlistId: String) { val playlist = getPlaylist(playlistId); if(playlist != null) { @@ -117,66 +251,6 @@ class StatePlaylists { } } - fun getHistoryPosition(url: String): Long { - val histVideo = _historyStore.findItem { it.video.url == url }; - if(histVideo != null) - return histVideo.position; - return 0; - } - fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long { - val pos = if(position < 0) 0 else position; - val historyVideo = _historyStore.findItem { it.video.url == video.url }; - if (historyVideo != null) { - val positionBefore = historyVideo.position; - if (updateExisting) { - var shouldUpdate = false; - if (positionBefore < 30) { - shouldUpdate = true; - } else { - if (position > 30) { - shouldUpdate = true; - } - } - - if (shouldUpdate) { - - //A unrecovered item - if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) - historyVideo.video = SerializedPlatformVideo.fromVideo(video); - - historyVideo.position = pos; - historyVideo.date = OffsetDateTime.now(); - _historyStore.saveAsync(historyVideo); - onHistoricVideoChanged.emit(video, pos); - } - } - - return positionBefore; - } else { - val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now()); - _historyStore.saveAsync(newHistItem); - return 0; - } - } - - fun getHistory() : List { - return _historyStore.getItems().sortedByDescending { it.date }; - } - - fun removeHistory(url: String) { - val hist = _historyStore.findItem { it.video.url == url }; - if(hist != null) - _historyStore.delete(hist); - } - - fun removeHistoryRange(minutesToDelete: Long) { - val now = OffsetDateTime.now(); - val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; - - for(item in toDelete) - _historyStore.delete(item); - } - suspend fun createPlaylistFromChannel(channelUrl: String, onPage: (Int) -> Unit): Playlist { val channel = StatePlatform.instance.getChannel(channelUrl).await(); return createPlaylistFromChannel(channel, onPage); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt new file mode 100644 index 00000000..de234590 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnIndex.kt @@ -0,0 +1,7 @@ +package com.futo.platformplayer.stores.db + +import androidx.room.ColumnInfo + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ColumnIndex(val name: String = ColumnInfo.INHERIT_FIELD_NAME) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt new file mode 100644 index 00000000..c1b19df6 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ColumnOrdered.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.stores.db + +@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class ColumnOrdered(val priority: Int, val descending: Boolean = false); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt index 7800d649..ce3c9c70 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt @@ -3,14 +3,13 @@ package com.futo.platformplayer.stores.db import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.stores.db.types.DBHistory +import kotlin.reflect.KClass abstract class ManagedDBDescriptor, D: ManagedDBDatabase, DA: ManagedDBDAOBase> { - abstract fun dbClass(): Class; + abstract val table_name: String; + abstract fun dbClass(): KClass; abstract fun create(obj: T): I; - open val ordered: String? = null; - - open fun sqlIndexOnly(tableName: String): SimpleSQLiteQuery? = null; - open fun sqlPage(tableName: String, page: Int, length: Int): SimpleSQLiteQuery? = null; + abstract fun indexClass(): KClass; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt index 78a30abf..b111ff59 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt @@ -5,10 +5,13 @@ import androidx.room.Ignore import androidx.room.PrimaryKey import com.futo.platformplayer.api.media.Serializer -interface ManagedDBIndex { - var id: Long? - var serialized: ByteArray? +open class ManagedDBIndex { + @ColumnIndex + @PrimaryKey(true) + open var id: Long? = null; + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var serialized: ByteArray? = null; - @get:Ignore - var obj: T?; + @Ignore + var obj: T? = null; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 98eda24f..e2665eac 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -1,15 +1,26 @@ package com.futo.platformplayer.stores.db +import androidx.room.ColumnInfo import androidx.room.Room import androidx.sqlite.db.SimpleSQLiteQuery +import com.futo.platformplayer.api.media.structures.AdhocPager +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.assume +import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer import kotlinx.serialization.KSerializer +import java.lang.reflect.Field +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import kotlin.reflect.KClass import kotlin.reflect.KType +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.javaField class ManagedDBStore, T, D: ManagedDBDatabase, DA: ManagedDBDAOBase> { private val _class: KType; @@ -22,16 +33,25 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA private var _dbDescriptor: ManagedDBDescriptor; + private val _columnInfo: List; + + private val _sqlGet: (Long)-> SimpleSQLiteQuery; + private val _sqlGetAll: (LongArray)-> SimpleSQLiteQuery; private val _sqlAll: SimpleSQLiteQuery; + private val _sqlCount: SimpleSQLiteQuery; private val _sqlDeleteAll: SimpleSQLiteQuery; + private val _sqlDeleteById: (Long) -> SimpleSQLiteQuery; private var _sqlIndexed: SimpleSQLiteQuery? = null; + private var _sqlPage: ((Int, Int) -> SimpleSQLiteQuery)? = null; val className: String? get() = _class.classifier?.assume>()?.simpleName; val name: String; private val _indexes: ArrayListAny, ConcurrentMap>> = arrayListOf(); + private val _indexCollection = ConcurrentHashMap(); + private var _withUnique: Pair<(I)->Any, ConcurrentMap>? = null; constructor(name: String, descriptor: ManagedDBDescriptor, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { _dbDescriptor = descriptor; @@ -43,23 +63,52 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }; _serializer = serializer; _class = clazz; + _columnInfo = _dbDescriptor.indexClass().memberProperties + .filter { it.hasAnnotation() && it.name != "serialized" } + .map { ColumnMetadata(it.javaField!!, it.findAnnotation()!!, it.findAnnotation()) }; - _sqlAll = SimpleSQLiteQuery("SELECT * FROM $_name" + if(descriptor.ordered.isNullOrEmpty()) "" else " ${descriptor.ordered}"); - _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${_name}"); - _sqlIndexed = descriptor.sqlIndexOnly(_name); + val indexColumnNames = _columnInfo.map { it.name }; + + val orderedColumns = _columnInfo.filter { it.ordered != null }.sortedBy { it.ordered!!.priority }; + val orderSQL = if(orderedColumns.size > 0) + " ORDER BY " + orderedColumns.map { "${it.name} ${if(it.ordered!!.descending) "DESC" else "ASC"}" }.joinToString(", "); + else ""; + + _sqlGet = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id = ?", arrayOf(it)) }; + _sqlGetAll = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id IN (?)", arrayOf(it)) }; + _sqlAll = SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL}"); + _sqlCount = SimpleSQLiteQuery("SELECT COUNT(id) FROM ${_dbDescriptor.table_name}"); + _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${_dbDescriptor.table_name}"); + _sqlDeleteById = { id -> SimpleSQLiteQuery("DELETE FROM ${_dbDescriptor.table_name} WHERE id = :id", arrayOf(id)) }; + _sqlIndexed = SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${_dbDescriptor.table_name}"); + + if(orderedColumns.size > 0) { + _sqlPage = { page, length -> + SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL} LIMIT ? OFFSET ?", arrayOf(length, page * length)); + } + } } - fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap): ManagedDBStore { + fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap, withUnique: Boolean = false): ManagedDBStore { if(_sqlIndexed == null) throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented"); _indexes.add(Pair(keySelector, indexContainer)); + if(withUnique) + withUnique(keySelector, indexContainer); + + return this; + } + fun withUnique(keySelector: (I)->Any, indexContainer: ConcurrentMap): ManagedDBStore { + if(_withUnique != null) + throw IllegalStateException("Only 1 unique property is allowed"); + _withUnique = Pair(keySelector, indexContainer); + return this; } - fun load(): ManagedDBStore { - _db = Room.databaseBuilder(StateApp.instance.context, _dbDescriptor.dbClass(), _name) + _db = Room.databaseBuilder(StateApp.instance.context, _dbDescriptor.dbClass().java, _name) .fallbackToDestructiveMigration() .allowMainThreadQueries() .build() @@ -73,18 +122,41 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return this; } - fun insert(obj: T) { + fun getUnique(obj: I): I? { + if(_withUnique == null) + throw IllegalStateException("Unique is not configured for ${name}"); + val key = _withUnique!!.first.invoke(obj); + return _withUnique!!.second[key]; + } + fun isUnique(obj: I): Boolean { + if(_withUnique == null) + throw IllegalStateException("Unique is not configured for ${name}"); + val key = _withUnique!!.first.invoke(obj); + return !_withUnique!!.second.containsKey(key); + } + + fun count(): Int { + return dbDaoBase.action(_sqlCount); + } + + fun insert(obj: T): Long { val newIndex = _dbDescriptor.create(obj); + val unique = getUnique(newIndex); + if(unique != null) + return unique.id!!; + newIndex.serialized = serialize(obj); newIndex.id = dbDaoBase.insert(newIndex); newIndex.serialized = null; + if(!_indexes.isEmpty()) { for (index in _indexes) { val key = index.first(newIndex); index.second.put(key, newIndex); } } + return newIndex.id!!; } fun update(id: Long, obj: T) { val newIndex = _dbDescriptor.create(obj); @@ -109,45 +181,72 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun getAllObjects(): List = convertObjects(getAll()); fun getAll(): List { - return dbDaoBase.getMultiple(_sqlAll); + return deserializeIndexes(dbDaoBase.getMultiple(_sqlAll)); } - fun getObject(id: Long) = convertObject(get(id)); + fun getObject(id: Long) = get(id).obj!!; fun get(id: Long): I { - return dbDaoBase.get(SimpleSQLiteQuery("SELECT * FROM $_name WHERE id = ?", arrayOf(id))); + return deserializeIndex(dbDaoBase.get(_sqlGet(id))); } - fun getAllObjects(vararg id: Long): List = convertObjects(getAll(*id)); + fun getAllObjects(vararg id: Long): List = getAll(*id).map { it.obj!! }; fun getAll(vararg id: Long): List { - return dbDaoBase.getMultiple(SimpleSQLiteQuery("SELECT * FROM $_name WHERE id IN (?)", arrayOf(id))); + return deserializeIndexes(dbDaoBase.getMultiple(_sqlGetAll(id))); } - fun getPageObjects(page: Int, length: Int): List = convertObjects(getPage(page, length)); + fun getObjectPage(page: Int, length: Int): List = convertObjects(getPage(page, length)); + fun getObjectPager(pageLength: Int = 20): IPager { + return AdhocPager({ + getObjectPage(it - 1, pageLength); + }); + } fun getPage(page: Int, length: Int): List { - val query = _dbDescriptor.sqlPage(_name, page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}"); + if(_sqlPage == null) + throw IllegalStateException("DB Store [${name}] does not have ordered fields to provide pages"); + val query = _sqlPage!!(page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}"); return dbDaoBase.getMultiple(query); } + fun getPager(pageLength: Int = 20): IPager { + return AdhocPager({ + getPage(it - 1, pageLength); + }); + } + fun delete(item: I) { dbDaoBase.delete(item); for(index in _indexes) index.second.remove(index.first(item)); } + fun delete(id: Long) { + dbDaoBase.action(_sqlDeleteById(id)); + for(index in _indexes) + index.second.values.removeIf { it.id == id } + } fun deleteAll() { dbDaoBase.action(_sqlDeleteAll); + _indexCollection.clear(); for(index in _indexes) index.second.clear(); } - - fun convertObject(index: ManagedDBIndex): T? { - return index.serialized?.let { - _serializer.deserialize(_class, it); - }; + fun convertObject(index: I): T? { + return index.obj ?: deserializeIndex(index).obj; } - fun convertObjects(indexes: List>): List { - return indexes.mapNotNull { convertObject(it) }; + fun convertObjects(indexes: List): List { + return indexes.mapNotNull { it.obj ?: convertObject(it) }; + } + fun deserializeIndex(index: I): I { + if(index.serialized == null) throw IllegalStateException("Cannot deserialize index-only items from [${name}]"); + val obj = _serializer.deserialize(_class, index.serialized!!); + index.obj = obj; + return index; + } + fun deserializeIndexes(indexes: List): List { + for(index in indexes) + deserializeIndex(index); + return indexes; } fun serialize(obj: T): ByteArray { @@ -158,4 +257,12 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA inline fun , D: ManagedDBDatabase, DA: ManagedDBDAOBase> create(name: String, descriptor: ManagedDBDescriptor, serializer: KSerializer? = null) = ManagedDBStore(name, descriptor, kotlin.reflect.typeOf(), JsonStoreSerializer.create(serializer)); } + + class ColumnMetadata( + val field: Field, + val info: ColumnIndex, + val ordered: ColumnOrdered? + ) { + val name get() = if(info.name == ColumnInfo.INHERIT_FIELD_NAME) field.name else info.name; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt index d6463ec2..77256688 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt @@ -15,11 +15,6 @@ class DBChannelCache { class Index: ManagedDBIndex { @PrimaryKey(true) override var id: Long? = null; - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - override var serialized: ByteArray? = null; - - @Ignore - override var obj: SerializedPlatformContent? = null; var feedType: String? = null; var channelUrl: String? = null; diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt index 2b0d3301..89cd8fbc 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt @@ -10,11 +10,14 @@ import androidx.room.Query import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.db.ColumnIndex +import com.futo.platformplayer.stores.db.ColumnOrdered import com.futo.platformplayer.stores.db.ManagedDBDAOBase import com.futo.platformplayer.stores.db.ManagedDBDatabase import com.futo.platformplayer.stores.db.ManagedDBDescriptor import com.futo.platformplayer.stores.db.ManagedDBIndex import com.futo.platformplayer.stores.db.ManagedDBStore +import kotlin.reflect.KClass import kotlin.reflect.KType class DBHistory { @@ -22,6 +25,7 @@ class DBHistory { const val TABLE_NAME = "history"; } + //These classes solely exist for bounding generics for type erasure @Dao interface DBDAO: ManagedDBDAOBase {} @Database(entities = [Index::class], version = 2) @@ -30,26 +34,25 @@ class DBHistory { } class Descriptor: ManagedDBDescriptor() { + override val table_name: String = TABLE_NAME; override fun create(obj: HistoryVideo): Index = Index(obj); - override fun dbClass(): Class = DB::class.java; - - //Optional - override fun sqlIndexOnly(tableName: String): SimpleSQLiteQuery = SimpleSQLiteQuery("SELECT id, url, position, date FROM $TABLE_NAME"); - override fun sqlPage(tableName: String, page: Int, length: Int): SimpleSQLiteQuery = SimpleSQLiteQuery("SELECT * FROM $TABLE_NAME ORDER BY date DESC, id DESC LIMIT ? OFFSET ?", arrayOf(length, page * length)); + override fun dbClass(): KClass = DB::class; + override fun indexClass(): KClass = Index::class; } @Entity(TABLE_NAME) class Index: ManagedDBIndex { @PrimaryKey(true) + @ColumnOrdered(1) + @ColumnIndex override var id: Long? = null; - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - override var serialized: ByteArray? = null; - - @Ignore - override var obj: HistoryVideo? = null; + @ColumnIndex var url: String; + @ColumnIndex var position: Long; + @ColumnIndex + @ColumnOrdered(0, true) var date: Long; constructor() { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt index 72d81241..6970d82a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt @@ -6,7 +6,11 @@ import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlaylists +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch class HistoryListAdapter : RecyclerView.Adapter { private lateinit var _filteredVideos: MutableList; @@ -18,16 +22,18 @@ class HistoryListAdapter : RecyclerView.Adapter { updateFilteredVideos(); StatePlaylists.instance.onHistoricVideoChanged.subscribe(this) { video, position -> - val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; - if (index == -1) { - return@subscribe; - } + StateApp.instance.scope.launch(Dispatchers.Main) { + val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; + if (index == -1) { + return@launch; + } - _filteredVideos[index].position = position; - if (index < _filteredVideos.size - 2) { - notifyItemRangeChanged(index, 2); - } else { - notifyItemChanged(index); + _filteredVideos[index].position = position; + if (index < _filteredVideos.size - 2) { + notifyItemRangeChanged(index, 2); + } else { + notifyItemChanged(index); + } } }; } From 869789f0e27176084bbadfef7c6a7c2bac91c96e Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 23 Nov 2023 16:03:25 +0100 Subject: [PATCH 04/12] WIP --- .../com/futo/platformplayer/states/StateApp.kt | 17 ++++++++++++----- .../platformplayer/states/StatePlaylists.kt | 6 ++++-- .../platformplayer/stores/db/ManagedDBStore.kt | 2 ++ .../views/adapters/HistoryListAdapter.kt | 2 ++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 08e02159..0b4f8d78 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -528,11 +528,13 @@ class StateApp { StatePlaylists.instance.toMigrateCheck(); StatePlaylists.instance._historyDBStore.deleteAll(); + if(StatePlaylists.instance.shouldMigrateLegacyHistory()) StatePlaylists.instance.migrateLegacyHistory(); - if(false) { + if(true) { + /* Logger.i(TAG, "TEST:--------(200)---------"); testHistoryDB(200); Logger.i(TAG, "TEST:--------(1000)---------"); @@ -543,14 +545,19 @@ class StateApp { testHistoryDB(4000); Logger.i(TAG, "TEST:--------(6000)---------"); testHistoryDB(6000); + */ + Logger.i(TAG, "TEST:--------(100000)---------"); + scope.launch(Dispatchers.Default) { + testHistoryDB(100000); + } } } fun testHistoryDB(count: Int) { Logger.i(TAG, "TEST: Starting tests"); StatePlaylists.instance._historyDBStore.deleteAll(); - val testHistoryItem = StatePlaylists.instance.getHistory().first(); - val testItemJson = StatePlaylists.instance.getHistory().first().video.toJson(); + val testHistoryItem = StatePlaylists.instance.getHistoryLegacy().first(); + val testItemJson = testHistoryItem.video.toJson(); val now = OffsetDateTime.now(); val testSet = (0..count).map { HistoryVideo(Json.decodeFromString(testItemJson.replace(testHistoryItem.video.url, UUID.randomUUID().toString())), it.toLong(), now.minusHours(it.toLong())) } @@ -562,7 +569,7 @@ class StateApp { StatePlaylists.instance._historyDBStore.insert(item); }; Logger.i(TAG, "TEST: Inserting in ${insertMS}ms"); - + /* var fetched: List? = null; val fetchMS = measureTimeMillis { fetched = StatePlaylists.instance._historyDBStore.getAll(); @@ -574,7 +581,7 @@ class StateApp { Logger.i(TAG, "TEST: Deserialized: ${deserialized.size}"); }; Logger.i(TAG, "TEST: Deserialize speed ${deserializeMS}MS"); - + */ var fetchedIndex: List? = null; val fetchIndexMS = measureTimeMillis { fetchedIndex = StatePlaylists.instance._historyDBStore.getAllIndexes(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index 43ff1bed..1dea8560 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -58,8 +58,7 @@ class StatePlaylists { val historyIndex: ConcurrentMap = ConcurrentHashMap(); val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) - .withIndex({ it.url }, historyIndex) - .withUnique({ it.url }, historyIndex) + .withIndex({ it.url }, historyIndex, true) .load(); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); @@ -158,6 +157,9 @@ class StatePlaylists { } } */ + fun getHistoryLegacy(): List { + return _historyStore.getItems(); + } fun getHistory() : List { return _historyDBStore.getAllObjects(); //return _historyStore.getItems().sortedByDescending { it.date }; diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index e2665eac..a5677b9e 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.stores.db import androidx.room.ColumnInfo import androidx.room.Room +import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.api.media.structures.AdhocPager import com.futo.platformplayer.api.media.structures.IPager @@ -241,6 +242,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA if(index.serialized == null) throw IllegalStateException("Cannot deserialize index-only items from [${name}]"); val obj = _serializer.deserialize(_class, index.serialized!!); index.obj = obj; + index.serialized = null; return index; } fun deserializeIndexes(indexes: List): List { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt index 6970d82a..5b847f61 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt @@ -4,6 +4,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateApp @@ -45,6 +46,7 @@ class HistoryListAdapter : RecyclerView.Adapter { fun updateFilteredVideos() { val videos = StatePlaylists.instance.getHistory(); + if (_query.isBlank()) { _filteredVideos = videos.toMutableList(); } else { From 662e94bceea705ca084836c98bc6b1d1e85a3447 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 24 Nov 2023 22:42:30 +0100 Subject: [PATCH 05/12] Unittests and fixes for dbstore --- .../platformplayer/ManagedDBStoreTests.kt | 159 ++++++++++++++++++ .../platformplayer/states/StatePlaylists.kt | 2 +- .../stores/db/ManagedDBDAOBase.kt | 2 + .../stores/db/ManagedDBIndex.kt | 9 +- .../stores/db/ManagedDBStore.kt | 76 ++++++--- .../stores/db/types/DBChannelCache.kt | 1 - .../stores/db/types/DBHistory.kt | 1 - .../com/futo/platformplayer/testing/DBTOs.kt | 47 ++++++ 8 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt create mode 100644 app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt diff --git a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt new file mode 100644 index 00000000..ff606e8f --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt @@ -0,0 +1,159 @@ +package com.futo.platformplayer + +import androidx.test.platform.app.InstrumentationRegistry +import com.futo.platformplayer.stores.db.ManagedDBDescriptor +import com.futo.platformplayer.stores.db.ManagedDBStore +import com.futo.platformplayer.testing.DBTOs +import org.junit.Assert +import org.junit.Test +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import kotlin.reflect.KClass + +class ManagedDBStoreTests { + val context = InstrumentationRegistry.getInstrumentation().targetContext; + + @Test + fun startup() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + store.shutdown(); + } + + @Test + fun insert() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + val testObj = DBTOs.TestObject(); + createAndAssert(store, testObj); + + store.shutdown(); + } + @Test + fun update() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + val testObj = DBTOs.TestObject(); + val obj = createAndAssert(store, testObj); + + testObj.someStr = "Testing"; + store.update(obj.id!!, testObj); + val obj2 = store.get(obj.id!!); + assertIndexEquals(obj2, testObj); + + store.shutdown(); + } + @Test + fun delete() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + val testObj = DBTOs.TestObject(); + val obj = createAndAssert(store, testObj); + store.delete(obj.id!!); + + Assert.assertEquals(store.count(), 0); + Assert.assertNull(store.getOrNull(obj.id!!)); + + store.shutdown(); + } + + @Test + fun withIndex() { + val index = ConcurrentHashMap(); + val store = ManagedDBStore.create("test", Descriptor()) + .withIndex({it.someString}, index, true) + .load(context, true); + store.deleteAll(); + + val testObj1 = DBTOs.TestObject(); + val testObj2 = DBTOs.TestObject(); + val testObj3 = DBTOs.TestObject(); + val obj1 = createAndAssert(store, testObj1); + val obj2 = createAndAssert(store, testObj2); + val obj3 = createAndAssert(store, testObj3); + Assert.assertEquals(store.count(), 3); + + Assert.assertTrue(index.containsKey(testObj1.someStr)); + Assert.assertTrue(index.containsKey(testObj2.someStr)); + Assert.assertTrue(index.containsKey(testObj3.someStr)); + Assert.assertEquals(index.size, 3); + + val oldStr = testObj1.someStr; + testObj1.someStr = UUID.randomUUID().toString(); + store.update(obj1.id!!, testObj1); + + Assert.assertEquals(index.size, 3); + Assert.assertFalse(index.containsKey(oldStr)); + Assert.assertTrue(index.containsKey(testObj1.someStr)); + Assert.assertTrue(index.containsKey(testObj2.someStr)); + Assert.assertTrue(index.containsKey(testObj3.someStr)); + + store.delete(obj2.id!!); + Assert.assertEquals(index.size, 2); + + Assert.assertFalse(index.containsKey(oldStr)); + Assert.assertTrue(index.containsKey(testObj1.someStr)); + Assert.assertFalse(index.containsKey(testObj2.someStr)); + Assert.assertTrue(index.containsKey(testObj3.someStr)); + store.shutdown(); + } + + @Test + fun withUnique() { + val index = ConcurrentHashMap(); + val store = ManagedDBStore.create("test", Descriptor()) + .withIndex({it.someString}, index, false, true) + .load(context, true); + store.deleteAll(); + + val testObj1 = DBTOs.TestObject(); + val testObj2 = DBTOs.TestObject(); + val testObj3 = DBTOs.TestObject(); + val obj1 = createAndAssert(store, testObj1); + val obj2 = createAndAssert(store, testObj2); + + testObj3.someStr = testObj2.someStr; + Assert.assertEquals(store.insert(testObj3), obj2.id!!); + Assert.assertEquals(store.count(), 2); + + store.shutdown(); + } + + + + private fun createAndAssert(store: ManagedDBStore, obj: DBTOs.TestObject): DBTOs.TestIndex { + val id = store.insert(obj); + Assert.assertTrue(id > 0); + + val dbObj = store.get(id); + assertIndexEquals(dbObj, obj); + return dbObj; + } + + private fun assertObjectEquals(obj1: DBTOs.TestObject, obj2: DBTOs.TestObject) { + Assert.assertEquals(obj1.someStr, obj2.someStr); + Assert.assertEquals(obj1.someNum, obj2.someNum); + } + private fun assertIndexEquals(obj1: DBTOs.TestIndex, obj2: DBTOs.TestObject) { + Assert.assertEquals(obj1.someString, obj2.someStr); + Assert.assertEquals(obj1.someNum, obj2.someNum); + assertObjectEquals(obj1.obj, obj2); + } + + + class Descriptor: ManagedDBDescriptor() { + override val table_name: String = "testing"; + override fun indexClass(): KClass = DBTOs.TestIndex::class; + override fun dbClass(): KClass = DBTOs.DB::class; + override fun create(obj: DBTOs.TestObject): DBTOs.TestIndex = DBTOs.TestIndex(obj); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index 1dea8560..b75e8240 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -58,7 +58,7 @@ class StatePlaylists { val historyIndex: ConcurrentMap = ConcurrentHashMap(); val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) - .withIndex({ it.url }, historyIndex, true) + .withIndex({ it.url }, historyIndex, false, true) .load(); val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt index a679bb16..05c19390 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt @@ -15,6 +15,8 @@ interface ManagedDBDAOBase> { @RawQuery fun get(query: SupportSQLiteQuery): I; @RawQuery + fun getNullable(query: SupportSQLiteQuery): I?; + @RawQuery fun getMultiple(query: SupportSQLiteQuery): List; @RawQuery diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt index b111ff59..93562aca 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt @@ -13,5 +13,12 @@ open class ManagedDBIndex { var serialized: ByteArray? = null; @Ignore - var obj: T? = null; + private var _obj: T? = null; + + @get:Ignore + val obj: T get() = _obj ?: throw IllegalStateException("Attempted to access serialized object on a index-only instance"); + + fun setInstance(obj: T) { + this._obj = obj; + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index a5677b9e..4eacb6a4 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -1,15 +1,13 @@ package com.futo.platformplayer.stores.db +import android.content.Context import androidx.room.ColumnInfo import androidx.room.Room -import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.api.media.structures.AdhocPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.assume -import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer import kotlinx.serialization.KSerializer @@ -37,6 +35,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA private val _columnInfo: List; private val _sqlGet: (Long)-> SimpleSQLiteQuery; + private val _sqlGetIndex: (Long)-> SimpleSQLiteQuery; private val _sqlGetAll: (LongArray)-> SimpleSQLiteQuery; private val _sqlAll: SimpleSQLiteQuery; private val _sqlCount: SimpleSQLiteQuery; @@ -49,7 +48,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA val name: String; - private val _indexes: ArrayListAny, ConcurrentMap>> = arrayListOf(); + private val _indexes: ArrayList> = arrayListOf(); private val _indexCollection = ConcurrentHashMap(); private var _withUnique: Pair<(I)->Any, ConcurrentMap>? = null; @@ -76,6 +75,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA else ""; _sqlGet = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id = ?", arrayOf(it)) }; + _sqlGetIndex = { SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${_dbDescriptor.table_name} WHERE id = ?", arrayOf(it)) }; _sqlGetAll = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id IN (?)", arrayOf(it)) }; _sqlAll = SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL}"); _sqlCount = SimpleSQLiteQuery("SELECT COUNT(id) FROM ${_dbDescriptor.table_name}"); @@ -90,10 +90,10 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA } } - fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap, withUnique: Boolean = false): ManagedDBStore { + fun withIndex(keySelector: (I)->Any, indexContainer: ConcurrentMap, allowChange: Boolean = false, withUnique: Boolean = false): ManagedDBStore { if(_sqlIndexed == null) throw IllegalStateException("Can only create indexes if sqlIndexOnly is implemented"); - _indexes.add(Pair(keySelector, indexContainer)); + _indexes.add(IndexDescriptor(keySelector, indexContainer, allowChange)); if(withUnique) withUnique(keySelector, indexContainer); @@ -108,8 +108,11 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return this; } - fun load(): ManagedDBStore { - _db = Room.databaseBuilder(StateApp.instance.context, _dbDescriptor.dbClass().java, _name) + fun load(context: Context? = null, inMemory: Boolean = false): ManagedDBStore { + _db = (if(!inMemory) + Room.databaseBuilder(context ?: StateApp.instance.context, _dbDescriptor.dbClass().java, _name) + else + Room.inMemoryDatabaseBuilder(context ?: StateApp.instance.context, _dbDescriptor.dbClass().java)) .fallbackToDestructiveMigration() .allowMainThreadQueries() .build() @@ -117,11 +120,17 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA if(_indexes.any()) { val allItems = _dbDaoBase!!.getMultiple(_sqlIndexed!!); for(index in _indexes) - index.second.putAll(allItems.associateBy(index.first)); + index.collection.putAll(allItems.associateBy(index.keySelector)); } return this; } + fun shutdown() { + val db = _db; + _db = null; + _dbDaoBase = null; + db?.close(); + } fun getUnique(obj: I): I? { if(_withUnique == null) @@ -142,9 +151,12 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun insert(obj: T): Long { val newIndex = _dbDescriptor.create(obj); - val unique = getUnique(newIndex); - if(unique != null) - return unique.id!!; + + if(_withUnique != null) { + val unique = getUnique(newIndex); + if (unique != null) + return unique.id!!; + } newIndex.serialized = serialize(obj); newIndex.id = dbDaoBase.insert(newIndex); @@ -153,13 +165,15 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA if(!_indexes.isEmpty()) { for (index in _indexes) { - val key = index.first(newIndex); - index.second.put(key, newIndex); + val key = index.keySelector(newIndex); + index.collection.put(key, newIndex); } } return newIndex.id!!; } fun update(id: Long, obj: T) { + val existing = if(_indexes.any { it.checkChange }) _dbDaoBase!!.getNullable(_sqlGetIndex(id)) else null + val newIndex = _dbDescriptor.create(obj); newIndex.id = id; newIndex.serialized = serialize(obj); @@ -168,8 +182,13 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA if(!_indexes.isEmpty()) { for (index in _indexes) { - val key = index.first(newIndex); - index.second.put(key, newIndex); + val key = index.keySelector(newIndex); + if(index.checkChange && existing != null) { + val keyExisting = index.keySelector(existing); + if(keyExisting != key) + index.collection.remove(keyExisting); + } + index.collection.put(key, newIndex); } } } @@ -189,6 +208,15 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun get(id: Long): I { return deserializeIndex(dbDaoBase.get(_sqlGet(id))); } + fun getOrNull(id: Long): I? { + val result = dbDaoBase.getNullable(_sqlGet(id)); + if(result == null) + return null; + return deserializeIndex(result); + } + fun getIndexOnlyOrNull(id: Long): I? { + return dbDaoBase.get(_sqlGetIndex(id)); + } fun getAllObjects(vararg id: Long): List = getAll(*id).map { it.obj!! }; fun getAll(vararg id: Long): List { @@ -217,19 +245,20 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA dbDaoBase.delete(item); for(index in _indexes) - index.second.remove(index.first(item)); + index.collection.remove(index.keySelector(item)); } fun delete(id: Long) { dbDaoBase.action(_sqlDeleteById(id)); + for(index in _indexes) - index.second.values.removeIf { it.id == id } + index.collection.values.removeIf { it.id == id } } fun deleteAll() { dbDaoBase.action(_sqlDeleteAll); _indexCollection.clear(); for(index in _indexes) - index.second.clear(); + index.collection.clear(); } fun convertObject(index: I): T? { @@ -241,7 +270,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun deserializeIndex(index: I): I { if(index.serialized == null) throw IllegalStateException("Cannot deserialize index-only items from [${name}]"); val obj = _serializer.deserialize(_class, index.serialized!!); - index.obj = obj; + index.setInstance(obj); index.serialized = null; return index; } @@ -260,6 +289,13 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA = ManagedDBStore(name, descriptor, kotlin.reflect.typeOf(), JsonStoreSerializer.create(serializer)); } + //Pair<(I)->Any, ConcurrentMap> + class IndexDescriptor( + val keySelector: (I) -> Any, + val collection: ConcurrentMap, + val checkChange: Boolean + ) + class ColumnMetadata( val field: Field, val info: ColumnIndex, diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt index 77256688..8007f393 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt @@ -24,7 +24,6 @@ class DBChannelCache { constructor(sCache: SerializedPlatformContent) { id = null; serialized = null; - obj = sCache; channelUrl = sCache.author.url; } } diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt index 89cd8fbc..efc91eac 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt @@ -66,7 +66,6 @@ class DBHistory { url = historyVideo.video.url; position = historyVideo.position; date = historyVideo.date.toEpochSecond(); - obj = historyVideo; } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt b/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt new file mode 100644 index 00000000..0af725db --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt @@ -0,0 +1,47 @@ +package com.futo.platformplayer.testing + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import com.futo.platformplayer.stores.db.ColumnIndex +import com.futo.platformplayer.stores.db.ColumnOrdered +import com.futo.platformplayer.stores.db.ManagedDBDAOBase +import com.futo.platformplayer.stores.db.ManagedDBDatabase +import com.futo.platformplayer.stores.db.ManagedDBIndex +import kotlinx.serialization.Serializable +import java.util.Random +import java.util.UUID + +class DBTOs { + @Dao + interface DBDAO: ManagedDBDAOBase {} + @Database(entities = [TestIndex::class], version = 2) + abstract class DB: ManagedDBDatabase() { + abstract override fun base(): DBDAO; + } + + + @Entity("testing") + class TestIndex(): ManagedDBIndex() { + + @ColumnIndex + var someString: String = ""; + @ColumnIndex + @ColumnOrdered(0) + var someNum: Int = 0; + + constructor(obj: TestObject, customInt: Int? = null) : this() { + someString = obj.someStr; + someNum = customInt ?: obj.someNum; + } + } + @Serializable + class TestObject { + var someStr = UUID.randomUUID().toString(); + var someNum = random.nextInt(); + } + + companion object { + val random = Random(); + } +} \ No newline at end of file From c49b9f7841ca718400efe1bbfd1054a708b65ba6 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 27 Nov 2023 17:38:55 +0100 Subject: [PATCH 06/12] DBStore query support and tests --- .../platformplayer/ManagedDBStoreTests.kt | 120 ++++++++++++++++++ .../futo/platformplayer/states/StateApp.kt | 2 +- .../stores/db/ManagedDBStore.kt | 83 +++++++++--- 3 files changed, 183 insertions(+), 22 deletions(-) diff --git a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt index ff606e8f..5f30353c 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt @@ -127,8 +127,123 @@ class ManagedDBStoreTests { store.shutdown(); } + @Test + fun getPage() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + val testObjs = createSequence(store, 25); + + val page1 = store.getPage(0, 10); + val page2 = store.getPage(1, 10); + val page3 = store.getPage(2, 10); + Assert.assertEquals(10, page1.size); + Assert.assertEquals(10, page2.size); + Assert.assertEquals(5, page3.size); + + store.shutdown(); + } + @Test + fun query() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + val testStr = UUID.randomUUID().toString(); + + val testObj1 = DBTOs.TestObject(); + val testObj2 = DBTOs.TestObject(); + val testObj3 = DBTOs.TestObject(); + val testObj4 = DBTOs.TestObject(); + testObj3.someStr = testStr; + testObj4.someStr = testStr; + val obj1 = createAndAssert(store, testObj1); + val obj2 = createAndAssert(store, testObj2); + val obj3 = createAndAssert(store, testObj3); + val obj4 = createAndAssert(store, testObj4); + + val results = store.query(DBTOs.TestIndex::someString, testStr); + + Assert.assertEquals(2, results.size); + for(result in results) { + if(result.someNum == obj3.someNum) + assertIndexEquals(obj3, result); + else + assertIndexEquals(obj4, result); + } + store.shutdown(); + } + @Test + fun queryPage() { + val index = ConcurrentHashMap(); + val store = ManagedDBStore.create("test", Descriptor()) + .withIndex({ it.someNum }, index) + .load(context, true); + store.deleteAll(); + + val testStr = UUID.randomUUID().toString(); + + val testResults = createSequence(store, 40, { i, testObject -> + if(i % 2 == 0) + testObject.someStr = testStr; + }); + val page1 = store.queryPage(DBTOs.TestIndex::someString, testStr, 0,10); + val page2 = store.queryPage(DBTOs.TestIndex::someString, testStr, 1,10); + val page3 = store.queryPage(DBTOs.TestIndex::someString, testStr, 2,10); + + Assert.assertEquals(10, page1.size); + Assert.assertEquals(10, page2.size); + Assert.assertEquals(0, page3.size); + + + store.shutdown(); + } + @Test + fun queryPager() { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + + val testStr = UUID.randomUUID().toString(); + + val testResults = createSequence(store, 100, { i, testObject -> + if(i % 2 == 0) + testObject.someStr = testStr; + }); + val pager = store.queryPager(DBTOs.TestIndex::someString, testStr, 10); + + val items = pager.getResults().toMutableList(); + while(pager.hasMorePages()) { + pager.nextPage(); + items.addAll(pager.getResults()); + } + Assert.assertEquals(50, items.size); + for(i in 0 until 50) { + val k = i * 2; + Assert.assertEquals(k, items[i].someNum); + } + + store.shutdown(); + } + + + + + + + private fun createSequence(store: ManagedDBStore, count: Int, modifier: ((Int, DBTOs.TestObject)->Unit)? = null): List { + val list = mutableListOf(); + for(i in 0 until count) { + val obj = DBTOs.TestObject(); + obj.someNum = i; + modifier?.invoke(i, obj); + list.add(createAndAssert(store, obj)); + } + return list; + } private fun createAndAssert(store: ManagedDBStore, obj: DBTOs.TestObject): DBTOs.TestIndex { val id = store.insert(obj); @@ -148,6 +263,11 @@ class ManagedDBStoreTests { Assert.assertEquals(obj1.someNum, obj2.someNum); assertObjectEquals(obj1.obj, obj2); } + private fun assertIndexEquals(obj1: DBTOs.TestIndex, obj2: DBTOs.TestIndex) { + Assert.assertEquals(obj1.someString, obj2.someString); + Assert.assertEquals(obj1.someNum, obj2.someNum); + assertIndexEquals(obj1, obj2.obj); + } class Descriptor: ManagedDBDescriptor() { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 0b4f8d78..0fde6ff8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -533,7 +533,7 @@ class StateApp { StatePlaylists.instance.migrateLegacyHistory(); - if(true) { + if(false) { /* Logger.i(TAG, "TEST:--------(200)---------"); testHistoryDB(200); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 4eacb6a4..30076fd3 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -15,6 +15,7 @@ import java.lang.reflect.Field import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import kotlin.reflect.KClass +import kotlin.reflect.KProperty import kotlin.reflect.KType import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation @@ -30,7 +31,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA private var _dbDaoBase: ManagedDBDAOBase? = null; val dbDaoBase: ManagedDBDAOBase get() = _dbDaoBase ?: throw IllegalStateException("Not initialized db [${name}]"); - private var _dbDescriptor: ManagedDBDescriptor; + val descriptor: ManagedDBDescriptor; private val _columnInfo: List; @@ -52,9 +53,10 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA private val _indexCollection = ConcurrentHashMap(); private var _withUnique: Pair<(I)->Any, ConcurrentMap>? = null; + private val _orderSQL: String?; constructor(name: String, descriptor: ManagedDBDescriptor, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { - _dbDescriptor = descriptor; + this.descriptor = descriptor; _name = name; this.name = niceName ?: name.let { if(it.isNotEmpty()) @@ -63,29 +65,29 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }; _serializer = serializer; _class = clazz; - _columnInfo = _dbDescriptor.indexClass().memberProperties + _columnInfo = this.descriptor.indexClass().memberProperties .filter { it.hasAnnotation() && it.name != "serialized" } .map { ColumnMetadata(it.javaField!!, it.findAnnotation()!!, it.findAnnotation()) }; val indexColumnNames = _columnInfo.map { it.name }; val orderedColumns = _columnInfo.filter { it.ordered != null }.sortedBy { it.ordered!!.priority }; - val orderSQL = if(orderedColumns.size > 0) + _orderSQL = if(orderedColumns.size > 0) " ORDER BY " + orderedColumns.map { "${it.name} ${if(it.ordered!!.descending) "DESC" else "ASC"}" }.joinToString(", "); else ""; - _sqlGet = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id = ?", arrayOf(it)) }; - _sqlGetIndex = { SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${_dbDescriptor.table_name} WHERE id = ?", arrayOf(it)) }; - _sqlGetAll = { SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} WHERE id IN (?)", arrayOf(it)) }; - _sqlAll = SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL}"); - _sqlCount = SimpleSQLiteQuery("SELECT COUNT(id) FROM ${_dbDescriptor.table_name}"); - _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${_dbDescriptor.table_name}"); - _sqlDeleteById = { id -> SimpleSQLiteQuery("DELETE FROM ${_dbDescriptor.table_name} WHERE id = :id", arrayOf(id)) }; - _sqlIndexed = SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${_dbDescriptor.table_name}"); + _sqlGet = { SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} WHERE id = ?", arrayOf(it)) }; + _sqlGetIndex = { SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${this.descriptor.table_name} WHERE id = ?", arrayOf(it)) }; + _sqlGetAll = { SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} WHERE id IN (?)", arrayOf(it)) }; + _sqlAll = SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} ${_orderSQL}"); + _sqlCount = SimpleSQLiteQuery("SELECT COUNT(id) FROM ${this.descriptor.table_name}"); + _sqlDeleteAll = SimpleSQLiteQuery("DELETE FROM ${this.descriptor.table_name}"); + _sqlDeleteById = { id -> SimpleSQLiteQuery("DELETE FROM ${this.descriptor.table_name} WHERE id = :id", arrayOf(id)) }; + _sqlIndexed = SimpleSQLiteQuery("SELECT ${indexColumnNames.joinToString(", ")} FROM ${this.descriptor.table_name}"); if(orderedColumns.size > 0) { _sqlPage = { page, length -> - SimpleSQLiteQuery("SELECT * FROM ${_dbDescriptor.table_name} ${orderSQL} LIMIT ? OFFSET ?", arrayOf(length, page * length)); + SimpleSQLiteQuery("SELECT * FROM ${this.descriptor.table_name} ${_orderSQL} LIMIT ? OFFSET ?", arrayOf(length, page * length)); } } } @@ -110,9 +112,9 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun load(context: Context? = null, inMemory: Boolean = false): ManagedDBStore { _db = (if(!inMemory) - Room.databaseBuilder(context ?: StateApp.instance.context, _dbDescriptor.dbClass().java, _name) + Room.databaseBuilder(context ?: StateApp.instance.context, descriptor.dbClass().java, _name) else - Room.inMemoryDatabaseBuilder(context ?: StateApp.instance.context, _dbDescriptor.dbClass().java)) + Room.inMemoryDatabaseBuilder(context ?: StateApp.instance.context, descriptor.dbClass().java)) .fallbackToDestructiveMigration() .allowMainThreadQueries() .build() @@ -150,7 +152,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA } fun insert(obj: T): Long { - val newIndex = _dbDescriptor.create(obj); + val newIndex = descriptor.create(obj); if(_withUnique != null) { val unique = getUnique(newIndex); @@ -174,7 +176,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun update(id: Long, obj: T) { val existing = if(_indexes.any { it.checkChange }) _dbDaoBase!!.getNullable(_sqlGetIndex(id)) else null - val newIndex = _dbDescriptor.create(obj); + val newIndex = descriptor.create(obj); newIndex.id = id; newIndex.serialized = serialize(obj); dbDaoBase.update(newIndex); @@ -223,23 +225,54 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return deserializeIndexes(dbDaoBase.getMultiple(_sqlGetAll(id))); } - fun getObjectPage(page: Int, length: Int): List = convertObjects(getPage(page, length)); - fun getObjectPager(pageLength: Int = 20): IPager { + fun query(field: String, obj: Any): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(obj)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + fun query(field: KProperty<*>, obj: Any): List = query(validateFieldName(field), obj); + fun queryPage(field: String, obj: Any, page: Int, pageSize: Int): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ? ${_orderSQL} LIMIT ? OFFSET ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + fun queryPage(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List = queryPage(validateFieldName(field), obj, page, pageSize); + + fun queryPageObjects(field: String, obj: Any, page: Int, pageSize: Int): List = convertObjects(queryPage(field, obj, page, pageSize)); + fun queryPageObjects(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List = queryPageObjects(validateFieldName(field), obj, page, pageSize); + + fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager = queryPager(validateFieldName(field), obj, pageSize); + fun queryPager(field: String, obj: Any, pageSize: Int): IPager { return AdhocPager({ - getObjectPage(it - 1, pageLength); + queryPage(field, obj, it - 1, pageSize); }); } + + fun queryObjectPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager = queryObjectPager(validateFieldName(field), obj, pageSize); + fun queryObjectPager(field: String, obj: Any, pageSize: Int): IPager { + return AdhocPager({ + queryPageObjects(field, obj, it - 1, pageSize); + }); + } + fun getPage(page: Int, length: Int): List { if(_sqlPage == null) throw IllegalStateException("DB Store [${name}] does not have ordered fields to provide pages"); val query = _sqlPage!!(page, length) ?: throw IllegalStateException("Paged db not setup for ${_name}"); - return dbDaoBase.getMultiple(query); + return deserializeIndexes(dbDaoBase.getMultiple(query)); } + fun getPageObjects(page: Int, length: Int): List = convertObjects(getPage(page, length)); + fun getPager(pageLength: Int = 20): IPager { return AdhocPager({ getPage(it - 1, pageLength); }); } + fun getObjectPager(pageLength: Int = 20): IPager { + return AdhocPager({ + getPageObjects(it - 1, pageLength); + }); + } fun delete(item: I) { dbDaoBase.delete(item); @@ -284,6 +317,14 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return _serializer.serialize(_class, obj); } + + private fun validateFieldName(prop: KProperty<*>): String { + val declaringClass = prop.javaField?.declaringClass; + if(declaringClass != descriptor.indexClass().java) + throw IllegalStateException("Cannot query by property [${prop.name}] from ${declaringClass?.simpleName} not part of ${descriptor.indexClass().simpleName}"); + return prop.name; + } + companion object { inline fun , D: ManagedDBDatabase, DA: ManagedDBDAOBase> create(name: String, descriptor: ManagedDBDescriptor, serializer: KSerializer? = null) = ManagedDBStore(name, descriptor, kotlin.reflect.typeOf(), JsonStoreSerializer.create(serializer)); From aeb29c54cd9e931774bbfe36c9522d6966358f04 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 30 Nov 2023 00:12:46 +0100 Subject: [PATCH 07/12] WIP Channel content cache --- .../futo/platformplayer/states/StateCache.kt | 106 ++++++++++++++++++ .../stores/db/ManagedDBStore.kt | 28 ++++- .../stores/db/types/DBChannelCache.kt | 39 ++++++- 3 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/states/StateCache.kt diff --git a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt new file mode 100644 index 00000000..699f8b10 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -0,0 +1,106 @@ +package com.futo.platformplayer.states + +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.structures.DedupContentPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.MultiChronoContentPager +import com.futo.platformplayer.api.media.structures.PlatformContentPager +import com.futo.platformplayer.cache.ChannelContentCache +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.serializers.PlatformContentSerializer +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.db.ManagedDBStore +import com.futo.platformplayer.stores.db.types.DBChannelCache +import com.futo.platformplayer.stores.db.types.DBHistory +import com.futo.platformplayer.toSafeFileName +import java.time.OffsetDateTime + +class StateCache { + private val _channelCache = ManagedDBStore.create("channelCache", DBChannelCache.Descriptor(), PlatformContentSerializer()) + .load(); + + fun clear() { + _channelCache.deleteAll(); + } + fun clearToday() { + val today = _channelCache.queryGreater(DBChannelCache.Index::datetime, OffsetDateTime.now().toEpochSecond()); + for(content in today) + _channelCache.delete(content); + } + + fun getChannelCachePager(channelUrl: String): IPager { + return _channelCache.queryPager(DBChannelCache.Index::channelUrl, channelUrl, 20) { + it.obj; + } + } + fun getSubscriptionCachePager(): DedupContentPager { + Logger.i(TAG, "Subscriptions CachePager get subscriptions"); + val subs = StateSubscriptions.instance.getSubscriptions(); + Logger.i(TAG, "Subscriptions CachePager polycentric urls"); + val allUrls = subs.map { + val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf(); + if(!otherUrls.contains(it.channel.url)) + return@map listOf(listOf(it.channel.url), otherUrls).flatten(); + else + return@map otherUrls; + }.flatten().distinct(); + Logger.i(TAG, "Subscriptions CachePager compiling"); + + val pagers = MultiChronoContentPager(allUrls.map { getChannelCachePager(it) }, false, 20); + return DedupContentPager(pagers, StatePlatform.instance.getEnabledClients().map { it.id }); + } + + + fun getCachedContent(url: String): DBChannelCache.Index? { + return _channelCache.query(DBChannelCache.Index::url, url).firstOrNull(); + } + + fun uncacheContent(content: SerializedPlatformContent) { + val item = getCachedContent(content.url); + if(item != null) + _channelCache.delete(item); + } + fun cacheContents(contents: List): List { + return contents.filter { cacheContent(it) }; + } + fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { + if(content.author.url.isEmpty()) + return false; + + val serialized = SerializedPlatformContent.fromContent(content); + val existing = getCachedContent(content.url); + + if(existing != null && doUpdate) { + _channelCache.update(existing.id!!, serialized); + return true; + } + else if(existing == null) { + _channelCache.insert(serialized); + return true; + } + + return false; + } + + + companion object { + private val TAG = "StateCache"; + + private var _instance : StateCache? = null; + val instance : StateCache + get(){ + if(_instance == null) + _instance = StateCache(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 30076fd3..50eac84b 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -225,12 +225,32 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return deserializeIndexes(dbDaoBase.getMultiple(_sqlGetAll(id))); } + fun query(field: KProperty<*>, obj: Any): List = query(validateFieldName(field), obj); fun query(field: String, obj: Any): List { val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ?"; val query = SimpleSQLiteQuery(queryStr, arrayOf(obj)); return deserializeIndexes(dbDaoBase.getMultiple(query)); } - fun query(field: KProperty<*>, obj: Any): List = query(validateFieldName(field), obj); + fun queryGreater(field: KProperty<*>, obj: Any): List = queryGreater(validateFieldName(field), obj); + fun queryGreater(field: String, obj: Any): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} > ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(obj)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + fun querySmaller(field: KProperty<*>, obj: Any): List = querySmaller(validateFieldName(field), obj); + fun querySmaller(field: String, obj: Any): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} < ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(obj)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + fun queryBetween(field: KProperty<*>, greaterThan: Any, smallerThan: Any): List = queryBetween(validateFieldName(field), greaterThan, smallerThan); + fun queryBetween(field: String, greaterThan: Any, smallerThan: Any): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} > ? AND ${field} < ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(greaterThan, smallerThan)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + + fun queryPage(field: String, obj: Any, page: Int, pageSize: Int): List { val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ? ${_orderSQL} LIMIT ? OFFSET ?"; val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize)); @@ -247,6 +267,12 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA queryPage(field, obj, it - 1, pageSize); }); } + fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int, convert: (I)->X): IPager = queryPager(validateFieldName(field), obj, pageSize, convert); + fun queryPager(field: String, obj: Any, pageSize: Int, convert: (I)->X): IPager { + return AdhocPager({ + queryPage(field, obj, it - 1, pageSize).map(convert); + }); + } fun queryObjectPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager = queryObjectPager(validateFieldName(field), obj, pageSize); fun queryObjectPager(field: String, obj: Any, pageSize: Int): IPager { diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt index 8007f393..9f2af268 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt @@ -1,30 +1,65 @@ package com.futo.platformplayer.stores.db.types import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Database import androidx.room.Ignore import androidx.room.PrimaryKey import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.db.ColumnIndex +import com.futo.platformplayer.stores.db.ColumnOrdered +import com.futo.platformplayer.stores.db.ManagedDBDAOBase +import com.futo.platformplayer.stores.db.ManagedDBDatabase +import com.futo.platformplayer.stores.db.ManagedDBDescriptor import com.futo.platformplayer.stores.db.ManagedDBIndex +import java.time.OffsetDateTime +import kotlin.reflect.KClass class DBChannelCache { companion object { - const val TABLE_NAME = "channelCache"; + const val TABLE_NAME = "feed_cache"; + } + + + //These classes solely exist for bounding generics for type erasure + @Dao + interface DBDAO: ManagedDBDAOBase {} + @Database(entities = [Index::class], version = 2) + abstract class DB: ManagedDBDatabase() { + abstract override fun base(): DBDAO; + } + + class Descriptor: ManagedDBDescriptor() { + override val table_name: String = TABLE_NAME; + override fun create(obj: SerializedPlatformContent): Index = Index(obj); + override fun dbClass(): KClass = DB::class; + override fun indexClass(): KClass = Index::class; } class Index: ManagedDBIndex { + @ColumnIndex @PrimaryKey(true) + @ColumnOrdered(1) override var id: Long? = null; - var feedType: String? = null; + @ColumnIndex + var url: String? = null; + @ColumnIndex var channelUrl: String? = null; + @ColumnIndex + @ColumnOrdered(0) + var datetime: Long? = null; + constructor() {} constructor(sCache: SerializedPlatformContent) { id = null; serialized = null; + url = sCache.url; channelUrl = sCache.author.url; + datetime = sCache.datetime?.toEpochSecond(); } } } \ No newline at end of file From c5541b1747e34db94137b64e59548bd5e66d7a24 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 30 Nov 2023 20:58:37 +0100 Subject: [PATCH 08/12] Working DBCache, test plugin --- .../java/com/futo/platformplayer/Settings.kt | 3 +- .../com/futo/platformplayer/SettingsDev.kt | 74 +++++- .../cache/ChannelContentCache.kt | 213 ------------------ .../channel/tab/ChannelContentsFragment.kt | 4 +- .../fragment/mainactivity/main/FeedView.kt | 7 + .../main/SubscriptionsFeedFragment.kt | 24 +- .../futo/platformplayer/states/StateApp.kt | 3 +- .../futo/platformplayer/states/StateCache.kt | 81 ++++++- .../states/StateSubscriptions.kt | 1 - .../stores/db/ManagedDBStore.kt | 2 + .../stores/db/types/DBChannelCache.kt | 11 +- .../CachedSubscriptionAlgorithm.kt | 11 +- .../SimpleSubscriptionAlgorithm.kt | 6 +- .../SubscriptionsTaskFetchAlgorithm.kt | 8 +- app/src/main/res/values/strings.xml | 3 + .../assets/sources/test/TestConfig.json | 24 ++ .../assets/sources/test/TestScript.js | 45 ++++ .../unstable/assets/sources/test/odysee.png | Bin 0 -> 47198 bytes 18 files changed, 266 insertions(+), 254 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt create mode 100644 app/src/unstable/assets/sources/test/TestConfig.json create mode 100644 app/src/unstable/assets/sources/test/TestScript.js create mode 100644 app/src/unstable/assets/sources/test/odysee.png diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 8daa4736..541c2844 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -8,7 +8,6 @@ import android.webkit.CookieManager import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.activities.* import com.futo.platformplayer.api.http.ManagedHttpClient -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.logging.Logger @@ -276,7 +275,7 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14) fun clearChannelCache() { UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing.."); - ChannelContentCache.instance.clear(); + StateCache.instance.clear(); UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing"); } } diff --git a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt index 8be80aa9..111911f1 100644 --- a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt +++ b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer import android.content.Context import android.webkit.CookieManager +import androidx.lifecycle.lifecycleScope import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy @@ -20,12 +21,12 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.background.BackgroundWorker -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateSubscriptions @@ -82,26 +83,74 @@ class SettingsDev : FragmentedStorageFileJson() { var backgroundSubscriptionFetching: Boolean = false; } + + @FormField(R.string.cache, FieldForm.GROUP, -1, 3) + val cache: Cache = Cache(); + @Serializable + class Cache { + + @FormField(R.string.subscriptions_cache_5000, FieldForm.BUTTON, -1, 1) + fun subscriptionsCache5000() { + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + val subsCache = + StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(cacheScope = this)?.first; + + var total = 0; + var page = 0; + var lastToast = System.currentTimeMillis(); + while(subsCache!!.hasMorePages() && total < 5000) { + subsCache!!.nextPage(); + total += subsCache!!.getResults().size; + page++; + + if(page % 10 == 0) + withContext(Dispatchers.Main) { + val diff = System.currentTimeMillis() - lastToast; + lastToast = System.currentTimeMillis(); + UIDialogs.toast( + SettingsActivity.getActivity()!!, + "Page: ${page}, Total: ${total}, Speed: ${diff}ms" + ); + } + Thread.sleep(250); + } + + withContext(Dispatchers.Main) { + UIDialogs.toast( + SettingsActivity.getActivity()!!, + "FINISHED Page: ${page}, Total: ${total}" + ); + } + } + catch(ex: Throwable) { + Logger.e("SettingsDev", ex.message, ex); + Logger.i("SettingsDev", "Failed: ${ex.message}"); + } + } + } + } + @FormField(R.string.crash_me, FieldForm.BUTTON, - R.string.crashes_the_application_on_purpose, 2) + R.string.crashes_the_application_on_purpose, 3) fun crashMe() { throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!"); } @FormField(R.string.delete_announcements, FieldForm.BUTTON, - R.string.delete_all_announcements, 2) + R.string.delete_all_announcements, 3) fun deleteAnnouncements() { StateAnnouncement.instance.deleteAllAnnouncements(); } @FormField(R.string.clear_cookies, FieldForm.BUTTON, - R.string.clear_all_cookies_from_the_cookieManager, 2) + R.string.clear_all_cookies_from_the_cookieManager, 3) fun clearCookies() { val cookieManager: CookieManager = CookieManager.getInstance() cookieManager.removeAllCookies(null); } @FormField(R.string.test_background_worker, FieldForm.BUTTON, - R.string.test_background_worker_description, 3) + R.string.test_background_worker_description, 4) fun triggerBackgroundUpdate() { val act = SettingsActivity.getActivity()!!; UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker"); @@ -113,10 +162,10 @@ class SettingsDev : FragmentedStorageFileJson() { wm.enqueue(req); } @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, - R.string.test_background_worker_description, 3) + R.string.test_background_worker_description, 4) fun clearChannelContentCache() { UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache"); - ChannelContentCache.instance.clearToday(); + StateCache.instance.clearToday(); UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared"); } @@ -363,6 +412,17 @@ class SettingsDev : FragmentedStorageFileJson() { } } + + @Contextual + @Transient + @FormField(R.string.info, FieldForm.GROUP, -1, 19) + var info = Info(); + @Serializable + class Info { + @FormField(R.string.dev_info_channel_cache_size, FieldForm.READONLYTEXT, -1, 1, "channelCacheSize") + var channelCacheStartupCount = StateCache.instance.channelCacheStartupCount; + } + //region BOILERPLATE override fun encode(): String { return Json.encodeToString(this); diff --git a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt deleted file mode 100644 index 87614cc0..00000000 --- a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.futo.platformplayer.cache - -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent -import com.futo.platformplayer.api.media.structures.DedupContentPager -import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.api.media.structures.PlatformContentPager -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache -import com.futo.platformplayer.resolveChannelUrl -import com.futo.platformplayer.serializers.PlatformContentSerializer -import com.futo.platformplayer.states.StatePlatform -import com.futo.platformplayer.states.StateSubscriptions -import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.stores.v2.ManagedStore -import com.futo.platformplayer.toSafeFileName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.time.OffsetDateTime -import kotlin.streams.toList -import kotlin.system.measureTimeMillis - -class ChannelContentCache { - private val _targetCacheSize = 3000; - val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache"); - val _channelContents: HashMap>; - init { - val allFiles = _channelCacheDir.listFiles() ?: arrayOf(); - val initializeTime = measureTimeMillis { - _channelContents = HashMap(allFiles - .filter { it.isDirectory } - .parallelStream().map { - Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer()) - .withoutBackup() - .load()) - }.toList().associate { it }) - } - val minDays = OffsetDateTime.now().minusDays(10); - val totalItems = _channelContents.map { it.value.count() }.sum(); - val toTrim = totalItems - _targetCacheSize; - val trimmed: Int; - if(toTrim > 0) { - val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) } - .sortedBy { it.datetime!! }.take(toTrim); - for(content in redundantContent) - uncacheContent(content); - trimmed = redundantContent.size; - } - else trimmed = 0; - Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}"); - } - - fun clear() { - synchronized(_channelContents) { - for(channel in _channelContents) - for(content in channel.value.getItems()) - uncacheContent(content); - } - } - fun clearToday() { - val yesterday = OffsetDateTime.now().minusDays(1); - synchronized(_channelContents) { - for(channel in _channelContents) - for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true }) - uncacheContent(content); - } - } - - fun getChannelCachePager(channelUrl: String): PlatformContentPager { - val validID = channelUrl.toSafeFileName(); - - val validStores = _channelContents - .filter { it.key == validID } - .map { it.value }; - - val items = validStores.flatMap { it.getItems() } - .sortedByDescending { it.datetime }; - return PlatformContentPager(items, Math.min(150, items.size)); - } - fun getSubscriptionCachePager(): DedupContentPager { - Logger.i(TAG, "Subscriptions CachePager get subscriptions"); - val subs = StateSubscriptions.instance.getSubscriptions(); - Logger.i(TAG, "Subscriptions CachePager polycentric urls"); - val allUrls = subs.map { - val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf(); - if(!otherUrls.contains(it.channel.url)) - return@map listOf(listOf(it.channel.url), otherUrls).flatten(); - else - return@map otherUrls; - }.flatten().distinct(); - Logger.i(TAG, "Subscriptions CachePager compiling"); - val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet(); - - val validStores = _channelContents - .filter { validSubIds.contains(it.key) } - .map { it.value }; - - val items = validStores.flatMap { it.getItems() } - .sortedByDescending { it.datetime }; - - return DedupContentPager(PlatformContentPager(items, Math.min(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }); - } - - fun uncacheContent(content: SerializedPlatformContent) { - val store = getContentStore(content); - store?.delete(content); - } - fun cacheContents(contents: List): List { - return contents.filter { cacheContent(it) }; - } - fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { - if(content.author.url.isEmpty()) - return false; - - val channelId = content.author.url.toSafeFileName(); - val store = getContentStore(channelId).let { - if(it == null) { - Logger.i(TAG, "New Channel Cache for channel ${content.author.name}"); - val store = FragmentedStorage.storeJson(_channelCacheDir, channelId, PlatformContentSerializer()).load(); - _channelContents.put(channelId, store); - return@let store; - } - else return@let it; - } - val serialized = SerializedPlatformContent.fromContent(content); - val existing = store.findItems { it.url == content.url }; - - if(existing.isEmpty() || doUpdate) { - if(existing.isNotEmpty()) - existing.forEach { store.delete(it) }; - - store.save(serialized); - } - - return existing.isEmpty(); - } - - private fun getContentStore(content: IPlatformContent): ManagedStore? { - val channelId = content.author.url.toSafeFileName(); - return getContentStore(channelId); - } - private fun getContentStore(channelId: String): ManagedStore? { - return synchronized(_channelContents) { - var channelStore = _channelContents.get(channelId); - return@synchronized channelStore; - } - } - - companion object { - private val TAG = "ChannelCache"; - - private val _lock = Object(); - private var _instance: ChannelContentCache? = null; - val instance: ChannelContentCache get() { - synchronized(_lock) { - if(_instance == null) { - _instance = ChannelContentCache(); - } - } - return _instance!!; - } - - fun cachePagerResults(scope: CoroutineScope, pager: IPager, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager { - return ChannelVideoCachePager(pager, scope, onNewCacheHit); - } - } - - class ChannelVideoCachePager(val pager: IPager, private val scope: CoroutineScope, private val onNewCacheItem: ((IPlatformContent)->Unit)? = null): IPager { - - init { - val results = pager.getResults(); - - Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]"); - scope.launch(Dispatchers.IO) { - try { - val newCacheItems = instance.cacheContents(results); - if(onNewCacheItem != null) - newCacheItems.forEach { onNewCacheItem!!(it) } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to cache videos.", e); - } - } - } - - override fun hasMorePages(): Boolean { - return pager.hasMorePages(); - } - - override fun nextPage() { - pager.nextPage(); - val results = pager.getResults(); - - Logger.i(TAG, "Caching ${results.size} subscription results"); - scope.launch(Dispatchers.IO) { - try { - val newCacheItems = instance.cacheContents(results); - if(onNewCacheItem != null) - newCacheItems.forEach { onNewCacheItem!!(it) } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to cache videos.", e); - } - } - } - - override fun getResults(): List { - val results = pager.getResults(); - - return results; - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 490e7447..bdc3a3f8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -24,7 +24,6 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IRefreshPager import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.MultiPager -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.TaskHandler @@ -32,6 +31,7 @@ import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.fragment.mainactivity.main.FeedView import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.views.FeedStyle @@ -78,7 +78,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { private val _taskLoadVideos = TaskHandler>({lifecycleScope}, { val livePager = getContentPager(it); return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true) - ChannelContentCache.cachePagerResults(lifecycleScope, livePager); + StateCache.cachePagerResults(lifecycleScope, livePager); else livePager; }).success { livePager -> setLoading(false); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 1dad57ef..c4c28a73 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt @@ -352,6 +352,7 @@ abstract class FeedView : L } private fun loadPagerInternal(pager: TPager, cache: ItemCache? = null) { + Logger.i(TAG, "Setting new internal pager on feed"); _cache = cache; detachPagerEvents(); @@ -397,6 +398,7 @@ abstract class FeedView : L } } + var _lastNextPage = false; private fun loadNextPage() { synchronized(_pager_lock) { val pager: TPager = recyclerData.pager ?: return; @@ -405,9 +407,14 @@ abstract class FeedView : L //loadCachedPage(); if (pager.hasMorePages()) { + _lastNextPage = true; setLoading(true); _nextPageHandler.run(pager); } + else if(_lastNextPage) { + Logger.i(TAG, "End of page reached"); + _lastNextPage = false; + } } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index 21b75c83..25f44a3a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -15,13 +15,13 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.RateLimitException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.stores.FragmentedStorage @@ -132,8 +132,10 @@ class SubscriptionsFeedFragment : MainFragment() { if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen) loadResults(false); - else if(recyclerData.results.size == 0) + else if(recyclerData.results.size == 0) { loadCache(); + setLoading(false); + } } val announcementsView = _announcementsView; @@ -306,12 +308,18 @@ class SubscriptionsFeedFragment : MainFragment() { private fun loadCache() { - Logger.i(TAG, "Subscriptions load cache"); - val cachePager = ChannelContentCache.instance.getSubscriptionCachePager(); - val results = cachePager.getResults(); - Logger.i(TAG, "Subscriptions show cache (${results.size})"); - setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null); - setPager(cachePager); + fragment.lifecycleScope.launch(Dispatchers.IO) { + Logger.i(TAG, "Subscriptions retrieving cache"); + val cachePager = StateCache.instance.getSubscriptionCachePager(); + Logger.i(TAG, "Subscriptions retrieved cache"); + + withContext(Dispatchers.Main) { + val results = cachePager.getResults(); + Logger.i(TAG, "Subscriptions show cache (${results.size})"); + setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null); + setPager(cachePager); + } + } } private fun loadResults(withRefetch: Boolean = false) { setLoading(true); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 0fde6ff8..b85fc51b 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -26,7 +26,6 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException @@ -387,7 +386,7 @@ class StateApp { try { Logger.i(TAG, "MainApp Started: Initializing [ChannelContentCache]"); val time = measureTimeMillis { - ChannelContentCache.instance; + StateCache.instance; } Logger.i(TAG, "ChannelContentCache initialized in ${time}ms"); } catch (e: Throwable) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt index 699f8b10..899e5717 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.PlatformContentPager -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl @@ -16,12 +15,18 @@ import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.types.DBChannelCache import com.futo.platformplayer.stores.db.types.DBHistory import com.futo.platformplayer.toSafeFileName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.time.OffsetDateTime +import kotlin.system.measureTimeMillis class StateCache { private val _channelCache = ManagedDBStore.create("channelCache", DBChannelCache.Descriptor(), PlatformContentSerializer()) .load(); + val channelCacheStartupCount = _channelCache.count(); + fun clear() { _channelCache.deleteAll(); } @@ -36,6 +41,12 @@ class StateCache { it.obj; } } + fun getChannelCachePager(channelUrls: List): IPager { + val pagers = MultiChronoContentPager(channelUrls.map { _channelCache.queryPager(DBChannelCache.Index::channelUrl, it, 20) { + it.obj; + } }, false, 20); + return DedupContentPager(pagers, StatePlatform.instance.getEnabledClients().map { it.id }); + } fun getSubscriptionCachePager(): DedupContentPager { Logger.i(TAG, "Subscriptions CachePager get subscriptions"); val subs = StateSubscriptions.instance.getSubscriptions(); @@ -47,10 +58,15 @@ class StateCache { else return@map otherUrls; }.flatten().distinct(); - Logger.i(TAG, "Subscriptions CachePager compiling"); - val pagers = MultiChronoContentPager(allUrls.map { getChannelCachePager(it) }, false, 20); - return DedupContentPager(pagers, StatePlatform.instance.getEnabledClients().map { it.id }); + Logger.i(TAG, "Subscriptions CachePager get pagers"); + val pagers = allUrls.parallelStream().map { getChannelCachePager(it) }.toList(); + + Logger.i(TAG, "Subscriptions CachePager compiling"); + val pager = MultiChronoContentPager(pagers, false, 20); + pager.initialize(); + Logger.i(TAG, "Subscriptions CachePager compiled"); + return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id }); } @@ -63,8 +79,8 @@ class StateCache { if(item != null) _channelCache.delete(item); } - fun cacheContents(contents: List): List { - return contents.filter { cacheContent(it) }; + fun cacheContents(contents: List, doUpdate: Boolean = false): List { + return contents.filter { cacheContent(it, doUpdate) }; } fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { if(content.author.url.isEmpty()) @@ -102,5 +118,58 @@ class StateCache { _instance = null; } } + + + fun cachePagerResults(scope: CoroutineScope, pager: IPager, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager { + return ChannelContentCachePager(pager, scope, onNewCacheHit); + } + } + class ChannelContentCachePager(val pager: IPager, private val scope: CoroutineScope, private val onNewCacheItem: ((IPlatformContent)->Unit)? = null): IPager { + + init { + val results = pager.getResults(); + + Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]"); + scope.launch(Dispatchers.IO) { + try { + val newCacheItems = StateCache.instance.cacheContents(results, true); + if(onNewCacheItem != null) + newCacheItems.forEach { onNewCacheItem!!(it) } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to cache videos.", e); + } + } + } + + override fun hasMorePages(): Boolean { + return pager.hasMorePages(); + } + + override fun nextPage() { + pager.nextPage(); + val results = pager.getResults(); + + scope.launch(Dispatchers.IO) { + try { + val newCacheItemsCount: Int; + val ms = measureTimeMillis { + val newCacheItems = instance.cacheContents(results, true); + newCacheItemsCount = newCacheItems.size; + if(onNewCacheItem != null) + newCacheItems.forEach { onNewCacheItem!!(it) } + } + Logger.i(TAG, "Caching ${results.size} subscription results, updated ${newCacheItemsCount} (${ms}ms)"); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to cache ${results.size} videos.", e); + } + } + } + + override fun getResults(): List { + val results = pager.getResults(); + + return results; + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 11e92b3d..d892cdb6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -10,7 +10,6 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.* import com.futo.platformplayer.api.media.structures.ReusablePager.Companion.asReusable -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 50eac84b..fccc93a0 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -7,6 +7,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery import com.futo.platformplayer.api.media.structures.AdhocPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.assume +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.StoreSerializer @@ -264,6 +265,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager = queryPager(validateFieldName(field), obj, pageSize); fun queryPager(field: String, obj: Any, pageSize: Int): IPager { return AdhocPager({ + Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); queryPage(field, obj, it - 1, pageSize); }); } diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt index 9f2af268..a974c97f 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt @@ -3,7 +3,9 @@ package com.futo.platformplayer.stores.db.types import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Database +import androidx.room.Entity import androidx.room.Ignore +import androidx.room.Index import androidx.room.PrimaryKey import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.models.HistoryVideo @@ -25,7 +27,7 @@ class DBChannelCache { //These classes solely exist for bounding generics for type erasure @Dao interface DBDAO: ManagedDBDAOBase {} - @Database(entities = [Index::class], version = 2) + @Database(entities = [Index::class], version = 4) abstract class DB: ManagedDBDatabase() { abstract override fun base(): DBDAO; } @@ -37,6 +39,11 @@ class DBChannelCache { override fun indexClass(): KClass = Index::class; } + @Entity(TABLE_NAME, indices = [ + androidx.room.Index(value = ["url"]), + androidx.room.Index(value = ["channelUrl"]), + androidx.room.Index(value = ["datetime"], orders = [androidx.room.Index.Order.DESC]) + ]) class Index: ManagedDBIndex { @ColumnIndex @PrimaryKey(true) @@ -49,7 +56,7 @@ class DBChannelCache { var channelUrl: String? = null; @ColumnIndex - @ColumnOrdered(0) + @ColumnOrdered(0, true) var datetime: Long? = null; diff --git a/app/src/main/java/com/futo/platformplayer/subscription/CachedSubscriptionAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/CachedSubscriptionAlgorithm.kt index 0f4bf008..cba96ca5 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/CachedSubscriptionAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/CachedSubscriptionAlgorithm.kt @@ -5,10 +5,10 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.PlatformContentPager -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl +import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.toSafeFileName @@ -27,13 +27,16 @@ class CachedSubscriptionAlgorithm(pageSize: Int = 150, scope: CoroutineScope, al override fun getSubscriptions(subs: Map>): Result { val validSubIds = subs.flatMap { it.value } .map { it.toSafeFileName() }.toHashSet(); - val validStores = ChannelContentCache.instance._channelContents + /* + val validStores = StateCache.instance._channelContents .filter { validSubIds.contains(it.key) } - .map { it.value }; + .map { it.value };*/ + /* val items = validStores.flatMap { it.getItems() } .sortedByDescending { it.datetime }; + */ - return Result(DedupContentPager(PlatformContentPager(items, Math.min(_pageSize, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }), listOf()); + return Result(DedupContentPager(StateCache.instance.getChannelCachePager(subs.flatMap { it.value }.distinct()), StatePlatform.instance.getEnabledClients().map { it.id }), listOf()); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SimpleSubscriptionAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SimpleSubscriptionAlgorithm.kt index af96ffcd..bf40d738 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SimpleSubscriptionAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SimpleSubscriptionAlgorithm.kt @@ -8,7 +8,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException @@ -17,6 +16,7 @@ import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.polycentric.PolycentricCache +import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions @@ -157,7 +157,7 @@ class SimpleSubscriptionAlgorithm( val time = measureTimeMillis { pager = StatePlatform.instance.getChannelContent(platformClient, url, true, threadPool.poolSize, toIgnore); - pager = ChannelContentCache.cachePagerResults(scope, pager!!) { + pager = StateCache.cachePagerResults(scope, pager!!) { onNewCacheHit.emit(sub, it); }; @@ -176,7 +176,7 @@ class SimpleSubscriptionAlgorithm( throw channelEx; else { Logger.i(StateSubscriptions.TAG, "Channel ${sub.channel.name} failed, substituting with cache"); - pager = ChannelContentCache.instance.getChannelCachePager(sub.channel.url); + pager = StateCache.instance.getChannelCachePager(sub.channel.url); } } } diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index affdb7c9..d51688df 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.EmptyPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException @@ -21,6 +20,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateSubscriptions import kotlinx.coroutines.CoroutineScope @@ -108,7 +108,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null; val liveTasks = entry.value.filter { !it.task.fromCache }; val cachedTasks = entry.value.filter { it.task.fromCache }; - val livePager = if(!liveTasks.isEmpty()) ChannelContentCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, { + val livePager = if(!liveTasks.isEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, { onNewCacheHit.emit(sub!!, it); }) else null; val cachedPager = if(!cachedTasks.isEmpty()) MultiChronoContentPager(cachedTasks.map { it.pager!! }, true).apply { this.initialize() } else null; @@ -142,7 +142,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( return@submit SubscriptionTaskResult(task, null, null); else { cachedChannels.add(task.url); - return@submit SubscriptionTaskResult(task, ChannelContentCache.instance.getChannelCachePager(task.url), null); + return@submit SubscriptionTaskResult(task, StateCache.instance.getChannelCachePager(task.url), null); } } } @@ -197,7 +197,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( throw channelEx; else { Logger.i(StateSubscriptions.TAG, "Channel ${task.sub.channel.name} failed, substituting with cache"); - pager = ChannelContentCache.instance.getChannelCachePager(task.sub.channel.url); + pager = StateCache.instance.getChannelCachePager(task.sub.channel.url); taskEx = ex; return@submit SubscriptionTaskResult(task, pager, taskEx); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8552e9c4..583192bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,6 +399,7 @@ Version Code Version Name Version Type + Channel Cache Size (Startup) When watching a video in preview mode, resume at the position when opening the video code Please enable logging to submit logs Embedded plugins reinstalled, a reboot is recommended @@ -424,6 +425,7 @@ Developer Mode Development Server Experimental + Cache Fill storage till error Inject Injects a test source config (local) into V8 @@ -432,6 +434,7 @@ Removes all subscriptions Settings related to development server, be careful as it may open your phone to security vulnerabilities Start Server + Subscriptions Cache 5000 Start Server on boot Starts a DevServer on port 11337, may expose vulnerabilities. Test V8 Communication speed diff --git a/app/src/unstable/assets/sources/test/TestConfig.json b/app/src/unstable/assets/sources/test/TestConfig.json new file mode 100644 index 00000000..86eed6ee --- /dev/null +++ b/app/src/unstable/assets/sources/test/TestConfig.json @@ -0,0 +1,24 @@ +{ + "name": "Testing", + "description": "Just for testing.", + "author": "FUTO", + "authorUrl": "https://futo.org", + + "platformUrl": "https://odysee.com", + "sourceUrl": "https://plugins.grayjay.app/Test/TestConfig.json", + "repositoryUrl": "https://futo.org", + "scriptUrl": "./TestScript.js", + "version": 31, + + "iconUrl": "./odysee.png", + "id": "1c05bfc3-08b9-42d0-93d3-6d52e0fd34d8", + + "scriptSignature": "", + "scriptPublicKey": "", + "packages": ["Http"], + + "allowEval": false, + "allowUrls": [], + + "supportedClaimTypes": [] +} diff --git a/app/src/unstable/assets/sources/test/TestScript.js b/app/src/unstable/assets/sources/test/TestScript.js new file mode 100644 index 00000000..45c47d8f --- /dev/null +++ b/app/src/unstable/assets/sources/test/TestScript.js @@ -0,0 +1,45 @@ +var config = {}; + +//Source Methods +source.enable = function(conf){ + config = conf ?? {}; + //log(config); +} +source.getHome = function() { + return new ContentPager([ + source.getContentDetails("whatever") + ]); +}; + +//Video +source.isContentDetailsUrl = function(url) { + return REGEX_DETAILS_URL.test(url) +}; +source.getContentDetails = function(url) { + return new PlatformVideoDetails({ + id: new PlatformID("Test", "Something", config.id), + name: "Test Video", + thumbnails: new Thumbnails([]), + author: new PlatformAuthorLink(new PlatformID("Test", "TestID", config.id), + "TestAuthor", + "None", + ""), + datetime: parseInt(new Date().getTime() / 1000), + duration: 0, + viewCount: 0, + url: "", + isLive: false, + description: "", + rating: new RatingLikes(0), + video: new VideoSourceDescriptor([ + new HLSSource({ + name: "HLS", + url: "", + duration: 0, + priority: true + }) + ]) + }); +}; + +log("LOADED"); \ No newline at end of file diff --git a/app/src/unstable/assets/sources/test/odysee.png b/app/src/unstable/assets/sources/test/odysee.png new file mode 100644 index 0000000000000000000000000000000000000000..472960d00a49401c1d97199ff6fa90cc337bd9bc GIT binary patch literal 47198 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 literal 0 HcmV?d00001 From 585cf090d6e5895b684b341ac5440cc66f9f7a0c Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 1 Dec 2023 16:09:41 +0100 Subject: [PATCH 09/12] DBStore improvements, query like support, more unittest, refactor into StateHistory, history indexes --- .../platformplayer/ManagedDBStoreTests.kt | 108 +++++++-- .../channel/tab/ChannelContentsFragment.kt | 2 +- .../mainactivity/main/HistoryFragment.kt | 3 +- .../mainactivity/main/VideoDetailView.kt | 6 +- .../futo/platformplayer/states/StateApp.kt | 61 +---- .../futo/platformplayer/states/StateCache.kt | 30 ++- .../platformplayer/states/StateHistory.kt | 215 ++++++++++++++++++ .../platformplayer/states/StatePlaylists.kt | 150 +----------- .../stores/db/ManagedDBStore.kt | 42 +++- .../stores/db/types/DBHistory.kt | 28 +-- ...ChannelCache.kt => DBSubscriptionCache.kt} | 11 +- .../com/futo/platformplayer/testing/DBTOs.kt | 2 +- .../views/adapters/HistoryListAdapter.kt | 11 +- .../adapters/feedtypes/PreviewVideoView.kt | 3 +- 14 files changed, 396 insertions(+), 276 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/states/StateHistory.kt rename app/src/main/java/com/futo/platformplayer/stores/db/types/{DBChannelCache.kt => DBSubscriptionCache.kt} (88%) diff --git a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt index 5f30353c..81b318fd 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt @@ -203,35 +203,105 @@ class ManagedDBStoreTests { } @Test fun queryPager() { - val store = ManagedDBStore.create("test", Descriptor()) - .load(context, true); - store.deleteAll(); - val testStr = UUID.randomUUID().toString(); - - val testResults = createSequence(store, 100, { i, testObject -> + testQuery(100, { i, testObject -> if(i % 2 == 0) testObject.someStr = testStr; - }); - val pager = store.queryPager(DBTOs.TestIndex::someString, testStr, 10); + }) { + val pager = it.queryPager(DBTOs.TestIndex::someString, testStr, 10); - val items = pager.getResults().toMutableList(); - while(pager.hasMorePages()) { - pager.nextPage(); - items.addAll(pager.getResults()); + val items = pager.getResults().toMutableList(); + while(pager.hasMorePages()) { + pager.nextPage(); + items.addAll(pager.getResults()); + } + Assert.assertEquals(50, items.size); + for(i in 0 until 50) { + val k = i * 2; + Assert.assertEquals(k, items[i].someNum); + } } - Assert.assertEquals(50, items.size); - for(i in 0 until 50) { - val k = i * 2; - Assert.assertEquals(k, items[i].someNum); - } - - store.shutdown(); } + @Test + fun queryLike() { + val testStr = UUID.randomUUID().toString(); + val testStrLike = testStr.substring(0, 8) + "Testing" + testStr.substring(8, testStr.length); + testQuery(100, { i, testObject -> + if(i % 2 == 0) + testObject.someStr = testStrLike; + }) { + val results = it.queryLike(DBTOs.TestIndex::someString, "%Testing%"); + Assert.assertEquals(50, results.size); + } + } + @Test + fun queryLikePager() { + val testStr = UUID.randomUUID().toString(); + val testStrLike = testStr.substring(0, 8) + "Testing" + testStr.substring(8, testStr.length); + testQuery(100, { i, testObject -> + if(i % 2 == 0) + testObject.someStr = testStrLike; + + }) { + val pager = it.queryLikePager(DBTOs.TestIndex::someString, "%Testing%", 10); + val items = pager.getResults().toMutableList(); + while(pager.hasMorePages()) { + pager.nextPage(); + items.addAll(pager.getResults()); + } + Assert.assertEquals(50, items.size); + for(i in 0 until 50) { + val k = i * 2; + Assert.assertEquals(k, items[i].someNum); + } + } + } + + @Test + fun queryGreater() { + testQuery(100, { i, testObject -> + testObject.someNum = i; + }) { + val results = it.queryGreater(DBTOs.TestIndex::someNum, 51); + Assert.assertEquals(48, results.size); + } + } + @Test + fun querySmaller() { + testQuery(100, { i, testObject -> + testObject.someNum = i; + }) { + val results = it.querySmaller(DBTOs.TestIndex::someNum, 30); + Assert.assertEquals(30, results.size); + } + } + @Test + fun queryBetween() { + testQuery(100, { i, testObject -> + testObject.someNum = i; + }) { + val results = it.queryBetween(DBTOs.TestIndex::someNum, 30, 65); + Assert.assertEquals(34, results.size); + } + } + + + private fun testQuery(items: Int, modifier: (Int, DBTOs.TestObject)->Unit, testing: (ManagedDBStore)->Unit) { + val store = ManagedDBStore.create("test", Descriptor()) + .load(context, true); + store.deleteAll(); + createSequence(store, items, modifier); + try { + testing(store); + } + finally { + store.shutdown(); + } + } private fun createSequence(store: ManagedDBStore, count: Int, modifier: ((Int, DBTOs.TestObject)->Unit)? = null): List { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index bdc3a3f8..c1b0127e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt @@ -106,7 +106,7 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment { } val posBefore = _results.size; - val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo }; + val toAdd = it.filter { it is IPlatformVideo }.map { it as IPlatformVideo } _results.addAll(toAdd); _adapterResults?.let { adapterVideo -> adapterVideo.notifyItemRangeInserted(adapterVideo.childToParentPosition(posBefore), toAdd.size); }; }.exception { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt index a5cafe86..8e65eb1e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HistoryFragment.kt @@ -12,6 +12,7 @@ import androidx.core.widget.addTextChangedListener import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.views.others.TagsView @@ -58,7 +59,7 @@ class HistoryFragment : MainFragment() { tagsView.onClick.subscribe { timeMinutesToErase -> UIDialogs.showConfirmationDialog(requireContext(), getString(R.string.are_you_sure_delete_historical), { - StatePlaylists.instance.removeHistoryRange(timeMinutesToErase.second as Long); + StateHistory.instance.removeHistoryRange(timeMinutesToErase.second as Long); UIDialogs.toast(view.context, timeMinutesToErase.first + " " + getString(R.string.removed)); adapter.updateFilteredVideos(); adapter.notifyDataSetChanged(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 85f82a00..d1d067c0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -778,7 +778,7 @@ class VideoDetailView : ConstraintLayout { suspend fun getHistoryIndex(video: IPlatformVideo): DBHistory.Index = withContext(Dispatchers.IO){ val current = _historyIndex; if(current == null || current.url != video.url) { - val index = StatePlaylists.instance.getHistoryByVideo(video, true); + val index = StateHistory.instance.getHistoryByVideo(video, true)!!; _historyIndex = index; return@withContext index; } @@ -1290,7 +1290,7 @@ class VideoDetailView : ConstraintLayout { val historyItem = getHistoryIndex(videoDetail); withContext(Dispatchers.Main) { - _historicalPosition = StatePlaylists.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); + _historicalPosition = StateHistory.instance.updateHistoryPosition(video, historyItem,false, (toResume.toFloat() / 1000.0f).toLong()); Logger.i(TAG, "Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"); if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(_historicalPosition - lastPositionMilliseconds / 1000) > 5.0) { _layoutResume.visibility = View.VISIBLE; @@ -2107,7 +2107,7 @@ class VideoDetailView : ConstraintLayout { if (updateHistory && (_lastPositionSaveTime == -1L || currentTime - _lastPositionSaveTime > 5000)) { fragment.lifecycleScope.launch(Dispatchers.IO) { val history = getHistoryIndex(v); - StatePlaylists.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); } _lastPositionSaveTime = currentTime; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index b85fc51b..f08e1b4e 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -526,10 +526,8 @@ class StateApp { StatePlaylists.instance.toMigrateCheck(); - StatePlaylists.instance._historyDBStore.deleteAll(); - - if(StatePlaylists.instance.shouldMigrateLegacyHistory()) - StatePlaylists.instance.migrateLegacyHistory(); + if(StateHistory.instance.shouldMigrateLegacyHistory()) + StateHistory.instance.migrateLegacyHistory(); if(false) { @@ -544,64 +542,13 @@ class StateApp { testHistoryDB(4000); Logger.i(TAG, "TEST:--------(6000)---------"); testHistoryDB(6000); - */ Logger.i(TAG, "TEST:--------(100000)---------"); scope.launch(Dispatchers.Default) { - testHistoryDB(100000); + StateHistory.instance.testHistoryDB(100000); } + */ } } - fun testHistoryDB(count: Int) { - Logger.i(TAG, "TEST: Starting tests"); - StatePlaylists.instance._historyDBStore.deleteAll(); - - val testHistoryItem = StatePlaylists.instance.getHistoryLegacy().first(); - val testItemJson = testHistoryItem.video.toJson(); - val now = OffsetDateTime.now(); - - val testSet = (0..count).map { HistoryVideo(Json.decodeFromString(testItemJson.replace(testHistoryItem.video.url, UUID.randomUUID().toString())), it.toLong(), now.minusHours(it.toLong())) } - - - Logger.i(TAG, "TEST: Inserting (${testSet.size})"); - val insertMS = measureTimeMillis { - for(item in testSet) - StatePlaylists.instance._historyDBStore.insert(item); - }; - Logger.i(TAG, "TEST: Inserting in ${insertMS}ms"); - /* - var fetched: List? = null; - val fetchMS = measureTimeMillis { - fetched = StatePlaylists.instance._historyDBStore.getAll(); - Logger.i(TAG, "TEST: Fetched: ${fetched?.size}"); - }; - Logger.i(TAG, "TEST: Fetch speed ${fetchMS}MS"); - val deserializeMS = measureTimeMillis { - val deserialized = StatePlaylists.instance._historyDBStore.convertObjects(fetched!!); - Logger.i(TAG, "TEST: Deserialized: ${deserialized.size}"); - }; - Logger.i(TAG, "TEST: Deserialize speed ${deserializeMS}MS"); - */ - var fetchedIndex: List? = null; - val fetchIndexMS = measureTimeMillis { - fetchedIndex = StatePlaylists.instance._historyDBStore.getAllIndexes(); - Logger.i(TAG, "TEST: Fetched Index: ${fetchedIndex!!.size}"); - }; - Logger.i(TAG, "TEST: Fetched Index speed ${fetchIndexMS}ms"); - val fetchFromIndex = measureTimeMillis { - for(preItem in testSet) { - val item = StatePlaylists.instance.historyIndex[preItem.video.url]; - if(item == null) - throw IllegalStateException("Missing item [${preItem.video.url}]"); - if(item.url != preItem.video.url) - throw IllegalStateException("Mismatch item [${preItem.video.url}]"); - } - }; - Logger.i(TAG, "TEST: Index Lookup speed ${fetchFromIndex}ms"); - - val page1 = StatePlaylists.instance._historyDBStore.getPage(0, 20); - val page2 = StatePlaylists.instance._historyDBStore.getPage(1, 20); - val page3 = StatePlaylists.instance._historyDBStore.getPage(2, 20); - } fun mainAppStartedWithExternalFiles(context: Context) { if(!Settings.instance.didFirstStart) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt index 899e5717..957a958e 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -5,16 +5,12 @@ import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager -import com.futo.platformplayer.api.media.structures.PlatformContentPager import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.serializers.PlatformContentSerializer -import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.db.ManagedDBStore -import com.futo.platformplayer.stores.db.types.DBChannelCache -import com.futo.platformplayer.stores.db.types.DBHistory -import com.futo.platformplayer.toSafeFileName +import com.futo.platformplayer.stores.db.types.DBSubscriptionCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -22,27 +18,27 @@ import java.time.OffsetDateTime import kotlin.system.measureTimeMillis class StateCache { - private val _channelCache = ManagedDBStore.create("channelCache", DBChannelCache.Descriptor(), PlatformContentSerializer()) + private val _subscriptionCache = ManagedDBStore.create("subscriptionCache", DBSubscriptionCache.Descriptor(), PlatformContentSerializer()) .load(); - val channelCacheStartupCount = _channelCache.count(); + val channelCacheStartupCount = _subscriptionCache.count(); fun clear() { - _channelCache.deleteAll(); + _subscriptionCache.deleteAll(); } fun clearToday() { - val today = _channelCache.queryGreater(DBChannelCache.Index::datetime, OffsetDateTime.now().toEpochSecond()); + val today = _subscriptionCache.queryGreater(DBSubscriptionCache.Index::datetime, OffsetDateTime.now().toEpochSecond()); for(content in today) - _channelCache.delete(content); + _subscriptionCache.delete(content); } fun getChannelCachePager(channelUrl: String): IPager { - return _channelCache.queryPager(DBChannelCache.Index::channelUrl, channelUrl, 20) { + return _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, channelUrl, 20) { it.obj; } } fun getChannelCachePager(channelUrls: List): IPager { - val pagers = MultiChronoContentPager(channelUrls.map { _channelCache.queryPager(DBChannelCache.Index::channelUrl, it, 20) { + val pagers = MultiChronoContentPager(channelUrls.map { _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, it, 20) { it.obj; } }, false, 20); return DedupContentPager(pagers, StatePlatform.instance.getEnabledClients().map { it.id }); @@ -70,14 +66,14 @@ class StateCache { } - fun getCachedContent(url: String): DBChannelCache.Index? { - return _channelCache.query(DBChannelCache.Index::url, url).firstOrNull(); + fun getCachedContent(url: String): DBSubscriptionCache.Index? { + return _subscriptionCache.query(DBSubscriptionCache.Index::url, url).firstOrNull(); } fun uncacheContent(content: SerializedPlatformContent) { val item = getCachedContent(content.url); if(item != null) - _channelCache.delete(item); + _subscriptionCache.delete(item); } fun cacheContents(contents: List, doUpdate: Boolean = false): List { return contents.filter { cacheContent(it, doUpdate) }; @@ -90,11 +86,11 @@ class StateCache { val existing = getCachedContent(content.url); if(existing != null && doUpdate) { - _channelCache.update(existing.id!!, serialized); + _subscriptionCache.update(existing.id!!, serialized); return true; } else if(existing == null) { - _channelCache.insert(serialized); + _subscriptionCache.insert(serialized); return true; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt new file mode 100644 index 00000000..0a03ade4 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -0,0 +1,215 @@ +package com.futo.platformplayer.states + +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event2 +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.db.ManagedDBStore +import com.futo.platformplayer.stores.db.types.DBHistory +import com.futo.platformplayer.stores.v2.ReconstructStore +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import java.time.OffsetDateTime +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import kotlin.system.measureTimeMillis + +class StateHistory { + //Legacy + private val _historyStore = FragmentedStorage.storeJson("history") + .withRestore(object: ReconstructStore() { + override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString(); + override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo + = HistoryVideo.fromReconString(backup, null); + }) + .load(); + + private val historyIndex: ConcurrentMap = ConcurrentHashMap(); + val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) + .withIndex({ it.url }, historyIndex, false, true) + .load(); + + var onHistoricVideoChanged = Event2(); + + fun shouldMigrateLegacyHistory(): Boolean { + return _historyDBStore.count() == 0 && _historyStore.count() > 0; + } + fun migrateLegacyHistory() { + Logger.i(StatePlaylists.TAG, "Migrating legacy history"); + _historyDBStore.deleteAll(); + val allHistory = _historyStore.getItems(); + Logger.i(StatePlaylists.TAG, "Migrating legacy history (${allHistory.size}) items"); + for(item in allHistory) { + _historyDBStore.insert(item); + } + _historyStore.deleteAll(); + } + + + fun getHistoryPosition(url: String): Long { + return historyIndex[url]?.position ?: 0; + } + + + fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long { + val pos = if(position < 0) 0 else position; + if(index.obj == null) throw IllegalStateException("Can only update history with a deserialized db item"); + val historyVideo = index.obj!!; + + val positionBefore = historyVideo.position; + if (updateExisting) { + var shouldUpdate = false; + if (positionBefore < 30) { + shouldUpdate = true; + } else { + if (position > 30) { + shouldUpdate = true; + } + } + + if (shouldUpdate) { + + //A unrecovered item + if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) + historyVideo.video = SerializedPlatformVideo.fromVideo(liveObj); + + historyVideo.position = pos; + historyVideo.date = OffsetDateTime.now(); + _historyDBStore.update(index.id!!, historyVideo); + onHistoricVideoChanged.emit(liveObj, pos); + } + + return positionBefore; + } + + return positionBefore; + } + + fun getHistoryLegacy(): List { + return _historyStore.getItems(); + } + fun getHistory() : List { + return _historyDBStore.getAllObjects(); + //return _historyStore.getItems().sortedByDescending { it.date }; + } + fun getHistoryPager(): IPager { + return _historyDBStore.getObjectPager(); + } + fun getHistorySearchPager(query: String): IPager { + return _historyDBStore.queryLikeObjectPager(DBHistory.Index::url, "%${query}%", 10); + } + fun getHistoryIndexByUrl(url: String): DBHistory.Index? { + return historyIndex[url]; + } + fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false): DBHistory.Index? { + val existing = historyIndex[video.url]; + if(existing != null) + return _historyDBStore.get(existing.id!!); + else if(create) { + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, OffsetDateTime.now()); + val id = _historyDBStore.insert(newHistItem); + return _historyDBStore.get(id); + } + return null; + } + + fun removeHistory(url: String) { + val hist = getHistoryIndexByUrl(url); + if(hist != null) + _historyDBStore.delete(hist.id!!); + /* + val hist = _historyStore.findItem { it.video.url == url }; + if(hist != null) + _historyStore.delete(hist);*/ + } + + fun removeHistoryRange(minutesToDelete: Long) { + val now = OffsetDateTime.now().toEpochSecond(); + val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.datetime) < minutesToDelete * 60 }; + for(item in toDelete) + _historyDBStore.delete(item); + /* + val now = OffsetDateTime.now(); + val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; + + for(item in toDelete) + _historyStore.delete(item);*/ + } + + + companion object { + val TAG = "StateHistory"; + private var _instance : StateHistory? = null; + val instance : StateHistory + get(){ + if(_instance == null) + _instance = StateHistory(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } + + + fun testHistoryDB(count: Int) { + Logger.i(TAG, "TEST: Starting tests"); + _historyDBStore.deleteAll(); + + val testHistoryItem = getHistoryLegacy().first(); + val testItemJson = testHistoryItem.video.toJson(); + val now = OffsetDateTime.now(); + + val testSet = (0..count).map { HistoryVideo(Json.decodeFromString(testItemJson.replace(testHistoryItem.video.url, UUID.randomUUID().toString())), it.toLong(), now.minusHours(it.toLong())) } + + + Logger.i(TAG, "TEST: Inserting (${testSet.size})"); + val insertMS = measureTimeMillis { + for(item in testSet) + _historyDBStore.insert(item); + }; + Logger.i(TAG, "TEST: Inserting in ${insertMS}ms"); + + var fetched: List? = null; + val fetchMS = measureTimeMillis { + fetched = _historyDBStore.getAll(); + Logger.i(TAG, "TEST: Fetched: ${fetched?.size}"); + }; + Logger.i(TAG, "TEST: Fetch speed ${fetchMS}MS"); + val deserializeMS = measureTimeMillis { + val deserialized = _historyDBStore.convertObjects(fetched!!); + Logger.i(TAG, "TEST: Deserialized: ${deserialized.size}"); + }; + Logger.i(TAG, "TEST: Deserialize speed ${deserializeMS}MS"); + + var fetchedIndex: List? = null; + val fetchIndexMS = measureTimeMillis { + fetchedIndex = _historyDBStore.getAllIndexes(); + Logger.i(TAG, "TEST: Fetched Index: ${fetchedIndex!!.size}"); + }; + Logger.i(TAG, "TEST: Fetched Index speed ${fetchIndexMS}ms"); + val fetchFromIndex = measureTimeMillis { + for(preItem in testSet) { + val item = historyIndex[preItem.video.url]; + if(item == null) + throw IllegalStateException("Missing item [${preItem.video.url}]"); + if(item.url != preItem.video.url) + throw IllegalStateException("Mismatch item [${preItem.video.url}]"); + } + }; + Logger.i(TAG, "TEST: Index Lookup speed ${fetchFromIndex}ms"); + + val page1 = _historyDBStore.getPage(0, 20); + val page2 = _historyDBStore.getPage(1, 20); + val page3 = _historyDBStore.getPage(2, 20); + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index b75e8240..5c959406 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -45,165 +45,17 @@ class StatePlaylists { = SerializedPlatformVideo.fromVideo(StatePlatform.instance.getContentDetails(backup).await() as IPlatformVideoDetails); }) .load(); - private val _historyStore = FragmentedStorage.storeJson("history") - .withRestore(object: ReconstructStore() { - override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString(); - override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo - = HistoryVideo.fromReconString(backup, null); - }) - .load(); val playlistStore = FragmentedStorage.storeJson("playlists") .withRestore(PlaylistBackup()) .load(); - val historyIndex: ConcurrentMap = ConcurrentHashMap(); - val _historyDBStore = ManagedDBStore.create("history", DBHistory.Descriptor()) - .withIndex({ it.url }, historyIndex, false, true) - .load(); - val playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); - var onHistoricVideoChanged = Event2(); val onWatchLaterChanged = Event0(); fun toMigrateCheck(): List> { - return listOf(playlistStore, _watchlistStore, _historyStore); + return listOf(playlistStore, _watchlistStore); } - - fun shouldMigrateLegacyHistory(): Boolean { - return _historyDBStore.count() == 0 && _historyStore.count() > 0; - } - fun migrateLegacyHistory() { - Logger.i(TAG, "Migrating legacy history"); - _historyDBStore.deleteAll(); - val allHistory = _historyStore.getItems(); - Logger.i(TAG, "Migrating legacy history (${allHistory.size}) items"); - for(item in allHistory) { - _historyDBStore.insert(item); - } - } - - - fun getHistoryPosition(url: String): Long { - return historyIndex[url]?.position ?: 0; - } - fun updateHistoryPosition(liveObj: IPlatformVideo, index: DBHistory.Index, updateExisting: Boolean, position: Long = -1L): Long { - val pos = if(position < 0) 0 else position; - if(index.obj == null) throw IllegalStateException("Can only update history with a deserialized db item"); - val historyVideo = index.obj!!; - - val positionBefore = historyVideo.position; - if (updateExisting) { - var shouldUpdate = false; - if (positionBefore < 30) { - shouldUpdate = true; - } else { - if (position > 30) { - shouldUpdate = true; - } - } - - if (shouldUpdate) { - - //A unrecovered item - if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) - historyVideo.video = SerializedPlatformVideo.fromVideo(liveObj); - - historyVideo.position = pos; - historyVideo.date = OffsetDateTime.now(); - _historyDBStore.update(index.id!!, historyVideo); - onHistoricVideoChanged.emit(liveObj, pos); - } - - return positionBefore; - } - - return positionBefore; - } - /* - fun updateHistoryPosition(video: IPlatformVideo, updateExisting: Boolean, position: Long = -1L): Long { - val pos = if(position < 0) 0 else position; - val historyVideo = _historyStore.findItem { it.video.url == video.url }; - if (historyVideo != null) { - val positionBefore = historyVideo.position; - if (updateExisting) { - var shouldUpdate = false; - if (positionBefore < 30) { - shouldUpdate = true; - } else { - if (position > 30) { - shouldUpdate = true; - } - } - - if (shouldUpdate) { - - //A unrecovered item - if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L) - historyVideo.video = SerializedPlatformVideo.fromVideo(video); - - historyVideo.position = pos; - historyVideo.date = OffsetDateTime.now(); - _historyStore.saveAsync(historyVideo); - onHistoricVideoChanged.emit(video, pos); - } - } - - return positionBefore; - } else { - val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), pos, OffsetDateTime.now()); - _historyStore.saveAsync(newHistItem); - return 0; - } - } -*/ - fun getHistoryLegacy(): List { - return _historyStore.getItems(); - } - fun getHistory() : List { - return _historyDBStore.getAllObjects(); - //return _historyStore.getItems().sortedByDescending { it.date }; - } - fun getHistoryPager(): IPager { - return _historyDBStore.getObjectPager(); - } - fun getHistoryIndexByUrl(url: String): DBHistory.Index? { - return historyIndex[url]; - } - fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false): DBHistory.Index { - val existing = historyIndex[video.url]; - if(existing != null) - return _historyDBStore.get(existing.id!!); - else { - val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, OffsetDateTime.now()); - val id = _historyDBStore.insert(newHistItem); - return _historyDBStore.get(id); - } - } - - fun removeHistory(url: String) { - val hist = getHistoryIndexByUrl(url); - if(hist != null) - _historyDBStore.delete(hist.id!!); - /* - val hist = _historyStore.findItem { it.video.url == url }; - if(hist != null) - _historyStore.delete(hist);*/ - } - - fun removeHistoryRange(minutesToDelete: Long) { - val now = OffsetDateTime.now().toEpochSecond(); - val toDelete = _historyDBStore.getAllIndexes().filter { minutesToDelete == -1L || (now - it.date) < minutesToDelete * 60 }; - for(item in toDelete) - _historyDBStore.delete(item); - /* - val now = OffsetDateTime.now(); - val toDelete = _historyStore.findItems { minutesToDelete == -1L || ChronoUnit.MINUTES.between(it.date, now) < minutesToDelete }; - - for(item in toDelete) - _historyStore.delete(item);*/ - } - fun getWatchLater() : List { synchronized(_watchlistStore) { return _watchlistStore.getItems(); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index fccc93a0..0a3b7d2a 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -232,6 +232,12 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA val query = SimpleSQLiteQuery(queryStr, arrayOf(obj)); return deserializeIndexes(dbDaoBase.getMultiple(query)); } + fun queryLike(field: KProperty<*>, obj: String): List = queryLike(validateFieldName(field), obj); + fun queryLike(field: String, obj: String): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(obj)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } fun queryGreater(field: KProperty<*>, obj: Any): List = queryGreater(validateFieldName(field), obj); fun queryGreater(field: String, obj: Any): List { val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} > ?"; @@ -251,17 +257,30 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return deserializeIndexes(dbDaoBase.getMultiple(query)); } - + //Query Pages + fun queryPage(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List = queryPage(validateFieldName(field), obj, page, pageSize); fun queryPage(field: String, obj: Any, page: Int, pageSize: Int): List { val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} = ? ${_orderSQL} LIMIT ? OFFSET ?"; val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize)); return deserializeIndexes(dbDaoBase.getMultiple(query)); } - fun queryPage(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List = queryPage(validateFieldName(field), obj, page, pageSize); + fun queryLikePage(field: KProperty<*>, obj: String, page: Int, pageSize: Int): List = queryLikePage(validateFieldName(field), obj, page, pageSize); + fun queryLikePage(field: String, obj: String, page: Int, pageSize: Int): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?"; + val query = SimpleSQLiteQuery(queryStr, arrayOf(obj, pageSize, page * pageSize)); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + fun queryLikeObjectPage(field: String, obj: String, page: Int, pageSize: Int): List { + return convertObjects(queryLikePage(field, obj, page, pageSize)); + } + + + //Query Page Objects fun queryPageObjects(field: String, obj: Any, page: Int, pageSize: Int): List = convertObjects(queryPage(field, obj, page, pageSize)); fun queryPageObjects(field: KProperty<*>, obj: Any, page: Int, pageSize: Int): List = queryPageObjects(validateFieldName(field), obj, page, pageSize); + //Query Pager fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager = queryPager(validateFieldName(field), obj, pageSize); fun queryPager(field: String, obj: Any, pageSize: Int): IPager { return AdhocPager({ @@ -269,6 +288,23 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA queryPage(field, obj, it - 1, pageSize); }); } + + fun queryLikePager(field: KProperty<*>, obj: String, pageSize: Int): IPager = queryLikePager(validateFieldName(field), obj, pageSize); + fun queryLikePager(field: String, obj: String, pageSize: Int): IPager { + return AdhocPager({ + Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); + queryLikePage(field, obj, it - 1, pageSize); + }); + } + fun queryLikeObjectPager(field: KProperty<*>, obj: String, pageSize: Int): IPager = queryLikeObjectPager(validateFieldName(field), obj, pageSize); + fun queryLikeObjectPager(field: String, obj: String, pageSize: Int): IPager { + return AdhocPager({ + Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); + queryLikeObjectPage(field, obj, it - 1, pageSize); + }); + } + + //Query Pager with convert fun queryPager(field: KProperty<*>, obj: Any, pageSize: Int, convert: (I)->X): IPager = queryPager(validateFieldName(field), obj, pageSize, convert); fun queryPager(field: String, obj: Any, pageSize: Int, convert: (I)->X): IPager { return AdhocPager({ @@ -276,6 +312,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }); } + //Query Object Pager fun queryObjectPager(field: KProperty<*>, obj: Any, pageSize: Int): IPager = queryObjectPager(validateFieldName(field), obj, pageSize); fun queryObjectPager(field: String, obj: Any, pageSize: Int): IPager { return AdhocPager({ @@ -283,6 +320,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }); } + //Page fun getPage(page: Int, length: Int): List { if(_sqlPage == null) throw IllegalStateException("DB Store [${name}] does not have ordered fields to provide pages"); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt index efc91eac..c35229a3 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt @@ -28,7 +28,7 @@ class DBHistory { //These classes solely exist for bounding generics for type erasure @Dao interface DBDAO: ManagedDBDAOBase {} - @Database(entities = [Index::class], version = 2) + @Database(entities = [Index::class], version = 3) abstract class DB: ManagedDBDatabase() { abstract override fun base(): DBDAO; } @@ -40,32 +40,34 @@ class DBHistory { override fun indexClass(): KClass = Index::class; } - @Entity(TABLE_NAME) - class Index: ManagedDBIndex { + @Entity(TABLE_NAME, indices = [ + androidx.room.Index(value = ["url"]), + androidx.room.Index(value = ["name"]), + androidx.room.Index(value = ["datetime"], orders = [androidx.room.Index.Order.DESC]) + ]) + class Index(): ManagedDBIndex() { @PrimaryKey(true) @ColumnOrdered(1) @ColumnIndex override var id: Long? = null; @ColumnIndex - var url: String; + var url: String = ""; @ColumnIndex - var position: Long; + var position: Long = 0; @ColumnIndex @ColumnOrdered(0, true) - var date: Long; + var datetime: Long = 0; + @ColumnIndex + var name: String = ""; - constructor() { - url = ""; - position = 0; - date = 0; - } - constructor(historyVideo: HistoryVideo) { + constructor(historyVideo: HistoryVideo) : this() { id = null; serialized = null; url = historyVideo.video.url; position = historyVideo.position; - date = historyVideo.date.toEpochSecond(); + datetime = historyVideo.date.toEpochSecond(); + name = historyVideo.video.name; } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBSubscriptionCache.kt similarity index 88% rename from app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt rename to app/src/main/java/com/futo/platformplayer/stores/db/types/DBSubscriptionCache.kt index a974c97f..87ab8fec 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/types/DBChannelCache.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBSubscriptionCache.kt @@ -1,33 +1,28 @@ package com.futo.platformplayer.stores.db.types -import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Database import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Index import androidx.room.PrimaryKey import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent -import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.stores.db.ColumnIndex import com.futo.platformplayer.stores.db.ColumnOrdered import com.futo.platformplayer.stores.db.ManagedDBDAOBase import com.futo.platformplayer.stores.db.ManagedDBDatabase import com.futo.platformplayer.stores.db.ManagedDBDescriptor import com.futo.platformplayer.stores.db.ManagedDBIndex -import java.time.OffsetDateTime import kotlin.reflect.KClass -class DBChannelCache { +class DBSubscriptionCache { companion object { - const val TABLE_NAME = "feed_cache"; + const val TABLE_NAME = "subscription_cache"; } //These classes solely exist for bounding generics for type erasure @Dao interface DBDAO: ManagedDBDAOBase {} - @Database(entities = [Index::class], version = 4) + @Database(entities = [Index::class], version = 5) abstract class DB: ManagedDBDatabase() { abstract override fun base(): DBDAO; } diff --git a/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt b/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt index 0af725db..8778029a 100644 --- a/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt +++ b/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt @@ -15,7 +15,7 @@ import java.util.UUID class DBTOs { @Dao interface DBDAO: ManagedDBDAOBase {} - @Database(entities = [TestIndex::class], version = 2) + @Database(entities = [TestIndex::class], version = 3) abstract class DB: ManagedDBDatabase() { abstract override fun base(): DBDAO; } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt index 5b847f61..ec8cde83 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListAdapter.kt @@ -8,6 +8,7 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlaylists import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope @@ -22,7 +23,7 @@ class HistoryListAdapter : RecyclerView.Adapter { constructor() : super() { updateFilteredVideos(); - StatePlaylists.instance.onHistoricVideoChanged.subscribe(this) { video, position -> + StateHistory.instance.onHistoricVideoChanged.subscribe(this) { video, position -> StateApp.instance.scope.launch(Dispatchers.Main) { val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; if (index == -1) { @@ -45,7 +46,9 @@ class HistoryListAdapter : RecyclerView.Adapter { } fun updateFilteredVideos() { - val videos = StatePlaylists.instance.getHistory(); + val videos = StateHistory.instance.getHistory(); + val pager = StateHistory.instance.getHistoryPager(); + //filtered val pager = StateHistory.instance.getHistorySearchPager("querrryyyyy"); if (_query.isBlank()) { _filteredVideos = videos.toMutableList(); @@ -57,7 +60,7 @@ class HistoryListAdapter : RecyclerView.Adapter { } fun cleanup() { - StatePlaylists.instance.onHistoricVideoChanged.remove(this); + StateHistory.instance.onHistoricVideoChanged.remove(this); } override fun getItemCount() = _filteredVideos.size; @@ -73,7 +76,7 @@ class HistoryListAdapter : RecyclerView.Adapter { return@subscribe; } - StatePlaylists.instance.removeHistory(v.video.url); + StateHistory.instance.removeHistory(v.video.url); _filteredVideos.removeAt(index); notifyItemRemoved(index); }; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt index 8bc3770e..959043ed 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewVideoView.kt @@ -27,6 +27,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.views.others.CreatorThumbnail @@ -246,7 +247,7 @@ open class PreviewVideoView : LinearLayout { val timeBar = _timeBar if (timeBar != null) { if (shouldShowTimeBar) { - val historyPosition = StatePlaylists.instance.getHistoryPosition(video.url) + val historyPosition = StateHistory.instance.getHistoryPosition(video.url) timeBar.visibility = if (historyPosition > 0) VISIBLE else GONE timeBar.progress = historyPosition.toFloat() / video.duration.toFloat() } else { From 7cde8ed5383df39bb6660a50e416ffd3cd784c92 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 1 Dec 2023 16:45:17 +0100 Subject: [PATCH 10/12] Filler dev options --- .../com/futo/platformplayer/SettingsDev.kt | 88 ++++++++++++++++++- .../activities/DeveloperActivity.kt | 22 +++++ .../platformplayer/states/StateHistory.kt | 4 +- .../platformplayer/views/buttons/BigButton.kt | 17 ++++ .../views/fields/ButtonField.kt | 2 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 131 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt index 111911f1..ef4f9495 100644 --- a/app/src/main/java/com/futo/platformplayer/SettingsDev.kt +++ b/app/src/main/java/com/futo/platformplayer/SettingsDev.kt @@ -13,9 +13,11 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueString +import com.futo.platformplayer.activities.DeveloperActivity import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor @@ -29,9 +31,13 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StateHistory +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorageFileJson +import com.futo.platformplayer.stores.db.types.DBHistory +import com.futo.platformplayer.views.fields.ButtonField import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField import kotlinx.coroutines.CoroutineScope @@ -40,6 +46,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.* import kotlinx.serialization.json.* +import java.time.OffsetDateTime import java.util.UUID import java.util.concurrent.TimeUnit import java.util.stream.IntStream.range @@ -89,8 +96,16 @@ class SettingsDev : FragmentedStorageFileJson() { @Serializable class Cache { - @FormField(R.string.subscriptions_cache_5000, FieldForm.BUTTON, -1, 1) + @FormField(R.string.subscriptions_cache_5000, FieldForm.BUTTON, -1, 1, "subscription_cache_button") fun subscriptionsCache5000() { + Logger.i("SettingsDev", "Started caching 5000 sub items"); + UIDialogs.toast( + SettingsActivity.getActivity()!!, + "Started caching 5000 sub items" + ); + val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button"); + if(button is ButtonField) + button.setButtonEnabled(false); StateApp.instance.scope.launch(Dispatchers.IO) { try { val subsCache = @@ -127,6 +142,77 @@ class SettingsDev : FragmentedStorageFileJson() { Logger.e("SettingsDev", ex.message, ex); Logger.i("SettingsDev", "Failed: ${ex.message}"); } + finally { + withContext(Dispatchers.Main) { + if(button is ButtonField) + button.setButtonEnabled(true); + } + } + } + } + + @FormField(R.string.history_cache_100, FieldForm.BUTTON, -1, 1, "history_cache_button") + fun historyCache100() { + Logger.i("SettingsDev", "Started caching 100 history items (from home)"); + UIDialogs.toast( + SettingsActivity.getActivity()!!, + "Started caching 100 history items (from home)" + ); + val button = DeveloperActivity.getActivity()?.getField("history_cache_button"); + if(button is ButtonField) + button.setButtonEnabled(false); + StateApp.instance.scope.launch(Dispatchers.IO) { + try { + val subsCache = StatePlatform.instance.getHome(); + + var num = 0; + for(item in subsCache.getResults().filterIsInstance()) { + StateHistory.instance.getHistoryByVideo(item, true, OffsetDateTime.now().minusHours(num.toLong() * 4)) + num++; + } + + var total = 0; + var page = 0; + var lastToast = System.currentTimeMillis(); + while(subsCache!!.hasMorePages() && total < 5000) { + subsCache!!.nextPage(); + total += subsCache!!.getResults().size; + page++; + + for(item in subsCache.getResults().filterIsInstance()) { + StateHistory.instance.getHistoryByVideo(item, true, OffsetDateTime.now().minusHours(num.toLong() * 4)) + num++; + } + + if(page % 4 == 0) + withContext(Dispatchers.Main) { + val diff = System.currentTimeMillis() - lastToast; + lastToast = System.currentTimeMillis(); + UIDialogs.toast( + SettingsActivity.getActivity()!!, + "Page: ${page}, Total: ${total}, Speed: ${diff}ms" + ); + } + Thread.sleep(500); + } + + withContext(Dispatchers.Main) { + UIDialogs.toast( + SettingsActivity.getActivity()!!, + "FINISHED Page: ${page}, Total: ${total}" + ); + } + } + catch(ex: Throwable) { + Logger.e("SettingsDev", ex.message, ex); + Logger.i("SettingsDev", "Failed: ${ex.message}"); + } + finally { + withContext(Dispatchers.Main) { + if(button is ButtonField) + button.setButtonEnabled(true); + } + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt index 53215522..f714f880 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/DeveloperActivity.kt @@ -1,17 +1,24 @@ package com.futo.platformplayer.activities +import android.annotation.SuppressLint import android.os.Bundle import android.widget.ImageButton import androidx.appcompat.app.AppCompatActivity import com.futo.platformplayer.* import com.futo.platformplayer.views.fields.FieldForm +import com.futo.platformplayer.views.fields.IField class DeveloperActivity : AppCompatActivity() { private lateinit var _form: FieldForm; private lateinit var _buttonBack: ImageButton; + fun getField(id: String): IField? { + return _form.findField(id); + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); + DeveloperActivity._lastActivity = this; setContentView(R.layout.activity_dev); setNavigationBarColorAndIcons(); @@ -33,4 +40,19 @@ class DeveloperActivity : AppCompatActivity() { super.finish() overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up) } + + + + companion object { + //TODO: Temporary for solving Settings issues + @SuppressLint("StaticFieldLeak") + private var _lastActivity: DeveloperActivity? = null; + + fun getActivity(): DeveloperActivity? { + val act = _lastActivity; + if(act != null) + return act; + return null; + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt index 0a03ade4..b60e22be 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt @@ -105,12 +105,12 @@ class StateHistory { fun getHistoryIndexByUrl(url: String): DBHistory.Index? { return historyIndex[url]; } - fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false): DBHistory.Index? { + fun getHistoryByVideo(video: IPlatformVideo, create: Boolean = false, watchDate: OffsetDateTime? = null): DBHistory.Index? { val existing = historyIndex[video.url]; if(existing != null) return _historyDBStore.get(existing.id!!); else if(create) { - val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, OffsetDateTime.now()); + val newHistItem = HistoryVideo(SerializedPlatformVideo.fromVideo(video), 0, watchDate ?: OffsetDateTime.now()); val id = _historyDBStore.insert(newHistItem); return _historyDBStore.get(id); } diff --git a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt index 6d5e30e8..61f8013a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt +++ b/app/src/main/java/com/futo/platformplayer/views/buttons/BigButton.kt @@ -40,6 +40,8 @@ open class BigButton : LinearLayout { _root.apply { isClickable = true; setOnClickListener { + if(!isEnabled) + return@setOnClickListener; action(); onClick.emit(); }; @@ -54,6 +56,8 @@ open class BigButton : LinearLayout { _root.apply { isClickable = true; setOnClickListener { + if(!isEnabled) + return@setOnClickListener; onClick.emit(); }; } @@ -144,4 +148,17 @@ open class BigButton : LinearLayout { return this; } + + fun setButtonEnabled(enabled: Boolean) { + if(enabled) { + alpha = 1f; + isEnabled = true; + isClickable = true; + } + else { + alpha = 0.5f; + isEnabled = false; + isClickable = false; + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt index 6996005c..03af1ee8 100644 --- a/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt +++ b/app/src/main/java/com/futo/platformplayer/views/fields/ButtonField.kt @@ -57,6 +57,8 @@ class ButtonField : BigButton, IField { }; super.onClick.subscribe { + if(!isEnabled) + return@subscribe; if(_method?.parameterCount == 1) _method?.invoke(_obj, context); else if(_method?.parameterCount == 2) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 583192bc..12b56994 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -435,6 +435,7 @@ Settings related to development server, be careful as it may open your phone to security vulnerabilities Start Server Subscriptions Cache 5000 + History Cache 100 Start Server on boot Starts a DevServer on port 11337, may expose vulnerabilities. Test V8 Communication speed From cbf27126543f6aa47897d975c72d9bb769631d1e Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 4 Dec 2023 20:06:24 +0100 Subject: [PATCH 11/12] ManagedDBSTore delete corrupted items, Fix serialized content serializer, Fix notifications wrong intent --- .../models/video/SerializedPlatformContent.kt | 4 ++++ .../video/SerializedPlatformLockedContent.kt | 2 +- .../video/SerializedPlatformNestedContent.kt | 2 +- .../models/video/SerializedPlatformPost.kt | 3 ++- .../models/video/SerializedPlatformVideo.kt | 2 +- .../background/BackgroundWorker.kt | 2 +- .../engine/packages/PackageHttp.kt | 5 ++++- .../serializers/PlatformContentSerializer.kt | 5 +++-- .../futo/platformplayer/states/StateApp.kt | 6 ++++++ .../futo/platformplayer/states/StateCache.kt | 13 +++++++++++- .../states/StateNotifications.kt | 2 +- .../platformplayer/states/StatePlatform.kt | 3 +++ .../states/StateSubscriptions.kt | 5 ++++- .../stores/db/ManagedDBIndex.kt | 5 +++++ .../stores/db/ManagedDBStore.kt | 21 ++++++++++++++----- 15 files changed, 64 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt index b207f427..92e4a4fc 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformContent.kt @@ -6,9 +6,13 @@ import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.serializers.PlatformContentSerializer +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.SerialName @kotlinx.serialization.Serializable(with = PlatformContentSerializer::class) interface SerializedPlatformContent: IPlatformContent { + override val contentType: ContentType; + fun toJson() : String; fun fromJson(str : String) : SerializedPlatformContent; fun fromJsonArray(str : String) : Array; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformLockedContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformLockedContent.kt index dfe8771d..f0d815ba 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformLockedContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformLockedContent.kt @@ -30,7 +30,7 @@ open class SerializedPlatformLockedContent( override val unlockUrl: String? = null, override val contentThumbnails: Thumbnails ) : IPlatformLockedContent, SerializedPlatformContent { - final override val contentType: ContentType get() = ContentType.LOCKED; + override val contentType: ContentType = ContentType.LOCKED; override fun toJson() : String { return Json.encodeToString(this); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt index 5c2ad3f4..079bb91e 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformNestedContent.kt @@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent( override val contentProvider: String?, override val contentThumbnails: Thumbnails ) : IPlatformNestedContent, SerializedPlatformContent { - final override val contentType: ContentType get() = ContentType.NESTED_VIDEO; + final override val contentType: ContentType = ContentType.NESTED_VIDEO; override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id; override val contentSupported: Boolean get() = contentPlugin != null; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt index 8100fea7..a9d5aceb 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformPost.kt @@ -8,6 +8,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.polycentric.core.combineHashCodes +import kotlinx.serialization.EncodeDefault import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -26,7 +27,7 @@ open class SerializedPlatformPost( override val thumbnails: List, override val images: List ) : IPlatformPost, SerializedPlatformContent { - final override val contentType: ContentType get() = ContentType.POST; + override val contentType: ContentType = ContentType.POST; override fun toJson() : String { return Json.encodeToString(this); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index 12dd9e78..ee49ebca 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -26,7 +26,7 @@ open class SerializedPlatformVideo( override val duration: Long, override val viewCount: Long, ) : IPlatformVideo, SerializedPlatformContent { - final override val contentType: ContentType get() = ContentType.MEDIA; + override val contentType: ContentType = ContentType.MEDIA; override val isLive: Boolean = false; diff --git a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt index fbebe2b2..e0ed02b9 100644 --- a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt +++ b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt @@ -122,7 +122,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams //Only for testing notifications val testNotifs = 0; if(contentNotifs.size == 0 && testNotifs > 0) { - results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true } + results.first.getResults().filter { it is IPlatformVideo } .take(testNotifs).forEach { contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it)); } diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt index e35edfa8..eadf68c6 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.internal.V8BindObject import com.futo.platformplayer.getOrThrow import kotlinx.coroutines.CoroutineScope import java.net.SocketTimeoutException +import kotlin.streams.asSequence import kotlin.streams.toList class PackageHttp: V8Package { @@ -171,7 +172,9 @@ class PackageHttp: V8Package { return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers); else return@map it.first.request(it.second.method, it.second.url, it.second.headers); - }.toList(); + } + .asSequence() + .toList(); } } diff --git a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt index f7f839bf..c2766a59 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.serializers import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformNestedContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformPost import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.json.* @@ -22,7 +23,7 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer SerializedPlatformVideo.serializer(); "NESTED_VIDEO" -> SerializedPlatformNestedContent.serializer(); "ARTICLE" -> throw NotImplementedError("Articles not yet implemented"); - "POST" -> throw NotImplementedError("Post not yet implemented"); + "POST" -> SerializedPlatformPost.serializer(); else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.contentOrNull}") }; else @@ -30,7 +31,7 @@ class PlatformContentSerializer() : JsonContentPolymorphicSerializer SerializedPlatformVideo.serializer(); ContentType.NESTED_VIDEO.value -> SerializedPlatformNestedContent.serializer(); ContentType.ARTICLE.value -> throw NotImplementedError("Articles not yet implemented"); - ContentType.POST.value -> throw NotImplementedError("Post not yet implemented"); + ContentType.POST.value -> SerializedPlatformPost.serializer(); else -> throw NotImplementedError("Unknown Content Type Value: ${obj?.jsonPrimitive?.int}") }; } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index f08e1b4e..6554b4e9 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -13,6 +13,7 @@ import android.net.NetworkRequest import android.net.Uri import android.provider.DocumentsContract import android.util.DisplayMetrics +import android.util.Xml import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope @@ -22,6 +23,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient @@ -37,9 +39,12 @@ import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.receivers.AudioNoisyReceiver +import com.futo.platformplayer.serializers.PlatformContentSerializer import com.futo.platformplayer.services.DownloadService import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.types.DBHistory +import com.futo.platformplayer.stores.v2.JsonStoreSerializer import com.futo.platformplayer.stores.v2.ManagedStore import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString @@ -548,6 +553,7 @@ class StateApp { } */ } + } fun mainAppStartedWithExternalFiles(context: Context) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt index 957a958e..636eb922 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.states +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.api.media.structures.DedupContentPager @@ -11,10 +12,15 @@ import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.serializers.PlatformContentSerializer import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.stores.db.types.DBSubscriptionCache +import com.futo.platformplayer.stores.v2.JsonStoreSerializer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.time.OffsetDateTime +import kotlin.streams.asSequence +import kotlin.streams.toList import kotlin.system.measureTimeMillis class StateCache { @@ -34,6 +40,8 @@ class StateCache { fun getChannelCachePager(channelUrl: String): IPager { return _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, channelUrl, 20) { + if(it.objOrNull?.contentType == ContentType.POST) + Logger.i(TAG, "FOUND CACHED POST\n (${it.objOrNull?.name})"); it.obj; } } @@ -56,7 +64,10 @@ class StateCache { }.flatten().distinct(); Logger.i(TAG, "Subscriptions CachePager get pagers"); - val pagers = allUrls.parallelStream().map { getChannelCachePager(it) }.toList(); + val pagers = allUrls.parallelStream() + .map { getChannelCachePager(it) } + .asSequence() + .toList(); Logger.i(TAG, "Subscriptions CachePager compiling"); val pager = MultiChronoContentPager(pagers, false, 20); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt index 1ce15a20..b1fc2428 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt @@ -117,7 +117,7 @@ class StateNotifications { .setContentText("${content.name}") .setSubText(content.datetime?.toHumanNowDiffStringMinDay()) .setSilent(true) - .setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.getVideoIntent(context, content.url), + .setContentIntent(PendingIntent.getActivity(context, content.hashCode(), MainActivity.getVideoIntent(context, content.url), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .setChannelId(notificationChannel.id); if(thumbnail != null) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index e884573f..71e178fd 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.* import okhttp3.internal.concat import java.time.OffsetDateTime import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping +import kotlin.streams.asSequence import kotlin.streams.toList /*** @@ -389,6 +390,7 @@ class StatePlatform { } return@map homeResult; } + .asSequence() .toList() .associateWith { 1f }; @@ -709,6 +711,7 @@ class StatePlatform { } return@map results; } + .asSequence() .toList(); val pager = MultiChronoContentPager(pagers.toTypedArray()); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index d892cdb6..351b3a60 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -38,6 +38,7 @@ import java.util.concurrent.ForkJoinTask import kotlin.collections.ArrayList import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +import kotlin.streams.asSequence import kotlin.streams.toList import kotlin.system.measureTimeMillis @@ -258,7 +259,9 @@ class StateSubscriptions { Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id)); else Pair(it, listOf(it.channel.url)); - }.toList().associate { it }; + }.asSequence() + .toList() + .associate { it }; val result = algo.getSubscriptions(subUrls); return Pair(result.pager, result.exceptions); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt index 93562aca..c4cf5295 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBIndex.kt @@ -14,10 +14,15 @@ open class ManagedDBIndex { @Ignore private var _obj: T? = null; + @Ignore + var isCorrupted: Boolean = false; @get:Ignore val obj: T get() = _obj ?: throw IllegalStateException("Attempted to access serialized object on a index-only instance"); + @get:Ignore + val objOrNull: T? get() = _obj; + fun setInstance(obj: T) { this._obj = obj; } diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 0a3b7d2a..5e757de7 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -361,22 +361,33 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA } fun convertObject(index: I): T? { - return index.obj ?: deserializeIndex(index).obj; + return index.objOrNull ?: deserializeIndex(index).obj; } fun convertObjects(indexes: List): List { - return indexes.mapNotNull { it.obj ?: convertObject(it) }; + return indexes.mapNotNull { it.objOrNull ?: convertObject(it) }; } fun deserializeIndex(index: I): I { + if(index.isCorrupted) + return index; if(index.serialized == null) throw IllegalStateException("Cannot deserialize index-only items from [${name}]"); - val obj = _serializer.deserialize(_class, index.serialized!!); - index.setInstance(obj); + try { + val obj = _serializer.deserialize(_class, index.serialized!!); + index.setInstance(obj); + } + catch(ex: Throwable) { + if(index.serialized != null && index.serialized!!.size > 0) { + Logger.w("ManagedDBStore", "Corrupted object in ${name} found [${index.id}], deleting due to ${ex.message}", ex); + index.isCorrupted = true; + delete(index.id!!); + } + } index.serialized = null; return index; } fun deserializeIndexes(indexes: List): List { for(index in indexes) deserializeIndex(index); - return indexes; + return indexes.filter { !it.isCorrupted } } fun serialize(obj: T): ByteArray { From 06c39ce9737dae602d32a0eb09c9dc4dee6fb247 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 5 Dec 2023 17:04:09 +0100 Subject: [PATCH 12/12] QueryIn support, channel cache query grouped --- .../platformplayer/ManagedDBStoreTests.kt | 19 ++++++++++ .../main/SubscriptionsFeedFragment.kt | 9 ++++- .../futo/platformplayer/states/StateCache.kt | 37 ++++++++++++++----- .../stores/db/ManagedDBStore.kt | 33 +++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt index 81b318fd..971e475c 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer import androidx.test.platform.app.InstrumentationRegistry +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.stores.db.ManagedDBDescriptor import com.futo.platformplayer.stores.db.ManagedDBStore import com.futo.platformplayer.testing.DBTOs @@ -288,6 +289,24 @@ class ManagedDBStoreTests { Assert.assertEquals(34, results.size); } } + @Test + fun queryIn() { + val ids = mutableListOf() + testQuery(1100, { i, testObject -> + testObject.someNum = i; + ids.add(testObject.someStr); + }) { + val pager = it.queryInPager(DBTOs.TestIndex::someString, ids.take(1000), 65); + val list = mutableListOf(); + list.addAll(pager.getResults()); + while(pager.hasMorePages()) + { + pager.nextPage(); + list.addAll(pager.getResults()); + } + Assert.assertEquals(1000, list.size); + } + } private fun testQuery(items: Int, modifier: (Int, DBTOs.TestObject)->Unit, testing: (ManagedDBStore)->Unit) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index 25f44a3a..c1681a8f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt @@ -40,6 +40,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.OffsetDateTime +import kotlin.system.measureTimeMillis +import kotlin.time.measureTime class SubscriptionsFeedFragment : MainFragment() { override val isMainView : Boolean = true; @@ -309,9 +311,12 @@ class SubscriptionsFeedFragment : MainFragment() { private fun loadCache() { fragment.lifecycleScope.launch(Dispatchers.IO) { + val cachePager: IPager; Logger.i(TAG, "Subscriptions retrieving cache"); - val cachePager = StateCache.instance.getSubscriptionCachePager(); - Logger.i(TAG, "Subscriptions retrieved cache"); + val time = measureTimeMillis { + cachePager = StateCache.instance.getSubscriptionCachePager(); + } + Logger.i(TAG, "Subscriptions retrieved cache (${time}ms)"); withContext(Dispatchers.Main) { val results = cachePager.getResults(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt index 636eb922..10c6b94d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -39,11 +39,22 @@ class StateCache { } fun getChannelCachePager(channelUrl: String): IPager { - return _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, channelUrl, 20) { - if(it.objOrNull?.contentType == ContentType.POST) - Logger.i(TAG, "FOUND CACHED POST\n (${it.objOrNull?.name})"); - it.obj; + val result: IPager; + val time = measureTimeMillis { + result = _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, channelUrl, 20) { + it.obj; + } } + return result; + } + fun getAllChannelCachePager(channelUrls: List): IPager { + val result: IPager; + val time = measureTimeMillis { + result = _subscriptionCache.queryInPager(DBSubscriptionCache.Index::channelUrl, channelUrls, 20) { + it.obj; + } + } + return result; } fun getChannelCachePager(channelUrls: List): IPager { val pagers = MultiChronoContentPager(channelUrls.map { _subscriptionCache.queryPager(DBSubscriptionCache.Index::channelUrl, it, 20) { @@ -64,12 +75,20 @@ class StateCache { }.flatten().distinct(); Logger.i(TAG, "Subscriptions CachePager get pagers"); - val pagers = allUrls.parallelStream() - .map { getChannelCachePager(it) } - .asSequence() - .toList(); + val pagers: List>; - Logger.i(TAG, "Subscriptions CachePager compiling"); + val timeCacheRetrieving = measureTimeMillis { + pagers = listOf(getAllChannelCachePager(allUrls)); + + /*allUrls.parallelStream() + .map { + getChannelCachePager(it) + } + .asSequence() + .toList();*/ + } + + Logger.i(TAG, "Subscriptions CachePager compiling (retrieved in ${timeCacheRetrieving}ms)"); val pager = MultiChronoContentPager(pagers, false, 20); pager.initialize(); Logger.i(TAG, "Subscriptions CachePager compiled"); diff --git a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt index 5e757de7..e4d57744 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBStore.kt @@ -265,6 +265,7 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA return deserializeIndexes(dbDaoBase.getMultiple(query)); } + fun queryLikePage(field: KProperty<*>, obj: String, page: Int, pageSize: Int): List = queryLikePage(validateFieldName(field), obj, page, pageSize); fun queryLikePage(field: String, obj: String, page: Int, pageSize: Int): List { val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} LIKE ? ${_orderSQL} LIMIT ? OFFSET ?"; @@ -289,6 +290,38 @@ class ManagedDBStore, T, D: ManagedDBDatabase, DA }); } + + fun queryInPage(field: KProperty<*>, obj: List, page: Int, pageSize: Int): List = queryInPage(validateFieldName(field), obj, page, pageSize); + fun queryInPage(field: String, obj: List, page: Int, pageSize: Int): List { + val queryStr = "SELECT * FROM ${descriptor.table_name} WHERE ${field} IN (${obj.joinToString(",") { "?" }}) ${_orderSQL} LIMIT ? OFFSET ?"; + val query = SimpleSQLiteQuery(queryStr, (obj + arrayOf(pageSize, page * pageSize)).toTypedArray()); + return deserializeIndexes(dbDaoBase.getMultiple(query)); + } + fun queryInObjectPage(field: String, obj: List, page: Int, pageSize: Int): List { + return convertObjects(queryInPage(field, obj, page, pageSize)); + } + fun queryInPager(field: KProperty<*>, obj: List, pageSize: Int): IPager = queryInPager(validateFieldName(field), obj, pageSize); + fun queryInPager(field: String, obj: List, pageSize: Int): IPager { + return AdhocPager({ + Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); + queryInPage(field, obj, it - 1, pageSize); + }); + } + fun queryInObjectPager(field: KProperty<*>, obj: List, pageSize: Int): IPager = queryInObjectPager(validateFieldName(field), obj, pageSize); + fun queryInObjectPager(field: String, obj: List, pageSize: Int): IPager { + return AdhocPager({ + Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); + queryInObjectPage(field, obj, it - 1, pageSize); + }); + } + + fun queryInPager(field: KProperty<*>, obj: List, pageSize: Int, convert: (I)->X): IPager = queryInPager(validateFieldName(field), obj, pageSize, convert); + fun queryInPager(field: String, obj: List, pageSize: Int, convert: (I)->X): IPager { + return AdhocPager({ + queryInPage(field, obj, it - 1, pageSize).map(convert); + }); + } + fun queryLikePager(field: KProperty<*>, obj: String, pageSize: Int): IPager = queryLikePager(validateFieldName(field), obj, pageSize); fun queryLikePager(field: String, obj: String, pageSize: Int): IPager { return AdhocPager({