diff --git a/app/build.gradle b/app/build.gradle index e4b0d364..8881e28b 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 { @@ -38,7 +39,7 @@ protobuf { android { namespace 'com.futo.platformplayer' - compileSdk 33 + compileSdk 34 flavorDimensions "buildType" productFlavors { stable { @@ -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/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt new file mode 100644 index 00000000..971e475c --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/ManagedDBStoreTests.kt @@ -0,0 +1,368 @@ +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 +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(); + } + @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 testStr = UUID.randomUUID().toString(); + testQuery(100, { i, testObject -> + if(i % 2 == 0) + testObject.someStr = testStr; + }) { + val pager = it.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); + } + } + } + + + + @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); + } + } + @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) { + 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 { + 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); + 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); + } + 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() { + 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/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 3caae075..68bdb887 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 @@ -299,7 +298,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..ef4f9495 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 @@ -12,25 +13,31 @@ 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 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.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 @@ -39,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 @@ -82,26 +90,153 @@ 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, "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 = + 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}"); + } + 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); + } + } + } + } + } + @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 +248,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 +498,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/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/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/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/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/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/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/fragment/channel/tab/ChannelContentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelContentsFragment.kt index 490e7447..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 @@ -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); @@ -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/FeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/FeedView.kt index 0e498476..595f880f 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 @@ -372,6 +372,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(); @@ -418,6 +419,7 @@ abstract class FeedView : L } } + var _lastNextPage = false; private fun loadNextPage() { synchronized(_pager_lock) { val pager: TPager = recyclerData.pager ?: return; @@ -426,9 +428,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/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/SubscriptionsFeedFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionsFeedFragment.kt index 21b75c83..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 @@ -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 @@ -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; @@ -132,8 +134,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 +310,21 @@ 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) { + val cachePager: IPager; + Logger.i(TAG, "Subscriptions retrieving cache"); + val time = measureTimeMillis { + cachePager = StateCache.instance.getSubscriptionCachePager(); + } + Logger.i(TAG, "Subscriptions retrieved cache (${time}ms)"); + + 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/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index 11f025f2..37a6524b 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 @@ -72,6 +72,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 @@ -122,6 +123,7 @@ class VideoDetailView : ConstraintLayout { private set; var videoLocal: VideoLocal? = null; private var _playbackTracker: IPlaybackTracker? = null; + private var _historyIndex: DBHistory.Index? = null; val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url; @@ -769,6 +771,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 = StateHistory.instance.getHistoryByVideo(video, true)!!; + _historyIndex = index; + return@withContext index; + } + return@withContext current; + } //Lifecycle @@ -1274,24 +1285,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 = 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; + _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 = ""; } @@ -2087,7 +2104,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); + StateHistory.instance.updateHistoryPosition(v, history, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); + } _lastPositionSaveTime = currentTime; } 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 69b9f2aa..5b3521a9 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,19 @@ 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 android.util.Xml import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope @@ -28,12 +23,11 @@ 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.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 -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 import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException @@ -43,20 +37,23 @@ 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.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 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. @@ -66,20 +63,6 @@ import kotlin.time.measureTime 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)) @@ -427,7 +410,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) { @@ -565,7 +548,34 @@ class StateApp { StateAnnouncement.instance.registerDefaultHandlerAnnouncement(); StateAnnouncement.instance.registerDidYouKnow(); Logger.i(TAG, "MainApp Started: Finished"); + + StatePlaylists.instance.toMigrateCheck(); + + if(StateHistory.instance.shouldMigrateLegacyHistory()) + StateHistory.instance.migrateLegacyHistory(); + + + if(false) { + /* + 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); + Logger.i(TAG, "TEST:--------(100000)---------"); + scope.launch(Dispatchers.Default) { + StateHistory.instance.testHistoryDB(100000); + } + */ + } + } + fun mainAppStartedWithExternalFiles(context: Context) { if(!Settings.instance.didFirstStart) { if(StateBackup.hasAutomaticBackup()) { 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..10c6b94d --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -0,0 +1,201 @@ +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 +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.api.media.structures.MultiChronoContentPager +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.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 { + private val _subscriptionCache = ManagedDBStore.create("subscriptionCache", DBSubscriptionCache.Descriptor(), PlatformContentSerializer()) + .load(); + + val channelCacheStartupCount = _subscriptionCache.count(); + + fun clear() { + _subscriptionCache.deleteAll(); + } + fun clearToday() { + val today = _subscriptionCache.queryGreater(DBSubscriptionCache.Index::datetime, OffsetDateTime.now().toEpochSecond()); + for(content in today) + _subscriptionCache.delete(content); + } + + fun getChannelCachePager(channelUrl: String): IPager { + 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) { + 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(); + 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 get pagers"); + val pagers: List>; + + 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"); + return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id }); + } + + + 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) + _subscriptionCache.delete(item); + } + 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()) + return false; + + val serialized = SerializedPlatformContent.fromContent(content); + val existing = getCachedContent(content.url); + + if(existing != null && doUpdate) { + _subscriptionCache.update(existing.id!!, serialized); + return true; + } + else if(existing == null) { + _subscriptionCache.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; + } + } + + + 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/StateHistory.kt b/app/src/main/java/com/futo/platformplayer/states/StateHistory.kt new file mode 100644 index 00000000..b60e22be --- /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, 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, watchDate ?: 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/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/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index b5086399..5c959406 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 @@ -11,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 @@ -19,6 +21,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 +30,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 @@ -39,26 +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 playlistShareDir = FragmentedStorage.getOrCreateDirectory("shares"); - var onHistoricVideoChanged = Event2(); val onWatchLaterChanged = Event0(); fun toMigrateCheck(): List> { - return listOf(playlistStore, _watchlistStore, _historyStore); + return listOf(playlistStore, _watchlistStore); } - fun getWatchLater() : List { synchronized(_watchlistStore) { return _watchlistStore.getItems(); @@ -99,6 +96,7 @@ class StatePlaylists { return playlistStore.findItem { it.id == id }; } + fun didPlay(playlistId: String) { val playlist = getPlaylist(playlistId); if(playlist != null) { @@ -107,66 +105,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/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index 11e92b3d..351b3a60 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 @@ -39,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 @@ -259,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/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt index 6426e948..a940a1ea 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/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/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..05c19390 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDAOBase.kt @@ -0,0 +1,35 @@ +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 getNullable(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..ce3c9c70 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/ManagedDBDescriptor.kt @@ -0,0 +1,15 @@ +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 val table_name: String; + abstract fun dbClass(): KClass; + abstract fun create(obj: T): I; + + 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 d67bde96..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 @@ -1,5 +1,29 @@ package com.futo.platformplayer.stores.db -class ManagedDBIndex { +import androidx.room.ColumnInfo +import androidx.room.Ignore +import androidx.room.PrimaryKey +import com.futo.platformplayer.api.media.Serializer +open class ManagedDBIndex { + @ColumnIndex + @PrimaryKey(true) + open var id: Long? = null; + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var serialized: ByteArray? = null; + + @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; + } } \ 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 9d24c04f..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 @@ -1,5 +1,457 @@ package com.futo.platformplayer.stores.db -class ManagedDBStore { +import android.content.Context +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.logging.Logger +import com.futo.platformplayer.states.StateApp +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.KProperty +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; + 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}]"); + + val descriptor: ManagedDBDescriptor; + + 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; + 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: ArrayList> = arrayListOf(); + 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) { + this.descriptor = descriptor; + _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; + _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 }; + _orderSQL = if(orderedColumns.size > 0) + " ORDER BY " + orderedColumns.map { "${it.name} ${if(it.ordered!!.descending) "DESC" else "ASC"}" }.joinToString(", "); + else ""; + + _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 ${this.descriptor.table_name} ${_orderSQL} LIMIT ? OFFSET ?", arrayOf(length, page * length)); + } + } + } + + 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(IndexDescriptor(keySelector, indexContainer, allowChange)); + + 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(context: Context? = null, inMemory: Boolean = false): ManagedDBStore { + _db = (if(!inMemory) + Room.databaseBuilder(context ?: StateApp.instance.context, descriptor.dbClass().java, _name) + else + Room.inMemoryDatabaseBuilder(context ?: StateApp.instance.context, descriptor.dbClass().java)) + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + _dbDaoBase = _db!!.base() as ManagedDBDAOBase; + if(_indexes.any()) { + val allItems = _dbDaoBase!!.getMultiple(_sqlIndexed!!); + for(index in _indexes) + 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) + 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 = descriptor.create(obj); + + if(_withUnique != null) { + 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.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 = descriptor.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.keySelector(newIndex); + if(index.checkChange && existing != null) { + val keyExisting = index.keySelector(existing); + if(keyExisting != key) + index.collection.remove(keyExisting); + } + index.collection.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 deserializeIndexes(dbDaoBase.getMultiple(_sqlAll)); + } + + fun getObject(id: Long) = get(id).obj!!; + 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 { + 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 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} > ?"; + 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)); + } + + //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 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({ + Logger.i("ManagedDBStore", "Next Page [query: ${obj}](${it}) ${pageSize}"); + queryPage(field, obj, it - 1, pageSize); + }); + } + + + 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({ + 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({ + queryPage(field, obj, it - 1, pageSize).map(convert); + }); + } + + //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({ + queryPageObjects(field, obj, it - 1, pageSize); + }); + } + + //Page + 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 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); + + for(index in _indexes) + index.collection.remove(index.keySelector(item)); + } + fun delete(id: Long) { + dbDaoBase.action(_sqlDeleteById(id)); + + for(index in _indexes) + index.collection.values.removeIf { it.id == id } + } + fun deleteAll() { + dbDaoBase.action(_sqlDeleteAll); + + _indexCollection.clear(); + for(index in _indexes) + index.collection.clear(); + } + + fun convertObject(index: I): T? { + return index.objOrNull ?: deserializeIndex(index).obj; + } + fun convertObjects(indexes: List): List { + 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}]"); + 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.filter { !it.isCorrupted } + } + + fun serialize(obj: T): ByteArray { + 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)); + } + + //Pair<(I)->Any, ConcurrentMap> + class IndexDescriptor( + val keySelector: (I) -> Any, + val collection: ConcurrentMap, + val checkChange: Boolean + ) + + 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/DBHistory.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt new file mode 100644 index 00000000..c35229a3 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBHistory.kt @@ -0,0 +1,73 @@ +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.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 { + companion object { + const val TABLE_NAME = "history"; + } + + //These classes solely exist for bounding generics for type erasure + @Dao + interface DBDAO: ManagedDBDAOBase {} + @Database(entities = [Index::class], version = 3) + abstract class DB: ManagedDBDatabase() { + abstract override fun base(): DBDAO; + } + + class Descriptor: ManagedDBDescriptor() { + override val table_name: String = TABLE_NAME; + override fun create(obj: HistoryVideo): Index = Index(obj); + override fun dbClass(): KClass = DB::class; + override fun indexClass(): KClass = Index::class; + } + + @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 = ""; + @ColumnIndex + var position: Long = 0; + @ColumnIndex + @ColumnOrdered(0, true) + var datetime: Long = 0; + @ColumnIndex + var name: String = ""; + + constructor(historyVideo: HistoryVideo) : this() { + id = null; + serialized = null; + url = historyVideo.video.url; + position = historyVideo.position; + 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/DBSubscriptionCache.kt b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBSubscriptionCache.kt new file mode 100644 index 00000000..87ab8fec --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/stores/db/types/DBSubscriptionCache.kt @@ -0,0 +1,67 @@ +package com.futo.platformplayer.stores.db.types + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +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 kotlin.reflect.KClass + +class DBSubscriptionCache { + companion object { + 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 = 5) + 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; + } + + @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) + @ColumnOrdered(1) + override var id: Long? = null; + + @ColumnIndex + var url: String? = null; + @ColumnIndex + var channelUrl: String? = null; + + @ColumnIndex + @ColumnOrdered(0, true) + 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 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/java/com/futo/platformplayer/testing/DBTOs.kt b/app/src/main/java/com/futo/platformplayer/testing/DBTOs.kt new file mode 100644 index 00000000..8778029a --- /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 = 3) + 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 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..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 @@ -4,9 +4,15 @@ 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 +import com.futo.platformplayer.states.StateHistory 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; @@ -17,17 +23,19 @@ class HistoryListAdapter : RecyclerView.Adapter { constructor() : super() { updateFilteredVideos(); - StatePlaylists.instance.onHistoricVideoChanged.subscribe(this) { video, position -> - val index = _filteredVideos.indexOfFirst { v -> v.video.url == video.url }; - if (index == -1) { - return@subscribe; - } + 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) { + 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); + } } }; } @@ -38,7 +46,10 @@ 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(); } else { @@ -49,7 +60,7 @@ class HistoryListAdapter : RecyclerView.Adapter { } fun cleanup() { - StatePlaylists.instance.onHistoricVideoChanged.remove(this); + StateHistory.instance.onHistoricVideoChanged.remove(this); } override fun getItemCount() = _filteredVideos.size; @@ -65,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 8d88a7d4..fad5a394 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 @@ -251,7 +252,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 { 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 32263642..7b33534e 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 @@ -90,7 +90,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); @@ -129,8 +133,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 { 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 7f8fde77..60b1776a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -409,6 +409,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 @@ -434,6 +435,7 @@ Developer Mode Development Server Experimental + Cache Fill storage till error Inject Injects a test source config (local) into V8 @@ -442,6 +444,8 @@ Removes all subscriptions 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 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 00000000..472960d0 Binary files /dev/null and b/app/src/unstable/assets/sources/test/odysee.png differ