From d2ed0c65ca712ef4a6ec9314773cbdfe3b43c322 Mon Sep 17 00:00:00 2001 From: ajp-dev <48263871+ajp-dev@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:05:49 +0800 Subject: [PATCH 001/128] fix link typo for Script Signing typo causes HTTP 400 error when accessing link to Script Signing.md --- plugin-development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin-development.md b/plugin-development.md index 6f49269e..de9fc70c 100644 --- a/plugin-development.md +++ b/plugin-development.md @@ -138,7 +138,7 @@ See [Pagers.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/doc When you deploy your plugin, you'll need to add code signing for security. -See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%Signing.md) +See [Script Signing.md](https://gitlab.futo.org/videostreaming/grayjay/-/blob/master/docs/Script%20Signing.md) ## Plugin Deployment @@ -186,4 +186,4 @@ Make sure the signature is correctly generated and added. Also, ensure the versi If you have any issues or need further assistance, feel free to reach out to us at: -https://chat.futo.org/login/ \ No newline at end of file +https://chat.futo.org/login/ From a1d460385d644735a999e7b477a8e62ee240bfe4 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 20 Jan 2025 15:38:26 -0600 Subject: [PATCH 002/128] prevent the user from needing to tap update on system dialog when self updating Changelog: added --- app/src/main/AndroidManifest.xml | 1 + .../java/com/futo/platformplayer/dialogs/AutoUpdateDialog.kt | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index afd659a7..7a5f764b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + = Build.VERSION_CODES.S) { + params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) + } val sessionId = packageInstaller.createSession(params); session = packageInstaller.openSession(sessionId) From daf1d42a0f1398d4b01798c23c297c14bac15c84 Mon Sep 17 00:00:00 2001 From: Alexandre Picavet Date: Thu, 9 Jan 2025 17:44:11 +0100 Subject: [PATCH 003/128] feat(player): Add a setting to adjust player seek duration Create a seekOffset dropdown setting defaulting to 10 seconds. Update the fastForwardTick method of the GestureControlView to take the seekOffset setting into account and update the view accordingly. --- .../java/com/futo/platformplayer/Settings.kt | 18 +++++++++++++++++- .../views/behavior/GestureControlView.kt | 8 ++++---- app/src/main/res/values-fr/strings.xml | 8 ++++++++ app/src/main/res/values/strings.xml | 12 +++++++++++- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 270158fd..4cdbf764 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -486,6 +486,22 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22) var deleteFromWatchLaterAuto: Boolean = true; + + @FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23) + @DropdownFieldOptionsId(R.array.seek_offset_duration) + var seekOffset: Int = 2; + + fun getSeekOffset(): Long { + return when(seekOffset) { + 0 -> 3_000L; + 1 -> 5_000L; + 2 -> 10_000L; + 3 -> 20_000L; + 4 -> 30_000L; + 5 -> 60_000L; + else -> 10_000L; + } + } } @FormField(R.string.comments, "group", R.string.comments_description, 6) @@ -981,4 +997,4 @@ class Settings : FragmentedStorageFileJson() { } } //endregion -} \ No newline at end of file +} diff --git a/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt b/app/src/main/java/com/futo/platformplayer/views/behavior/GestureControlView.kt index 5f4d2b33..3719e85e 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 @@ -628,12 +628,12 @@ class GestureControlView : LinearLayout { private fun fastForwardTick() { _fastForwardCounter++; - val seekOffset: Long = 10000; + val seekOffset: Long = Settings.instance.playback.getSeekOffset(); if (_rewinding) { - _textRewind.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); + _textRewind.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds); onSeek.emit(-seekOffset); } else { - _textFastForward.text = "${_fastForwardCounter * 10} " + context.getString(R.string.seconds); + _textFastForward.text = "${_fastForwardCounter * seekOffset / 1_000} " + context.getString(R.string.seconds); onSeek.emit(seekOffset); } } @@ -807,4 +807,4 @@ class GestureControlView : LinearLayout { const val EXIT_DURATION_FAST_FORWARD: Long = 600; const val TAG = "GestureControlView"; } -} \ No newline at end of file +} diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 733eacc2..438020da 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -688,6 +688,14 @@ Continuer la lecture Superposition du lecteur + + 3 secondes + 5 secondes + 10 secondes + 20 secondes + 30 secondes + 60 secondes + Reprendre depuis le début Reprendre après 10s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa903f2f..3cff24ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,6 +403,8 @@ Allow full-screen portrait when watching horizontal videos Delete from WatchLater when watched After you leave a video that you mostly watched, it will be removed from watch later. + Seek duration + Fast-Forward / Fast-Rewind duration Switch to Audio in Background Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter Groups @@ -1026,6 +1028,14 @@ Within 30 seconds of loss Always + + 3 seconds + 5 seconds + 10 seconds + 20 seconds + 30 seconds + 60 seconds + 15 30 @@ -1039,4 +1049,4 @@ 1500 2000 - \ No newline at end of file + From 65ae8610fd386407dabb78eef21bcb97914c938f Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 11 Feb 2025 17:06:57 +0100 Subject: [PATCH 004/128] Hide download for live videos --- app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt | 2 +- .../fragment/mainactivity/main/VideoDetailView.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index f1d42838..849d1b8c 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -909,7 +909,7 @@ class UISlideOverlays { val watchLater = StatePlaylists.instance.getWatchLater(); items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", (listOf( - if(!isLimited) + if(!isLimited && !video.isLive) SlideUpMenuItem( container.context, R.drawable.ic_download, 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 f69fcd1a..97f2619a 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 @@ -882,7 +882,7 @@ class VideoDetailView : ConstraintLayout { _slideUpOverlay?.hide(); } else null, - if(!isLimitedVersion) + if(!isLimitedVersion && !(video?.isLive ?: false)) RoundButton(context, R.drawable.ic_download, context.getString(R.string.download), TAG_DOWNLOAD) { video?.let { _slideUpOverlay = UISlideOverlays.showDownloadVideoOverlay(it, _overlayContainer, context.contentResolver); From 3b62f999bf094b1b862c5ba0b14c8161d8128162 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 11 Feb 2025 17:41:25 +0100 Subject: [PATCH 005/128] Fixed HttpFileHandler bug causing casting local webm not to work. --- .../platformplayer/api/http/server/handlers/HttpFileHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt index ac72f633..a6ca97ab 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/server/handlers/HttpFileHandler.kt @@ -73,7 +73,7 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent") current += bytesToSend.toLong() - if (current >= end) { + if (current > end) { Logger.i(TAG, "Expected amount of bytes sent") break } From 4dce8d6a803a2e720a28923f8e42cb279f784fa6 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 11 Feb 2025 20:31:26 +0100 Subject: [PATCH 006/128] Export playlist support --- .../platformplayer/downloads/VideoDownload.kt | 4 +- .../platformplayer/downloads/VideoExport.kt | 4 +- .../mainactivity/main/PlaylistFragment.kt | 10 ++++ .../mainactivity/main/VideoListEditorView.kt | 10 ++++ .../platformplayer/states/StateDownloads.kt | 54 ++++++++++++++++++- .../res/layout/fragment_video_list_editor.xml | 16 ++++++ 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index cd4ae885..ede24707 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -375,8 +375,8 @@ class VideoDownload { else throw DownloadException("Could not find a valid video or audio source for download") if(asource is JSSource) { - this.hasVideoRequestExecutor = this.hasVideoRequestExecutor || asource.hasRequestExecutor; - this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate); + this.hasAudioRequestExecutor = this.hasAudioRequestExecutor || asource.hasRequestExecutor; + this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (asource is JSDashManifestRawSource && asource.hasGenerate); } if(asource == null) { diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt index 7c1c4e09..a4615822 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoExport.kt @@ -39,7 +39,7 @@ class VideoExport { this.subtitleSource = subtitleSource; } - suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope { + suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null, documentRoot: DocumentFile? = null): DocumentFile = coroutineScope { val v = videoSource; val a = audioSource; val s = subtitleSource; @@ -50,7 +50,7 @@ class VideoExport { if (s != null) sourceCount++; val outputFile: DocumentFile?; - val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); + val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); if (sourceCount > 1) { val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val f = downloadRoot.createFile("video/mp4", outputFileName) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index 81669c73..b58e3ee2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import androidx.core.app.ShareCompat import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.* +import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideo @@ -78,6 +79,14 @@ class PlaylistFragment : MainFragment() { val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name)); val editPlaylistOverlay = SlideUpMenuOverlay(context, overlayContainer, context.getString(R.string.edit_playlist), context.getString(R.string.ok), false, nameInput); + _buttonExport.setOnClickListener { + _playlist?.let { + val context = StateApp.instance.contextOrNull ?: return@let; + if(context is IWithResultLauncher) + StateDownloads.instance.exportPlaylist(context, it.id); + } + }; + _buttonDownload.visibility = View.VISIBLE; editPlaylistOverlay.onOK.subscribe { val text = nameInput.text; @@ -176,6 +185,7 @@ class PlaylistFragment : MainFragment() { setVideos(parameter.videos, true) setMetadata(parameter.videos.size, parameter.videos.sumOf { it.duration }) setButtonDownloadVisible(true) + setButtonExportVisible(false) setButtonEditVisible(true) if (!StatePlaylists.instance.playlistStore.hasItem { it.id == parameter.id }) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index 2e355fa4..b458a093 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -34,6 +34,7 @@ abstract class VideoListEditorView : LinearLayout { protected var overlayContainer: FrameLayout private set; protected var _buttonDownload: ImageButton; + protected var _buttonExport: ImageButton; private var _buttonShare: ImageButton; private var _buttonEdit: ImageButton; @@ -54,6 +55,8 @@ abstract class VideoListEditorView : LinearLayout { _buttonEdit = findViewById(R.id.button_edit); _buttonDownload = findViewById(R.id.button_download); _buttonDownload.visibility = View.GONE; + _buttonExport = findViewById(R.id.button_export); + _buttonExport.visibility = View.GONE; _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; @@ -68,6 +71,7 @@ abstract class VideoListEditorView : LinearLayout { buttonShuffle.setOnClickListener { onShuffleClick(); }; _buttonEdit.setOnClickListener { onEditClick(); }; + setButtonExportVisible(false); setButtonDownloadVisible(canEdit()); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); @@ -108,6 +112,7 @@ abstract class VideoListEditorView : LinearLayout { _buttonDownload.setBackgroundResource(R.drawable.background_button_round); if(isDownloading) { + setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.drawable.assume { it.start() }; _buttonDownload.setOnClickListener { @@ -117,6 +122,7 @@ abstract class VideoListEditorView : LinearLayout { } } else if(isDownloaded) { + setButtonExportVisible(true) _buttonDownload.setImageResource(R.drawable.ic_download_off); _buttonDownload.setOnClickListener { UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { @@ -125,6 +131,7 @@ abstract class VideoListEditorView : LinearLayout { } } else { + setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_download); _buttonDownload.setOnClickListener { onDownload(); @@ -171,6 +178,9 @@ abstract class VideoListEditorView : LinearLayout { protected fun setButtonDownloadVisible(isVisible: Boolean) { _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; } + protected fun setButtonExportVisible(isVisible: Boolean) { + _buttonExport.visibility = if (isVisible) View.VISIBLE else View.GONE; + } protected fun setButtonEditVisible(isVisible: Boolean) { _buttonEdit.visibility = if (isVisible) View.VISIBLE else View.GONE; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index 4bfeae7b..5bba7e77 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -3,9 +3,11 @@ package com.futo.platformplayer.states import android.content.ContentResolver import android.content.Context import android.os.StatFs +import androidx.documentfile.provider.DocumentFile import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -466,6 +468,54 @@ class StateDownloads { return _downloadsDirectory; } + fun exportPlaylist(context: Context, playlistId: String) { + if(context is IWithResultLauncher) + StateApp.instance.requestDirectoryAccess(context, "Export Playlist", "To export playlist to directory", null) { + if (it == null) + return@requestDirectoryAccess; + + val root = DocumentFile.fromTreeUri(context, it!!); + + val localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId) + + var lastNotifyTime = -1L; + + UIDialogs.showDialogProgress(context) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + it.setText("Exporting videos.."); + var i = 0; + for (video in localVideos) { + withContext(Dispatchers.Main) { + it.setText("Exporting videos...(${i}/${localVideos.size})"); + //it.setProgress(i.toDouble() / localVideos.size); + } + + try { + val export = VideoExport(video, video.videoSource.firstOrNull(), video.audioSource.firstOrNull(), video.subtitlesSources.firstOrNull()); + Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); + + val file = export.export(context, { progress -> + val now = System.currentTimeMillis(); + if (lastNotifyTime == -1L || now - lastNotifyTime > 100) { + it.setProgress(progress); + lastNotifyTime = now; + } + }, root); + } catch(ex: Throwable) { + Logger.e(TAG, "Failed export [${video.name}]: ${ex.message}", ex); + } + i++; + } + withContext(Dispatchers.Main) { + it.setProgress(1f); + it.dismiss(); + UIDialogs.appToast("Finished exporting playlist"); + } + }; + } + } + } + fun export(context: Context, videoLocal: VideoLocal, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?) { var lastNotifyTime = -1L; @@ -477,13 +527,13 @@ class StateDownloads { try { Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); - val file = export.export(context) { progress -> + val file = export.export(context, { progress -> val now = System.currentTimeMillis(); if (lastNotifyTime == -1L || now - lastNotifyTime > 100) { it.setProgress(progress); lastNotifyTime = now; } - } + }, null); withContext(Dispatchers.Main) { it.setProgress(100.0f) diff --git a/app/src/main/res/layout/fragment_video_list_editor.xml b/app/src/main/res/layout/fragment_video_list_editor.xml index 0e636c6f..a906421b 100644 --- a/app/src/main/res/layout/fragment_video_list_editor.xml +++ b/app/src/main/res/layout/fragment_video_list_editor.xml @@ -54,6 +54,22 @@ + Date: Wed, 12 Feb 2025 16:31:30 +0100 Subject: [PATCH 007/128] Fix for misisng exports for export playlist --- .../platformplayer/states/StateDownloads.kt | 35 +++++++++++++------ .../platformplayer/stores/v2/ManagedStore.kt | 11 ++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt index 5bba7e77..e82cd0da 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateDownloads.kt @@ -48,6 +48,17 @@ class StateDownloads { private val _downloadsStat = StatFs(_downloadsDirectory.absolutePath); private val _downloaded = FragmentedStorage.storeJson("downloaded") + .withOnModified({ + synchronized(_downloadedSet) { + if(!_downloadedSet.contains(it.id)) + _downloadedSet.add(it.id); + } + }, { + synchronized(_downloadedSet) { + if(_downloadedSet.contains(it.id)) + _downloadedSet.remove(it.id); + } + }) .load() .apply { afterLoadingDownloaded(this) }; private val _downloading = FragmentedStorage.storeJson("downloading") @@ -87,9 +98,6 @@ class StateDownloads { Logger.i("StateDownloads", "Deleting local video ${id.value}"); val downloaded = getCachedVideo(id); if(downloaded != null) { - synchronized(_downloadedSet) { - _downloadedSet.remove(id); - } _downloaded.delete(downloaded); } onDownloadedChanged.emit(); @@ -263,9 +271,6 @@ class StateDownloads { if(existing.groupID == null) { existing.groupID = VideoDownload.GROUP_WATCHLATER; existing.groupType = VideoDownload.GROUP_WATCHLATER; - synchronized(_downloadedSet) { - _downloadedSet.add(existing.id); - } _downloaded.save(existing); } } @@ -308,9 +313,6 @@ class StateDownloads { if(existing.groupID == null) { existing.groupID = playlist.id; existing.groupType = VideoDownload.GROUP_PLAYLIST; - synchronized(_downloadedSet) { - _downloadedSet.add(existing.id); - } _downloaded.save(existing); } } @@ -476,7 +478,16 @@ class StateDownloads { val root = DocumentFile.fromTreeUri(context, it!!); - val localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId) + val playlist = StatePlaylists.instance.getPlaylist(playlistId); + var localVideos = StateDownloads.instance.getDownloadedVideosPlaylist(playlistId); + if(playlist != null) { + val missing = playlist.videos + .filter { vid -> !localVideos.any { it.id.value == null || it.id.value == vid.id.value } } + .map { getCachedVideo(it.id) } + .filterNotNull(); + if(missing.size > 0) + localVideos = localVideos + missing; + }; var lastNotifyTime = -1L; @@ -484,6 +495,7 @@ class StateDownloads { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { it.setText("Exporting videos.."); var i = 0; + var success = 0; for (video in localVideos) { withContext(Dispatchers.Main) { it.setText("Exporting videos...(${i}/${localVideos.size})"); @@ -501,6 +513,7 @@ class StateDownloads { lastNotifyTime = now; } }, root); + success++; } catch(ex: Throwable) { Logger.e(TAG, "Failed export [${video.name}]: ${ex.message}", ex); } @@ -509,7 +522,7 @@ class StateDownloads { withContext(Dispatchers.Main) { it.setProgress(1f); it.dismiss(); - UIDialogs.appToast("Finished exporting playlist"); + UIDialogs.appToast("Finished exporting playlist (${success} videos${if(i < success) ", ${i} errors" else ""})"); } }; } diff --git a/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt b/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt index cb10cd20..90e79ccc 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/v2/ManagedStore.kt @@ -33,6 +33,9 @@ class ManagedStore{ val className: String? get() = _class.classifier?.assume>()?.simpleName; + private var _onModificationCreate: ((T) -> Unit)? = null; + private var _onModificationDelete: ((T) -> Unit)? = null; + val name: String; constructor(name: String, dir: File, clazz: KType, serializer: StoreSerializer, niceName: String? = null) { @@ -62,6 +65,12 @@ class ManagedStore{ return this; } + fun withOnModified(created: (T)->Unit, deleted: (T)->Unit): ManagedStore { + _onModificationCreate = created; + _onModificationDelete = deleted; + return this; + } + fun load(): ManagedStore { synchronized(_files) { _files.clear(); @@ -265,6 +274,7 @@ class ManagedStore{ file = saveNew(obj); if(_reconstructStore != null && (_reconstructStore!!.backupOnCreate || withReconstruction)) saveReconstruction(file, obj); + _onModificationCreate?.invoke(obj) } } } @@ -300,6 +310,7 @@ class ManagedStore{ _files.remove(item); Logger.v(TAG, "Deleting file ${logName(file.id)}"); file.delete(); + _onModificationDelete?.invoke(item) } } } From 83843f192da2238cffee2ff76d90e258db2d4f10 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 18:43:15 +0100 Subject: [PATCH 008/128] Show total downloaded content duration, Indicator how many subscriptions, save queue as playlist --- .../futo/platformplayer/UISlideOverlays.kt | 30 +++++++++++++++++++ .../mainactivity/main/CreatorsFragment.kt | 9 +++++- .../mainactivity/main/DownloadsFragment.kt | 3 +- .../futo/platformplayer/states/StatePlayer.kt | 7 +++++ .../views/adapters/SubscriptionAdapter.kt | 6 +++- .../views/overlays/QueueEditorOverlay.kt | 21 +++++++++++++ app/src/main/res/layout/fragment_creators.xml | 11 ++++++- app/src/main/res/layout/overlay_queue.xml | 15 ++++++++++ 8 files changed, 98 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 849d1b8c..382f14de 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -79,6 +79,36 @@ class UISlideOverlays { return menu; } + fun showQueueOptionsOverlay(context: Context, container: ViewGroup) { + UISlideOverlays.showOverlay(container, "Queue options", null, { + + }, SlideUpMenuItem(context, R.drawable.ic_playlist, "Save as playlist", "", "Creates a new playlist with queue as videos", null, { + val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name)); + val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput); + + addPlaylistOverlay.onOK.subscribe { + val text = nameInput.text.trim() + if (text.isBlank()) { + return@subscribe; + } + + addPlaylistOverlay.hide(); + nameInput.deactivate(); + nameInput.clear(); + StatePlayer.instance.saveQueueAsPlaylist(text); + UIDialogs.appToast("Playlist [${text}] created"); + }; + + addPlaylistOverlay.onCancel.subscribe { + nameInput.deactivate(); + nameInput.clear(); + }; + + addPlaylistOverlay.show(); + nameInput.activate(); + }, false)); + } + fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup): SlideUpMenuOverlay { val items = arrayListOf(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt index 6efc7a3d..54649ebf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CreatorsFragment.kt @@ -10,6 +10,7 @@ import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageButton import android.widget.Spinner +import android.widget.TextView import androidx.core.widget.addTextChangedListener import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -26,6 +27,7 @@ class CreatorsFragment : MainFragment() { private var _overlayContainer: FrameLayout? = null; private var _containerSearch: FrameLayout? = null; private var _editSearch: EditText? = null; + private var _textMeta: TextView? = null; private var _buttonClearSearch: ImageButton? = null override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -34,6 +36,7 @@ class CreatorsFragment : MainFragment() { val editSearch: EditText = view.findViewById(R.id.edit_search); val buttonClearSearch: ImageButton = view.findViewById(R.id.button_clear_search) _editSearch = editSearch + _textMeta = view.findViewById(R.id.text_meta); _buttonClearSearch = buttonClearSearch buttonClearSearch.setOnClickListener { editSearch.text.clear() @@ -41,7 +44,11 @@ class CreatorsFragment : MainFragment() { _buttonClearSearch?.visibility = View.INVISIBLE; } - val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); + val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)) { subs -> + _textMeta?.let { + it.text = "${subs.size} creator${if(subs.size > 1) "s" else ""}"; + } + }; adapter.onClick.subscribe { platformUser -> navigate(platformUser) }; adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index 7995d543..440aa235 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -22,6 +22,7 @@ import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.toHumanBytesSize +import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.views.AnyInsertedAdapterView import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop import com.futo.platformplayer.views.adapters.viewholders.VideoDownloadViewHolder @@ -215,7 +216,7 @@ class DownloadsFragment : MainFragment() { _listDownloadedHeader.visibility = GONE; } else { _listDownloadedHeader.visibility = VISIBLE; - _listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()})"; + _listDownloadedMeta.text = "(${downloaded.size} ${context.getString(R.string.videos).lowercase()}${if(downloaded.size > 0) ", ${downloaded.sumOf { it.duration }.toHumanDuration(false)}" else ""})"; } lastDownloads = downloaded; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index 286941c4..b8368ea5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -13,6 +13,7 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails 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.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.logging.Logger @@ -130,6 +131,12 @@ class StatePlayer { closeMediaSession(); } + fun saveQueueAsPlaylist(name: String){ + val videos = _queue.toList(); + val playlist = Playlist(name, videos.map { SerializedPlatformVideo.fromVideo(it) }); + StatePlaylists.instance.createOrUpdatePlaylist(playlist); + } + //Notifications fun hasMediaSession() : Boolean { return MediaPlaybackService.getService() != null; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt index e3644cc3..ef3f7cb0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -16,6 +16,7 @@ class SubscriptionAdapter : RecyclerView.Adapter { private lateinit var _sortedDataset: List; private val _inflater: LayoutInflater; private val _confirmationMessage: String; + private val _onDatasetChanged: ((List)->Unit)?; var onClick = Event1(); var onSettings = Event1(); @@ -30,9 +31,10 @@ class SubscriptionAdapter : RecyclerView.Adapter { updateDataset(); } - constructor(inflater: LayoutInflater, confirmationMessage: String) : super() { + constructor(inflater: LayoutInflater, confirmationMessage: String, onDatasetChanged: ((List)->Unit)? = null) : super() { _inflater = inflater; _confirmationMessage = confirmationMessage; + _onDatasetChanged = onDatasetChanged; StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } @@ -78,6 +80,8 @@ class SubscriptionAdapter : RecyclerView.Adapter { .filter { (queryLower.isNullOrBlank() || it.channel.name.lowercase().contains(queryLower)) } .toList(); + _onDatasetChanged?.invoke(_sortedDataset); + notifyDataSetChanged(); } diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt index 215e8dcb..edaed188 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt @@ -2,16 +2,26 @@ package com.futo.platformplayer.views.overlays import android.content.Context import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.R +import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.views.lists.VideoListEditorView +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput class QueueEditorOverlay : LinearLayout { private val _topbar : OverlayTopbar; private val _editor : VideoListEditorView; + private val _btnSettings: ImageView; + + private val _overlayContainer: FrameLayout; + val onClose = Event0(); @@ -19,6 +29,9 @@ class QueueEditorOverlay : LinearLayout { inflate(context, R.layout.overlay_queue, this) _topbar = findViewById(R.id.topbar); _editor = findViewById(R.id.editor); + _btnSettings = findViewById(R.id.button_settings); + _overlayContainer = findViewById(R.id.overlay_container); + _topbar.onClose.subscribe(this, onClose::emit); _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } @@ -28,6 +41,10 @@ class QueueEditorOverlay : LinearLayout { } _editor.onVideoClicked.subscribe { v -> StatePlayer.instance.setQueuePosition(v) } + _btnSettings.setOnClickListener { + handleSettings(); + } + _topbar.setInfo(context.getString(R.string.queue), ""); } @@ -40,4 +57,8 @@ class QueueEditorOverlay : LinearLayout { fun cleanup() { _topbar.onClose.remove(this); } + + fun handleSettings() { + UISlideOverlays.showQueueOptionsOverlay(context, _overlayContainer); + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_creators.xml b/app/src/main/res/layout/fragment_creators.xml index 62694f56..a3848565 100644 --- a/app/src/main/res/layout/fragment_creators.xml +++ b/app/src/main/res/layout/fragment_creators.xml @@ -16,7 +16,7 @@ + + diff --git a/app/src/main/res/layout/overlay_queue.xml b/app/src/main/res/layout/overlay_queue.xml index 9dc827fb..4cee7598 100644 --- a/app/src/main/res/layout/overlay_queue.xml +++ b/app/src/main/res/layout/overlay_queue.xml @@ -21,5 +21,20 @@ android:layout_height="0dp" app:layout_constraintTop_toBottomOf="@id/topbar" app:layout_constraintBottom_toBottomOf="parent" /> + + \ No newline at end of file From b9bbfb44c59b4f4606532768f98be4fcf3843fab Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 18:53:30 +0100 Subject: [PATCH 009/128] Update submodules, fix apple podcast dir --- .gitmodules | 2 +- app/src/stable/assets/sources/apple-podcast | 1 - app/src/stable/assets/sources/apple-podcasts | 1 + app/src/stable/assets/sources/bitchute | 2 +- app/src/stable/assets/sources/peertube | 2 +- app/src/stable/assets/sources/rumble | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/apple-podcasts | 2 +- app/src/unstable/assets/sources/bitchute | 2 +- app/src/unstable/assets/sources/peertube | 2 +- app/src/unstable/assets/sources/rumble | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 12 files changed, 11 insertions(+), 11 deletions(-) delete mode 160000 app/src/stable/assets/sources/apple-podcast create mode 160000 app/src/stable/assets/sources/apple-podcasts diff --git a/.gitmodules b/.gitmodules index dad5c74e..5f6ab0dc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -83,7 +83,7 @@ path = app/src/stable/assets/sources/dailymotion url = ../plugins/dailymotion.git [submodule "app/src/stable/assets/sources/apple-podcast"] - path = app/src/stable/assets/sources/apple-podcast + path = app/src/stable/assets/sources/apple-podcasts url = ../plugins/apple-podcasts.git [submodule "app/src/unstable/assets/sources/apple-podcasts"] path = app/src/unstable/assets/sources/apple-podcasts diff --git a/app/src/stable/assets/sources/apple-podcast b/app/src/stable/assets/sources/apple-podcast deleted file mode 160000 index f79c7141..00000000 --- a/app/src/stable/assets/sources/apple-podcast +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f79c7141bcb11464103abc56fd7be492fe8568ab diff --git a/app/src/stable/assets/sources/apple-podcasts b/app/src/stable/assets/sources/apple-podcasts new file mode 160000 index 00000000..090104c7 --- /dev/null +++ b/app/src/stable/assets/sources/apple-podcasts @@ -0,0 +1 @@ +Subproject commit 090104c7fa112d9772f43c7c2620e8c2cf3c9d6a diff --git a/app/src/stable/assets/sources/bitchute b/app/src/stable/assets/sources/bitchute index 8d7c0e25..7f869aa4 160000 --- a/app/src/stable/assets/sources/bitchute +++ b/app/src/stable/assets/sources/bitchute @@ -1 +1 @@ -Subproject commit 8d7c0e252738450f2a8bb2a48e9f8bdc24cfea54 +Subproject commit 7f869aa4b117214095feb367d38414402cd08417 diff --git a/app/src/stable/assets/sources/peertube b/app/src/stable/assets/sources/peertube index cfabdc97..20fd03d9 160000 --- a/app/src/stable/assets/sources/peertube +++ b/app/src/stable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit cfabdc97ab435822c44b0135b3b76519327ba05a +Subproject commit 20fd03d9847b308d81ce474144bee79b04385477 diff --git a/app/src/stable/assets/sources/rumble b/app/src/stable/assets/sources/rumble index 670cbc04..b9e6259f 160000 --- a/app/src/stable/assets/sources/rumble +++ b/app/src/stable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 670cbc043e8901026c43e1a2e4ac44e12e32143b +Subproject commit b9e6259f4eedd41d1838506d32d240e177c1feff diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 15d3391a..65524663 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 15d3391a5d091405b0c9bd92ff87ebcf2f6944eb +Subproject commit 655246634f4aac712c2cc26c8abc1bc29001e3d8 diff --git a/app/src/unstable/assets/sources/apple-podcasts b/app/src/unstable/assets/sources/apple-podcasts index f79c7141..090104c7 160000 --- a/app/src/unstable/assets/sources/apple-podcasts +++ b/app/src/unstable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit f79c7141bcb11464103abc56fd7be492fe8568ab +Subproject commit 090104c7fa112d9772f43c7c2620e8c2cf3c9d6a diff --git a/app/src/unstable/assets/sources/bitchute b/app/src/unstable/assets/sources/bitchute index 8d7c0e25..7f869aa4 160000 --- a/app/src/unstable/assets/sources/bitchute +++ b/app/src/unstable/assets/sources/bitchute @@ -1 +1 @@ -Subproject commit 8d7c0e252738450f2a8bb2a48e9f8bdc24cfea54 +Subproject commit 7f869aa4b117214095feb367d38414402cd08417 diff --git a/app/src/unstable/assets/sources/peertube b/app/src/unstable/assets/sources/peertube index cfabdc97..20fd03d9 160000 --- a/app/src/unstable/assets/sources/peertube +++ b/app/src/unstable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit cfabdc97ab435822c44b0135b3b76519327ba05a +Subproject commit 20fd03d9847b308d81ce474144bee79b04385477 diff --git a/app/src/unstable/assets/sources/rumble b/app/src/unstable/assets/sources/rumble index 670cbc04..b9e6259f 160000 --- a/app/src/unstable/assets/sources/rumble +++ b/app/src/unstable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit 670cbc043e8901026c43e1a2e4ac44e12e32143b +Subproject commit b9e6259f4eedd41d1838506d32d240e177c1feff diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 2c816009..65524663 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 2c816009f7a09ceb79a707654edbb01e7fb7a3a4 +Subproject commit 655246634f4aac712c2cc26c8abc1bc29001e3d8 From 1dfe18aa6fe8fa9b50af081f59d783e6c67d5a3b Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 18:58:01 +0100 Subject: [PATCH 010/128] Add Apple podcasts --- app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/res/raw/plugin_config.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index 3b7dacec..d98fc987 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -12,7 +12,8 @@ "cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json", "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", - "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json" + "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", + "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index 4e4cc1dc..cfbf3e87 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -12,7 +12,8 @@ "cf8ea74d-ad9b-489e-a083-539b6aa8648c": "sources/bilibili/build/BiliBiliConfig.json", "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", - "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json" + "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", + "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" From 36c51f1a0c1d6a30ef8e708585fe2d2dbf7ecd6e Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 19:06:43 +0100 Subject: [PATCH 011/128] Refs --- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 65524663..8f8774a7 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 655246634f4aac712c2cc26c8abc1bc29001e3d8 +Subproject commit 8f8774a782aa49889774920688de371f28317ca6 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 65524663..8f8774a7 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 655246634f4aac712c2cc26c8abc1bc29001e3d8 +Subproject commit 8f8774a782aa49889774920688de371f28317ca6 From 2f0ba1b1f7ceb0342f7cb30b8dcb6508979c827c Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 19:17:20 +0100 Subject: [PATCH 012/128] Setting to check disabled plugins for updates (off by default) --- app/src/main/java/com/futo/platformplayer/Settings.kt | 3 +++ .../main/java/com/futo/platformplayer/states/StatePlugins.kt | 3 +++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 0a62e28f..c95947ea 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -644,6 +644,9 @@ class Settings : FragmentedStorageFileJson() { @Serializable class Plugins { + @FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1) + var checkDisabledPluginsForUpdates: Boolean = false; + @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) var clearCookiesOnLogout: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index 02154677..3506bc54 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.states import android.content.Context import com.futo.platformplayer.R +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.LoginActivity import com.futo.platformplayer.api.http.ManagedHttpClient @@ -101,6 +102,8 @@ class StatePlugins { if (availableClient !is JSClient) { continue } + if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && StatePlatform.instance.isClientEnabled(availableClient.id)) + continue; val newConfig = checkForUpdates(availableClient.config); if (newConfig != null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e746d8fc..e7026f7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,6 +286,8 @@ Also removes any data related plugin like login or settings Announcement Notifications + Check disabled plugins for updates + Check disabled plugins for updates Planned Content Notifications Schedules discovered planned content as notifications, resulting in more accurate notifications for this content. Attempt to utilize byte ranges From 44c8800bec12293168afc19f5ba90708ada7c611 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 19:25:29 +0100 Subject: [PATCH 013/128] plugin disabled update check fix --- .../main/java/com/futo/platformplayer/states/StatePlugins.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt index 3506bc54..cbb7b4d4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlugins.kt @@ -102,7 +102,7 @@ class StatePlugins { if (availableClient !is JSClient) { continue } - if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && StatePlatform.instance.isClientEnabled(availableClient.id)) + if(!Settings.instance.plugins.checkDisabledPluginsForUpdates && !StatePlatform.instance.isClientEnabled(availableClient.id)) continue; val newConfig = checkForUpdates(availableClient.config); From 157d5b4c3659f6b1cd40f99c03e86248c70c4525 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 20:03:33 +0100 Subject: [PATCH 014/128] Fix container id conflict --- .../futo/platformplayer/views/overlays/QueueEditorOverlay.kt | 2 +- app/src/main/res/layout/overlay_queue.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt index edaed188..a7181e90 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt @@ -30,7 +30,7 @@ class QueueEditorOverlay : LinearLayout { _topbar = findViewById(R.id.topbar); _editor = findViewById(R.id.editor); _btnSettings = findViewById(R.id.button_settings); - _overlayContainer = findViewById(R.id.overlay_container); + _overlayContainer = findViewById(R.id.overlay_container_queue); _topbar.onClose.subscribe(this, onClose::emit); diff --git a/app/src/main/res/layout/overlay_queue.xml b/app/src/main/res/layout/overlay_queue.xml index 4cee7598..904372ea 100644 --- a/app/src/main/res/layout/overlay_queue.xml +++ b/app/src/main/res/layout/overlay_queue.xml @@ -33,7 +33,7 @@ app:srcCompat="@drawable/ic_settings" /> From 8b7c9df286d56f923736da30849f1812901ab5b3 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 12 Feb 2025 20:16:50 +0100 Subject: [PATCH 015/128] Add to queue button on recommendations, no toast on add to watch later if dup --- .../main/java/com/futo/platformplayer/UISlideOverlays.kt | 5 +++-- .../fragment/mainactivity/main/ChannelFragment.kt | 4 ++-- .../fragment/mainactivity/main/ContentFeedView.kt | 4 ++-- .../fragment/mainactivity/main/VideoDetailView.kt | 9 +++++++-- .../com/futo/platformplayer/states/StatePlaylists.kt | 6 +++++- .../views/adapters/feedtypes/PreviewVideoView.kt | 2 +- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 382f14de..67497b1e 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -1075,8 +1075,9 @@ class UISlideOverlays { StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), tag = "watch later", - call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); - UIDialogs.appToast("Added to watch later", false); + call = { + if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true)) + UIDialogs.appToast("Added to watch later", false); }), ) ); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 8bea629a..817a8ca2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -238,8 +238,8 @@ class ChannelFragment : MainFragment() { } adapter.onAddToWatchLaterClicked.subscribe { content -> if (content is IPlatformVideo) { - StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true) - UIDialogs.toast("Added to watch later\n[${content.name}]") + if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true)) + UIDialogs.toast("Added to watch later\n[${content.name}]") } } adapter.onUrlClicked.subscribe { url -> diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index 04a51189..4390a80c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -82,8 +82,8 @@ abstract class ContentFeedView : FeedView Date: Thu, 13 Feb 2025 21:00:02 +0100 Subject: [PATCH 016/128] Remove accidental always update --- .../fragment/mainactivity/main/SourceDetailFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt index 1bdedfcd..1b621c03 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt @@ -556,7 +556,7 @@ class SourceDetailFragment : MainFragment() { Logger.i(TAG, "Downloaded source config ($sourceUrl):\n${configJson}"); val config = SourcePluginConfig.fromJson(configJson); - if (config.version <= c.version && config.name != "Youtube") { + if (config.version <= c.version) { Logger.i(TAG, "Plugin is up to date."); withContext(Dispatchers.Main) { UIDialogs.toast(context.getString(R.string.plugin_is_fully_up_to_date)); }; return@launch; From 3cd4b4503f0fc89074d5281bc643c9bcd96d77f9 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 19 Feb 2025 11:59:59 -0600 Subject: [PATCH 017/128] fix other cookie handling Changelog: changed --- .../api/media/platforms/js/internal/JSHttpClient.kt | 8 +++++++- .../futo/platformplayer/engine/packages/PackageHttp.kt | 7 ++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt index da10e2ec..6f835304 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/internal/JSHttpClient.kt @@ -127,7 +127,7 @@ class JSHttpClient : ManagedHttpClient { } if(doApplyCookies) { - if (_currentCookieMap.isNotEmpty()) { + if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) { val cookiesToApply = hashMapOf(); synchronized(_currentCookieMap) { for(cookie in _currentCookieMap @@ -135,6 +135,12 @@ class JSHttpClient : ManagedHttpClient { .flatMap { it.value.toList() }) cookiesToApply[cookie.first] = cookie.second; }; + synchronized(_otherCookieMap) { + for(cookie in _otherCookieMap + .filter { domain.matchesDomain(it.key) } + .flatMap { it.value.toList() }) + cookiesToApply[cookie.first] = cookie.second; + } if(cookiesToApply.size > 0) { val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; "); 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 686318a1..6f131b4d 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 @@ -126,9 +126,9 @@ class PackageHttp: V8Package { @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) + _packageClientAuth.GET(url, headers, useByteResponse) else - _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); + _packageClient.GET(url, headers, useByteResponse); } @V8Function fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { @@ -385,7 +385,8 @@ class PackageHttp: V8Package { } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { + fun GET(url: String, headers: MutableMap = HashMap(), useByteResponse: Boolean = false) : IBridgeHttpResponse { + val returnType: ReturnType = if(useByteResponse) ReturnType.BYTES else ReturnType.STRING applyDefaultHeaders(headers); return logExceptions { catchHttp { From 7ffa6b1bb318d30ca42ad37771ba9289d057a97c Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 19 Feb 2025 12:32:17 -0600 Subject: [PATCH 018/128] revert params Changelog: changed --- .../com/futo/platformplayer/engine/packages/PackageHttp.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 6f131b4d..686318a1 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 @@ -126,9 +126,9 @@ class PackageHttp: V8Package { @V8Function fun GET(url: String, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { return if(useAuth) - _packageClientAuth.GET(url, headers, useByteResponse) + _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING) else - _packageClient.GET(url, headers, useByteResponse); + _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING); } @V8Function fun POST(url: String, body: Any, headers: MutableMap = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse { @@ -385,8 +385,7 @@ class PackageHttp: V8Package { } @V8Function - fun GET(url: String, headers: MutableMap = HashMap(), useByteResponse: Boolean = false) : IBridgeHttpResponse { - val returnType: ReturnType = if(useByteResponse) ReturnType.BYTES else ReturnType.STRING + fun GET(url: String, headers: MutableMap = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse { applyDefaultHeaders(headers); return logExceptions { catchHttp { From 78f516988074f5a45a1bd3596e42ea165f006a47 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 20 Feb 2025 14:43:13 -0600 Subject: [PATCH 019/128] add recommendations assignment in video details class Changelog: added --- app/src/main/assets/scripts/source.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index 0c87cac4..b6b4ab6d 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -263,6 +263,10 @@ class PlatformVideoDetails extends PlatformVideo { this.rating = obj.rating ?? null; //IRating this.subtitles = obj.subtitles ?? []; this.isShort = !!obj.isShort ?? false; + + if (obj.getContentRecommendations) { + this.getContentRecommendations = obj.getContentRecommendations + } } } From b5ac8b3ec6d94a2af55d587a1993a1ea268bc8a0 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Thu, 20 Feb 2025 21:59:29 +0000 Subject: [PATCH 020/128] Edit Authentication.md --- docs/Authentication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Authentication.md b/docs/Authentication.md index f21581a1..f54eced5 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -8,7 +8,7 @@ The goal of the authentication system is to provide plugins the ability to make > >You should always only login (and install for that matter) plugins you trust. -How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](_blank)). +How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](docs/packages/packageHttp.md)). This documentation will exclusively focus on configuring authentication and how it behaves. ## How it works @@ -58,5 +58,5 @@ Headers are exclusively applied to the domains they are retrieved from. A plugin By default, when authentication requests are made, the authenticated client will behave similar to that of a normal browser. Meaning that if the server you are communicating with sets new cookies, the client will use those cookies instead. These new cookies are NOT saved to disk, meaning that whenever that plugin reloads the cookies will revert to those assigned at login. This behavior can be modified by using custom http clients as described in the http package documentation. - (See [Package: Http](_blank)) + (See [Package: Http](docs/packages/packageHttp.md)) From 0006da7385378d133d6c0118f5075a6a1c33fe27 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 25 Feb 2025 11:00:54 +0100 Subject: [PATCH 021/128] Implemented sync display names. --- app/build.gradle | 2 +- .../activities/SyncHomeActivity.kt | 3 +- .../mainactivity/main/VideoDetailView.kt | 2 +- .../futo/platformplayer/states/StateSync.kt | 21 +++++++- .../sync/internal/SyncSession.kt | 51 ++++++++++++++++--- .../sync/internal/SyncSocketSession.kt | 11 ++-- 6 files changed, 74 insertions(+), 16 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 866a47dd..8d55d000 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,7 +197,7 @@ dependencies { implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.arthenica:ffmpeg-kit-full:5.1' + implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.google.zxing:core:3.4.1' diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index 2d9e51da..d1cd7706 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -101,7 +101,8 @@ class SyncHomeActivity : AppCompatActivity() { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { val connected = session?.connected ?: false syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) - .setName(publicKey) + .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) + //TODO: also display public key? .setStatus(if (connected) "Connected" else "Disconnected") return syncDeviceView } 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 147eb2c2..b84e8c5e 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 @@ -922,7 +922,7 @@ class VideoDetailView : ConstraintLayout { } else if(devices.size == 1){ val device = devices.first(); Logger.i(TAG, "Send to device? (public key: ${device.remotePublicKey}): " + videoToSend.url) - UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non ${device.remotePublicKey}" , { + UIDialogs.showConfirmationDialog(context, "Would you like to open\n[${videoToSend.name}]\non '${device.displayName}'" , { Logger.i(TAG, "Send to device confirmed (public key: ${device.remotePublicKey}): " + videoToSend.url) fragment.lifecycleScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 0197b856..96c25f9d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -44,6 +44,7 @@ import kotlin.system.measureTimeMillis class StateSync { private val _authorizedDevices = FragmentedStorage.get("authorized_devices") + private val _nameStorage = FragmentedStorage.get("sync_remembered_name_storage") private val _syncKeyPair = FragmentedStorage.get("sync_key_pair") private val _lastAddressStorage = FragmentedStorage.get("sync_last_address_storage") private val _syncSessionData = FragmentedStorage.get>("syncSessionData") @@ -305,12 +306,22 @@ class StateSync { synchronized(_sessions) { session = _sessions[s.remotePublicKey] if (session == null) { + val remoteDeviceName = synchronized(_nameStorage) { + _nameStorage.get(remotePublicKey) + } + session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> if (!isNewSession) { return@SyncSession } - Logger.i(TAG, "${s.remotePublicKey} authorized") + it.remoteDeviceName?.let { remoteDeviceName -> + synchronized(_nameStorage) { + _nameStorage.setAndSave(remotePublicKey, remoteDeviceName) + } + } + + Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})") synchronized(_lastAddressStorage) { _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) } @@ -341,7 +352,7 @@ class StateSync { deviceRemoved.emit(it.remotePublicKey) - }) + }, remoteDeviceName) _sessions[remotePublicKey] = session!! } session!!.addSocketSession(s) @@ -469,6 +480,12 @@ class StateSync { } } + fun getCachedName(publicKey: String): String? { + return synchronized(_nameStorage) { + _nameStorage.get(publicKey) + } + } + suspend fun delete(publicKey: String) { withContext(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 6281ca23..8b5621e0 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -6,12 +6,10 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.smartMerge import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup import com.futo.platformplayer.states.StateHistory -import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions @@ -30,6 +28,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.nio.ByteBuffer +import java.nio.ByteOrder import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -53,6 +52,9 @@ class SyncSession : IAuthorizable { private val _id = UUID.randomUUID() private var _remoteId: UUID? = null private var _lastAuthorizedRemoteId: UUID? = null + var remoteDeviceName: String? = null + private set + val displayName: String get() = remoteDeviceName ?: remotePublicKey var connected: Boolean = false private set(v) { @@ -62,7 +64,7 @@ class SyncSession : IAuthorizable { } } - constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit) { + constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) { this.remotePublicKey = remotePublicKey _onAuthorized = onAuthorized _onUnauthorized = onUnauthorized @@ -85,7 +87,20 @@ class SyncSession : IAuthorizable { fun authorize(socketSession: SyncSocketSession) { Logger.i(TAG, "Sent AUTHORIZED with session id $_id") - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) + + if (socketSession.remoteVersion >= 3) { + val idStringBytes = _id.toString().toByteArray() + val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray() + val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size) + socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply { + put(idStringBytes.size.toByte()) + put(idStringBytes) + put(nameBytes.size.toByte()) + put(nameBytes) + }.apply { flip() }) + } else { + socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) + } _authorized = true checkAuthorized() } @@ -138,15 +153,37 @@ class SyncSession : IAuthorizable { when (opcode) { Opcode.NOTIFY_AUTHORIZED.value -> { - val str = data.toUtf8String() - _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") + if (socketSession.remoteVersion >= 3) { + val idByteCount = data.get().toInt() + if (idByteCount > 64) + throw Exception("Id should always be smaller than 64 bytes") + + val idBytes = ByteArray(idByteCount) + data.get(idBytes) + + val nameByteCount = data.get().toInt() + if (nameByteCount > 64) + throw Exception("Name should always be smaller than 64 bytes") + + val nameBytes = ByteArray(nameByteCount) + data.get(nameBytes) + + _remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8)) + remoteDeviceName = nameBytes.toString(Charsets.UTF_8) + } else { + val str = data.toUtf8String() + _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") + remoteDeviceName = null + } + _remoteAuthorized = true - Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId") + Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") checkAuthorized() return } Opcode.NOTIFY_UNAUTHORIZED.value -> { _remoteId = null + remoteDeviceName = null _lastAuthorizedRemoteId = null _remoteAuthorized = false _onUnauthorized(this) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 4a1def91..c997cec4 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -46,6 +46,8 @@ class SyncSocketSession { val localPublicKey: String get() = _localPublicKey private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit var authorizable: IAuthorizable? = null + var remoteVersion: Int = -1 + private set val remoteAddress: String @@ -162,11 +164,12 @@ class SyncSocketSession { } private fun performVersionCheck() { - val CURRENT_VERSION = 2 + val CURRENT_VERSION = 3 + val MINIMUM_VERSION = 2 _outputStream.writeInt(CURRENT_VERSION) - val version = _inputStream.readInt() - Logger.i(TAG, "performVersionCheck (version = $version)") - if (version != CURRENT_VERSION) + remoteVersion = _inputStream.readInt() + Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") + if (remoteVersion < MINIMUM_VERSION) throw Exception("Invalid version") } From edc2b3d2956ad0315fc09822be0e0be9c4c003b2 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 25 Feb 2025 15:32:30 +0100 Subject: [PATCH 022/128] Fixed issue where video reload would reset video timestamp. --- .../mainactivity/main/VideoDetailView.kt | 72 +++++++++++++------ .../views/video/FutoVideoPlayerBase.kt | 3 +- 2 files changed, 54 insertions(+), 21 deletions(-) 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 b84e8c5e..afda7722 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 @@ -579,6 +579,14 @@ class VideoDetailView : ConstraintLayout { _minimize_title.setOnClickListener { onMaximize.emit(false) }; _minimize_meta.setOnClickListener { onMaximize.emit(false) }; + _player.onStateChange.subscribe { + if (_player.activelyPlaying) { + Logger.i(TAG, "Play changed, resetting error counter _didTriggerDatasourceErrorCount = 0 (_player.activelyPlaying: ${_player.activelyPlaying})") + _didTriggerDatasourceErrorCount = 0; + _didTriggerDatasourceError = false; + } + } + _player.onPlayChanged.subscribe { if (StateCasting.instance.activeDevice == null) { handlePlayChanged(it); @@ -963,6 +971,7 @@ class VideoDetailView : ConstraintLayout { throw IllegalStateException("Expected media content, found ${video.contentType}"); withContext(Dispatchers.Main) { + _videoResumePositionMilliseconds = _player.position setVideoDetails(video); } } @@ -1265,8 +1274,6 @@ class VideoDetailView : ConstraintLayout { @OptIn(ExperimentalCoroutinesApi::class) fun setVideoDetails(videoDetail: IPlatformVideoDetails, newVideo: Boolean = false) { Logger.i(TAG, "setVideoDetails (${videoDetail.name})") - _didTriggerDatasourceErrroCount = 0; - _didTriggerDatasourceError = false; _autoplayVideo = null Logger.i(TAG, "Autoplay video cleared (setVideoDetails)") @@ -1277,6 +1284,10 @@ class VideoDetailView : ConstraintLayout { _lastVideoSource = null; _lastAudioSource = null; _lastSubtitleSource = null; + + Logger.i(TAG, "_didTriggerDatasourceErrorCount reset to 0 because new video") + _didTriggerDatasourceErrorCount = 0; + _didTriggerDatasourceError = false; } if (videoDetail.datetime != null && videoDetail.datetime!! > OffsetDateTime.now()) @@ -1831,7 +1842,7 @@ class VideoDetailView : ConstraintLayout { } } - private var _didTriggerDatasourceErrroCount = 0; + private var _didTriggerDatasourceErrorCount = 0; private var _didTriggerDatasourceError = false; private fun onDataSourceError(exception: Throwable) { Logger.e(TAG, "onDataSourceError", exception); @@ -1841,32 +1852,53 @@ class VideoDetailView : ConstraintLayout { return; val config = currentVideo.sourceConfig; - if(_didTriggerDatasourceErrroCount <= 3) { + if(_didTriggerDatasourceErrorCount <= 3) { _didTriggerDatasourceError = true; - _didTriggerDatasourceErrroCount++; + _didTriggerDatasourceErrorCount++; + + UIDialogs.toast("Detected video error, attempting automatic reload (${_didTriggerDatasourceErrorCount})"); + Logger.i(TAG, "Block detected, attempting bypass (_didTriggerDatasourceErrorCount = ${_didTriggerDatasourceErrorCount})"); - UIDialogs.toast("Block detected, attempting bypass"); //return; fragment.lifecycleScope.launch(Dispatchers.IO) { - val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await(); - val previousVideoSource = _lastVideoSource; - val previousAudioSource = _lastAudioSource; + try { + val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await(); + val previousVideoSource = _lastVideoSource; + val previousAudioSource = _lastAudioSource; - if(newDetails is IPlatformVideoDetails) { - val newVideoSource = if(previousVideoSource != null) - VideoHelper.selectBestVideoSource(newDetails.video, previousVideoSource.height * previousVideoSource.width, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS); - else null; - val newAudioSource = if(previousAudioSource != null) - VideoHelper.selectBestAudioSource(newDetails.video, FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, previousAudioSource.language, previousAudioSource.bitrate.toLong()); - else null; - withContext(Dispatchers.Main) { - video = newDetails; - _player.setSource(newVideoSource, newAudioSource, true, true); + if (newDetails is IPlatformVideoDetails) { + val newVideoSource = if (previousVideoSource != null) + VideoHelper.selectBestVideoSource( + newDetails.video, + previousVideoSource.height * previousVideoSource.width, + FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS + ); + else null; + val newAudioSource = if (previousAudioSource != null) + VideoHelper.selectBestAudioSource( + newDetails.video, + FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS, + previousAudioSource.language, + previousAudioSource.bitrate.toLong() + ); + else null; + withContext(Dispatchers.Main) { + video = newDetails; + _player.setSource(newVideoSource, newAudioSource, true, true); + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get video details, attempting retrying without reloading.", e) + fragment.lifecycleScope.launch(Dispatchers.Main) { + video?.let { + _videoResumePositionMilliseconds = _player.position + setVideoDetails(it, false) + } } } } } - else if(_didTriggerDatasourceErrroCount > 3) { + else if(_didTriggerDatasourceErrorCount > 3) { UIDialogs.showDialog(context, R.drawable.ic_error_pred, context.getString(R.string.media_error), context.getString(R.string.the_media_source_encountered_an_unauthorized_error_this_might_be_solved_by_a_plugin_reload_would_you_like_to_reload_experimental), diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 61366bf5..c872ca02 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -96,6 +96,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val exoPlayerStateName: String; var playing: Boolean = false; + val activelyPlaying: Boolean get() = (exoPlayer?.player?.playbackState == Player.STATE_READY) && (exoPlayer?.player?.playWhenReady ?: false) val position: Long get() = exoPlayer?.player?.currentPosition ?: 0; val duration: Long get() = exoPlayer?.player?.duration ?: 0; @@ -829,7 +830,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss"); when (error.errorCode) { - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}"); if(error.cause is HttpDataSource.InvalidResponseCodeException) { val cause = error.cause as HttpDataSource.InvalidResponseCodeException From 1bbfa7d39ecb3a8712f59e7d840b9f53d5deac67 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 26 Feb 2025 21:29:06 +0100 Subject: [PATCH 023/128] WIP home filtering --- .../mainactivity/main/HomeFragment.kt | 43 ++++++++++- .../futo/platformplayer/views/ToggleBar.kt | 74 +++++++++++++++++++ app/src/main/res/layout/view_toggle_bar.xml | 16 ++++ app/src/main/res/values/strings.xml | 2 +- 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt create mode 100644 app/src/main/res/layout/view_toggle_bar.xml diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index 26210bc8..9cdac8f9 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -23,6 +23,7 @@ import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.NoResultsView +import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewHolder @@ -94,6 +95,8 @@ class HomeFragment : MainFragment() { class HomeView : ContentFeedView { override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); + private var _toggleBar: ToggleBar? = null; + private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar @@ -127,6 +130,8 @@ class HomeFragment : MainFragment() { }, fragment); }; + initializeToolbarContent(); + setPreviewsEnabled(Settings.instance.home.previewFeedItems); showAnnouncementView() } @@ -201,13 +206,43 @@ class HomeFragment : MainFragment() { loadResults(); } - override fun filterResults(results: List): List { - return results.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) }; + private val _filterLock = Object(); + private var _toggleRecent = false; + fun initializeToolbarContent() { + //Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing + /* + _toggleBar = ToggleBar(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + synchronized(_filterLock) { + _toggleBar?.setToggles( + //TODO: loadResults needs to be replaced with an internal reload of the current content + ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) } + ) + } + + _toolbarContentView.addView(_toggleBar, 0); + */ } - private fun loadResults() { + override fun filterResults(results: List): List { + return results.filter { + if(StateMeta.instance.isVideoHidden(it.url)) + return@filter false; + if(StateMeta.instance.isCreatorHidden(it.author.url)) + return@filter false; + + if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) { + return@filter false; + } + + return@filter true; + }; + } + + private fun loadResults(withRefetch: Boolean = true) { setLoading(true); - _taskGetPager.run(true); + _taskGetPager.run(withRefetch); } private fun loadedResult(pager : IPager) { if (pager is EmptyPager) { diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt new file mode 100644 index 00000000..be3d8df8 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -0,0 +1,74 @@ +package com.futo.platformplayer.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.channels.SerializedChannel +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.Subscription +import com.futo.platformplayer.models.SubscriptionGroup +import com.futo.platformplayer.states.StateSubscriptionGroups +import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny +import com.futo.platformplayer.views.others.ToggleTagView +import com.futo.platformplayer.views.adapters.viewholders.SubscriptionBarViewHolder +import com.futo.platformplayer.views.adapters.viewholders.SubscriptionGroupBarViewHolder +import com.futo.platformplayer.views.subscriptions.SubscriptionExploreButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ToggleBar : LinearLayout { + private val _tagsContainer: LinearLayout; + + override fun onAttachedToWindow() { + super.onAttachedToWindow(); + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow(); + StateSubscriptionGroups.instance.onGroupsChanged.remove(this); + } + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.view_toggle_bar, this); + + _tagsContainer = findViewById(R.id.container_tags); + } + + fun setToggles(vararg buttons: Toggle) { + _tagsContainer.removeAllViews(); + for(button in buttons) { + _tagsContainer.addView(ToggleTagView(context).apply { + this.setInfo(button.name, button.isActive); + this.onClick.subscribe { button.action(it); }; + }); + } + } + + class Toggle { + val name: String; + val icon: Int; + val action: (Boolean)->Unit; + val isActive: Boolean; + + constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = icon; + this.action = action; + this.isActive = isActive; + } + constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = 0; + this.action = action; + this.isActive = isActive; + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_bar.xml b/app/src/main/res/layout/view_toggle_bar.xml new file mode 100644 index 00000000..3da2f363 --- /dev/null +++ b/app/src/main/res/layout/view_toggle_bar.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7026f7a..d4df1905 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -418,7 +418,7 @@ Log Level Logging Sync Grayjay - Sync your settings across multiple devices + Sync your data across multiple devices Manage Polycentric identity Manage your Polycentric identity Manual check From 442272f51788a4172bd0628d6be9f0aabefd77ab Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 27 Feb 2025 10:38:03 +0100 Subject: [PATCH 024/128] SettingsActivity can now be landscape. --- app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index afd659a7..c9917a2d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -156,7 +156,6 @@ android:theme="@style/Theme.FutoVideo.NoActionBar" /> Date: Thu, 27 Feb 2025 14:34:51 +0100 Subject: [PATCH 025/128] submods --- app/src/stable/assets/sources/apple-podcasts | 2 +- app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/bitchute | 2 +- app/src/stable/assets/sources/dailymotion | 2 +- app/src/stable/assets/sources/kick | 2 +- app/src/stable/assets/sources/nebula | 2 +- app/src/stable/assets/sources/odysee | 2 +- app/src/stable/assets/sources/patreon | 2 +- app/src/stable/assets/sources/peertube | 2 +- app/src/stable/assets/sources/rumble | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/twitch | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/apple-podcasts | 2 +- app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/bitchute | 2 +- app/src/unstable/assets/sources/dailymotion | 2 +- app/src/unstable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/nebula | 2 +- app/src/unstable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/patreon | 2 +- app/src/unstable/assets/sources/peertube | 2 +- app/src/unstable/assets/sources/rumble | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/twitch | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 28 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/src/stable/assets/sources/apple-podcasts b/app/src/stable/assets/sources/apple-podcasts index 090104c7..07e39f9d 160000 --- a/app/src/stable/assets/sources/apple-podcasts +++ b/app/src/stable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 090104c7fa112d9772f43c7c2620e8c2cf3c9d6a +Subproject commit 07e39f9df71b1937adf5bfb718a115fc232aa6f8 diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 13b30fd7..ce0571bd 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 13b30fd76e30a60c114c97b876542f7f106b5881 +Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb diff --git a/app/src/stable/assets/sources/bitchute b/app/src/stable/assets/sources/bitchute index 7f869aa4..3fbd872a 160000 --- a/app/src/stable/assets/sources/bitchute +++ b/app/src/stable/assets/sources/bitchute @@ -1 +1 @@ -Subproject commit 7f869aa4b117214095feb367d38414402cd08417 +Subproject commit 3fbd872ad8bd7df62c5fbec7437e1200d82b74e1 diff --git a/app/src/stable/assets/sources/dailymotion b/app/src/stable/assets/sources/dailymotion index d00c7ff8..b34134ca 160000 --- a/app/src/stable/assets/sources/dailymotion +++ b/app/src/stable/assets/sources/dailymotion @@ -1 +1 @@ -Subproject commit d00c7ff8e557d8b5624c162e4e554f65625c5e29 +Subproject commit b34134ca2dbb1662b060b4a67f14e7c5d077889d diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index 8d957b6f..2046944c 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 8d957b6fc4f354f4ab68d3cb2d1a7fa19323edeb +Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14 diff --git a/app/src/stable/assets/sources/nebula b/app/src/stable/assets/sources/nebula index 9e6dcf09..f30a3bfc 160000 --- a/app/src/stable/assets/sources/nebula +++ b/app/src/stable/assets/sources/nebula @@ -1 +1 @@ -Subproject commit 9e6dcf093538511eac56dc44a32b99139f1f1005 +Subproject commit f30a3bfc0f6a894d816ab7fa732b8f63eb54b84e diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index 04b4d8ed..f2f83344 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit 04b4d8ed3163b7146bb58c418c201899e04e34cb +Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 diff --git a/app/src/stable/assets/sources/patreon b/app/src/stable/assets/sources/patreon index 9c835e07..e5dce87c 160000 --- a/app/src/stable/assets/sources/patreon +++ b/app/src/stable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit 9c835e075c66ea014e544d9fe35fbb317d72a196 +Subproject commit e5dce87c9d8ae7571df40a6fa252404e18b963f1 diff --git a/app/src/stable/assets/sources/peertube b/app/src/stable/assets/sources/peertube index 20fd03d9..2bcab14d 160000 --- a/app/src/stable/assets/sources/peertube +++ b/app/src/stable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit 20fd03d9847b308d81ce474144bee79b04385477 +Subproject commit 2bcab14d01a564aa8ab9218de54042fc68b9ee76 diff --git a/app/src/stable/assets/sources/rumble b/app/src/stable/assets/sources/rumble index b9e6259f..a32dbb62 160000 --- a/app/src/stable/assets/sources/rumble +++ b/app/src/stable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit b9e6259f4eedd41d1838506d32d240e177c1feff +Subproject commit a32dbb626aacfc6264e505cd5c7f34dd8a60edfc diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index a72aeb85..ae47f2ea 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit a72aeb85d0fc0c17382cb1a7066fe4ec8b63691c +Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index eb231ade..0d05e35c 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit eb231adeae7acd0ed8b14e2ebc2b93424ac6811c +Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d diff --git a/app/src/stable/assets/sources/twitch b/app/src/stable/assets/sources/twitch index 1b2833cd..a75e8460 160000 --- a/app/src/stable/assets/sources/twitch +++ b/app/src/stable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit 1b2833cdf22afec8b1177bf8bc3e5f83bc014e37 +Subproject commit a75e846045a7882002dd7a6bfa83550f52d9dbab diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 8f8774a7..857c147b 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 8f8774a782aa49889774920688de371f28317ca6 +Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf diff --git a/app/src/unstable/assets/sources/apple-podcasts b/app/src/unstable/assets/sources/apple-podcasts index 090104c7..07e39f9d 160000 --- a/app/src/unstable/assets/sources/apple-podcasts +++ b/app/src/unstable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 090104c7fa112d9772f43c7c2620e8c2cf3c9d6a +Subproject commit 07e39f9df71b1937adf5bfb718a115fc232aa6f8 diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 13b30fd7..ce0571bd 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 13b30fd76e30a60c114c97b876542f7f106b5881 +Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb diff --git a/app/src/unstable/assets/sources/bitchute b/app/src/unstable/assets/sources/bitchute index 7f869aa4..3fbd872a 160000 --- a/app/src/unstable/assets/sources/bitchute +++ b/app/src/unstable/assets/sources/bitchute @@ -1 +1 @@ -Subproject commit 7f869aa4b117214095feb367d38414402cd08417 +Subproject commit 3fbd872ad8bd7df62c5fbec7437e1200d82b74e1 diff --git a/app/src/unstable/assets/sources/dailymotion b/app/src/unstable/assets/sources/dailymotion index d00c7ff8..b34134ca 160000 --- a/app/src/unstable/assets/sources/dailymotion +++ b/app/src/unstable/assets/sources/dailymotion @@ -1 +1 @@ -Subproject commit d00c7ff8e557d8b5624c162e4e554f65625c5e29 +Subproject commit b34134ca2dbb1662b060b4a67f14e7c5d077889d diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index 8d957b6f..2046944c 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 8d957b6fc4f354f4ab68d3cb2d1a7fa19323edeb +Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14 diff --git a/app/src/unstable/assets/sources/nebula b/app/src/unstable/assets/sources/nebula index 9e6dcf09..f30a3bfc 160000 --- a/app/src/unstable/assets/sources/nebula +++ b/app/src/unstable/assets/sources/nebula @@ -1 +1 @@ -Subproject commit 9e6dcf093538511eac56dc44a32b99139f1f1005 +Subproject commit f30a3bfc0f6a894d816ab7fa732b8f63eb54b84e diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index 04b4d8ed..f2f83344 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit 04b4d8ed3163b7146bb58c418c201899e04e34cb +Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 diff --git a/app/src/unstable/assets/sources/patreon b/app/src/unstable/assets/sources/patreon index 9c835e07..e5dce87c 160000 --- a/app/src/unstable/assets/sources/patreon +++ b/app/src/unstable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit 9c835e075c66ea014e544d9fe35fbb317d72a196 +Subproject commit e5dce87c9d8ae7571df40a6fa252404e18b963f1 diff --git a/app/src/unstable/assets/sources/peertube b/app/src/unstable/assets/sources/peertube index 20fd03d9..2bcab14d 160000 --- a/app/src/unstable/assets/sources/peertube +++ b/app/src/unstable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit 20fd03d9847b308d81ce474144bee79b04385477 +Subproject commit 2bcab14d01a564aa8ab9218de54042fc68b9ee76 diff --git a/app/src/unstable/assets/sources/rumble b/app/src/unstable/assets/sources/rumble index b9e6259f..a32dbb62 160000 --- a/app/src/unstable/assets/sources/rumble +++ b/app/src/unstable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit b9e6259f4eedd41d1838506d32d240e177c1feff +Subproject commit a32dbb626aacfc6264e505cd5c7f34dd8a60edfc diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index a72aeb85..ae47f2ea 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit a72aeb85d0fc0c17382cb1a7066fe4ec8b63691c +Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index eb231ade..0d05e35c 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit eb231adeae7acd0ed8b14e2ebc2b93424ac6811c +Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d diff --git a/app/src/unstable/assets/sources/twitch b/app/src/unstable/assets/sources/twitch index 1b2833cd..a75e8460 160000 --- a/app/src/unstable/assets/sources/twitch +++ b/app/src/unstable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit 1b2833cdf22afec8b1177bf8bc3e5f83bc014e37 +Subproject commit a75e846045a7882002dd7a6bfa83550f52d9dbab diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 8f8774a7..857c147b 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 8f8774a782aa49889774920688de371f28317ca6 +Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf From bbeb9b83a0ba4538107758ab993566aa625749dc Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 5 Mar 2025 11:58:09 +0100 Subject: [PATCH 026/128] Removed dynamic Polycentric calls. --- .../views/adapters/PlaylistView.kt | 43 ----------- .../views/adapters/SubscriptionViewHolder.kt | 41 ---------- .../adapters/feedtypes/PreviewPostView.kt | 43 ----------- .../adapters/feedtypes/PreviewVideoView.kt | 71 ----------------- .../viewholders/CreatorBarViewHolder.kt | 76 ------------------- .../adapters/viewholders/CreatorViewHolder.kt | 39 ---------- .../viewholders/SubscriptionBarViewHolder.kt | 38 ---------- .../main/res/layout/list_locked_thumbnail.xml | 17 +---- .../main/res/layout/list_playlist_feed.xml | 17 +---- app/src/main/res/layout/list_post_preview.xml | 18 +---- .../main/res/layout/list_post_thumbnail.xml | 19 +---- .../main/res/layout/list_video_thumbnail.xml | 17 +---- .../layout/list_video_thumbnail_nested.xml | 19 +---- 13 files changed, 13 insertions(+), 445 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt index b606bf26..c8d86b14 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -29,21 +29,12 @@ open class PlaylistView : LinearLayout { protected val _imageThumbnail: ImageView protected val _imageChannel: ImageView? protected val _creatorThumbnail: CreatorThumbnail? - protected val _imageNeopassChannel: ImageView?; protected val _platformIndicator: PlatformIndicator; protected val _textPlaylistName: TextView protected val _textVideoCount: TextView protected val _textVideoCountLabel: TextView; protected val _textPlaylistItems: TextView protected val _textChannelName: TextView - protected var _neopassAnimator: ObjectAnimator? = null; - - private val _taskLoadValidClaims = TaskHandler(StateApp.instance.scopeGetter, - { PolycentricCache.instance.getValidClaimsAsync(it).await() }) - .success { it -> updateClaimsLayout(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load claims.", it); - }; val onPlaylistClicked = Event1(); val onChannelClicked = Event1(); @@ -66,7 +57,6 @@ open class PlaylistView : LinearLayout { _textVideoCountLabel = findViewById(R.id.text_video_count_label); _textChannelName = findViewById(R.id.text_channel_name); _textPlaylistItems = findViewById(R.id.text_playlist_items); - _imageNeopassChannel = findViewById(R.id.image_neopass_channel); setOnClickListener { onOpenClicked() }; _imageChannel?.setOnClickListener { currentPlaylist?.let { onChannelClicked.emit(it.author) } }; @@ -88,20 +78,6 @@ open class PlaylistView : LinearLayout { open fun bind(content: IPlatformContent) { - _taskLoadValidClaims.cancel(); - - if (content.author.id.claimType > 0) { - val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id); - if (cachedClaims != null) { - updateClaimsLayout(cachedClaims, animate = false); - } else { - updateClaimsLayout(null, animate = false); - _taskLoadValidClaims.run(content.author.id); - } - } else { - updateClaimsLayout(null, animate = false); - } - isClickable = true; _imageChannel?.let { @@ -155,25 +131,6 @@ open class PlaylistView : LinearLayout { } } - private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) { - _neopassAnimator?.cancel(); - _neopassAnimator = null; - - val firstClaim = claims?.ownedClaims?.firstOrNull(); - val harborAvailable = firstClaim != null - if (harborAvailable) { - _imageNeopassChannel?.visibility = View.VISIBLE - if (animate) { - _neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) - _neopassAnimator?.start() - } - } else { - _imageNeopassChannel?.visibility = View.GONE - } - - _creatorThumbnail?.setHarborAvailable(harborAvailable, animate, firstClaim?.system?.toProto()) - } - companion object { private val TAG = "VideoPreviewViewHolder" } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt index 603f79d3..05a3f5e7 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt @@ -32,14 +32,6 @@ class SubscriptionViewHolder : ViewHolder { private val _platformIndicator : PlatformIndicator; private val _textMeta: TextView; - private val _taskLoadProfile = TaskHandler( - StateApp.instance.scopeGetter, - { PolycentricCache.instance.getProfileAsync(it) }) - .success { it -> onProfileLoaded(null, it, true) } - .exception { - Logger.w(TAG, "Failed to load profile.", it); - }; - var subscription: Subscription? = null private set; @@ -74,45 +66,12 @@ class SubscriptionViewHolder : ViewHolder { } fun bind(sub: Subscription) { - _taskLoadProfile.cancel(); - this.subscription = sub; _creatorThumbnail.setThumbnail(sub.channel.thumbnail, false); _textName.text = sub.channel.name; bindViewMetrics(sub); _platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId); - - val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true); - if (cachedProfile != null) { - onProfileLoaded(sub, cachedProfile, false); - if (cachedProfile.expired) { - _taskLoadProfile.run(sub.channel.id); - } - } else { - _taskLoadProfile.run(sub.channel.id); - } - } - - private fun onProfileLoaded(sub: Subscription?, cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - val dp_46 = 46.dp(itemView.context.resources); - val profile = cachedPolycentricProfile?.profile; - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(this.subscription?.channel?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); - } - - if (profile != null) { - _textName.text = profile.systemState.username; - } - - if(sub != null) - bindViewMetrics(sub) } fun bindViewMetrics(sub: Subscription?) { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt index 5a476421..cbf0b8a6 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt @@ -44,7 +44,6 @@ class PreviewPostView : LinearLayout { private val _imageAuthorThumbnail: ImageView; private val _textAuthorName: TextView; - private val _imageNeopassChannel: ImageView; private val _textMetadata: TextView; private val _textTitle: TextView; private val _textDescription: TextView; @@ -64,15 +63,6 @@ class PreviewPostView : LinearLayout { private val _layoutComments: LinearLayout?; private val _textComments: TextView?; - private var _neopassAnimator: ObjectAnimator? = null; - - private val _taskLoadValidClaims = TaskHandler(StateApp.instance.scopeGetter, - { PolycentricCache.instance.getValidClaimsAsync(it).await() }) - .success { it -> updateClaimsLayout(it, animate = true) } - .exception { - Logger.w(TAG, "Failed to load claims.", it); - }; - val content: IPlatformContent? get() = _content; val onContentClicked = Event1(); @@ -83,7 +73,6 @@ class PreviewPostView : LinearLayout { _imageAuthorThumbnail = findViewById(R.id.image_author_thumbnail); _textAuthorName = findViewById(R.id.text_author_name); - _imageNeopassChannel = findViewById(R.id.image_neopass_channel); _textMetadata = findViewById(R.id.text_metadata); _textTitle = findViewById(R.id.text_title); _textDescription = findViewById(R.id.text_description); @@ -130,21 +119,8 @@ class PreviewPostView : LinearLayout { } fun bind(content: IPlatformContent) { - _taskLoadValidClaims.cancel(); _content = content; - if (content.author.id.claimType > 0) { - val cachedClaims = PolycentricCache.instance.getCachedValidClaims(content.author.id); - if (cachedClaims != null) { - updateClaimsLayout(cachedClaims, animate = false); - } else { - updateClaimsLayout(null, animate = false); - _taskLoadValidClaims.run(content.author.id); - } - } else { - updateClaimsLayout(null, animate = false); - } - _textAuthorName.text = content.author.name; _textMetadata.text = content.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: ""; @@ -292,25 +268,6 @@ class PreviewPostView : LinearLayout { }; } - private fun updateClaimsLayout(claims: PolycentricCache.CachedOwnedClaims?, animate: Boolean) { - _neopassAnimator?.cancel(); - _neopassAnimator = null; - - val harborAvailable = claims != null && !claims.ownedClaims.isNullOrEmpty(); - if (harborAvailable) { - _imageNeopassChannel.visibility = View.VISIBLE - if (animate) { - _neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) - _neopassAnimator?.start() - } - } else { - _imageNeopassChannel.visibility = View.GONE - } - - //TODO: Necessary if we decide to use creator thumbnail with neopass indicator instead - //_creatorThumbnail?.setHarborAvailable(harborAvailable, animate) - } - companion object { val TAG = "PreviewPostView"; } 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 33066060..f2123832 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 @@ -47,7 +47,6 @@ open class PreviewVideoView : LinearLayout { protected val _imageVideo: ImageView protected val _imageChannel: ImageView? protected val _creatorThumbnail: CreatorThumbnail? - protected val _imageNeopassChannel: ImageView?; protected val _platformIndicator: PlatformIndicator; protected val _textVideoName: TextView protected val _textChannelName: TextView @@ -57,7 +56,6 @@ open class PreviewVideoView : LinearLayout { protected var _playerVideoThumbnail: FutoThumbnailPlayer? = null; protected val _containerLive: LinearLayout; protected val _playerContainer: FrameLayout; - protected var _neopassAnimator: ObjectAnimator? = null; protected val _layoutDownloaded: FrameLayout; protected val _button_add_to_queue : View; @@ -65,15 +63,6 @@ open class PreviewVideoView : LinearLayout { protected val _button_add_to : View; protected val _exoPlayer: PlayerManager?; - - private val _taskLoadProfile = TaskHandler( - StateApp.instance.scopeGetter, - { PolycentricCache.instance.getProfileAsync(it) }) - .success { it -> onProfileLoaded(it, true) } - .exception { - Logger.w(TAG, "Failed to load profile.", it); - }; - private val _timeBar: ProgressBar?; val onVideoClicked = Event2(); @@ -108,7 +97,6 @@ open class PreviewVideoView : LinearLayout { _button_add_to_queue = findViewById(R.id.button_add_to_queue); _button_add_to_watch_later = findViewById(R.id.button_add_to_watch_later); _button_add_to = findViewById(R.id.button_add_to); - _imageNeopassChannel = findViewById(R.id.image_neopass_channel); _layoutDownloaded = findViewById(R.id.layout_downloaded); _timeBar = findViewById(R.id.time_bar) @@ -160,15 +148,12 @@ open class PreviewVideoView : LinearLayout { open fun bind(content: IPlatformContent) { - _taskLoadProfile.cancel(); - isClickable = true; val isPlanned = (content.datetime?.getNowDiffSeconds() ?: 0) < 0; stopPreview(); - _imageNeopassChannel?.visibility = View.GONE; _creatorThumbnail?.setThumbnail(content.author.thumbnail, false); val thumbnail = content.author.thumbnail @@ -186,16 +171,6 @@ open class PreviewVideoView : LinearLayout { _textChannelName.text = content.author.name - val cachedProfile = PolycentricCache.instance.getCachedProfile(content.author.url, true); - if (cachedProfile != null) { - onProfileLoaded(cachedProfile, false); - if (cachedProfile.expired) { - _taskLoadProfile.run(content.author.id); - } - } else { - _taskLoadProfile.run(content.author.id); - } - _imageChannel?.clipToOutline = true; _textVideoName.text = content.name; @@ -335,52 +310,6 @@ open class PreviewVideoView : LinearLayout { _playerVideoThumbnail?.setMuteChangedListener(callback); } - private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - _neopassAnimator?.cancel(); - _neopassAnimator = null; - - val profile = cachedPolycentricProfile?.profile; - if (_creatorThumbnail != null) { - val dp_32 = 32.dp(context.resources); - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_32 * dp_32) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(content?.author?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); - } - } else if (_imageChannel != null) { - val dp_28 = 28.dp(context.resources); - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_28 * dp_28) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _imageChannel.let { - Glide.with(_imageChannel) - .load(avatar) - .placeholder(R.drawable.placeholder_channel_thumbnail) - .into(_imageChannel); - } - - _imageNeopassChannel?.visibility = View.VISIBLE - if (animate) { - _neopassAnimator = ObjectAnimator.ofFloat(_imageNeopassChannel, "alpha", 0.0f, 1.0f).setDuration(500) - _neopassAnimator?.start() - } else { - _imageNeopassChannel?.alpha = 1.0f; - } - } else { - _imageNeopassChannel?.visibility = View.GONE - } - } - - if (profile != null) { - _textChannelName.text = profile.systemState.username - } - } - companion object { private val TAG = "VideoPreviewViewHolder" } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt index 14322506..897718bf 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt @@ -27,14 +27,6 @@ class CreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyVi val onClick = Event1(); - private val _taskLoadProfile = TaskHandler( - StateApp.instance.scopeGetter, - { PolycentricCache.instance.getProfileAsync(it) }) - .success { onProfileLoaded(it, true) } - .exception { - Logger.w(TAG, "Failed to load profile.", it); - }; - init { _creatorThumbnail = _view.findViewById(R.id.creator_thumbnail); _name = _view.findViewById(R.id.text_channel_name); @@ -45,40 +37,10 @@ class CreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyVi } override fun bind(value: IPlatformChannel) { - _taskLoadProfile.cancel(); - _channel = value; _creatorThumbnail.setThumbnail(value.thumbnail, false); _name.text = value.name; - - val cachedProfile = PolycentricCache.instance.getCachedProfile(value.url, true); - if (cachedProfile != null) { - onProfileLoaded(cachedProfile, false); - if (cachedProfile.expired) { - _taskLoadProfile.run(value.id); - } - } else { - _taskLoadProfile.run(value.id); - } - } - - private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - val dp_55 = 55.dp(itemView.context.resources) - val profile = cachedPolycentricProfile?.profile; - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(_channel?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); - } - - if (profile != null) { - _name.text = profile.systemState.username; - } } companion object { @@ -94,14 +56,6 @@ class SelectableCreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda val onClick = Event1(); - private val _taskLoadProfile = TaskHandler( - StateApp.instance.scopeGetter, - { PolycentricCache.instance.getProfileAsync(it) }) - .success { onProfileLoaded(it, true) } - .exception { - Logger.w(TAG, "Failed to load profile.", it); - }; - init { _creatorThumbnail = _view.findViewById(R.id.creator_thumbnail); _name = _view.findViewById(R.id.text_channel_name); @@ -112,8 +66,6 @@ class SelectableCreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda } override fun bind(value: Selectable) { - _taskLoadProfile.cancel(); - _channel = value; if(value.active) @@ -123,34 +75,6 @@ class SelectableCreatorBarViewHolder(private val _viewGroup: ViewGroup) : AnyAda _creatorThumbnail.setThumbnail(value.channel.thumbnail, false); _name.text = value.channel.name; - - val cachedProfile = PolycentricCache.instance.getCachedProfile(value.channel.url, true); - if (cachedProfile != null) { - onProfileLoaded(cachedProfile, false); - if (cachedProfile.expired) { - _taskLoadProfile.run(value.channel.id); - } - } else { - _taskLoadProfile.run(value.channel.id); - } - } - - private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - val dp_55 = 55.dp(itemView.context.resources) - val profile = cachedPolycentricProfile?.profile; - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(_channel?.channel?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); - } - - if (profile != null) { - _name.text = profile.systemState.username; - } } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt index 5c57ffa9..b5784d4c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt @@ -34,14 +34,6 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo val onClick = Event1(); - private val _taskLoadProfile = TaskHandler( - StateApp.instance.scopeGetter, - { PolycentricCache.instance.getProfileAsync(it) }) - .success { it -> onProfileLoaded(it, true) } - .exception { - Logger.w(TAG, "Failed to load profile.", it); - }; - init { _textName = _view.findViewById(R.id.text_channel_name); _creatorThumbnail = _view.findViewById(R.id.creator_thumbnail); @@ -61,21 +53,9 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo } override fun bind(value: PlatformAuthorLink) { - _taskLoadProfile.cancel(); - _creatorThumbnail.setThumbnail(value.thumbnail, false); _textName.text = value.name; - val cachedProfile = PolycentricCache.instance.getCachedProfile(value.url, true); - if (cachedProfile != null) { - onProfileLoaded(cachedProfile, false); - if (cachedProfile.expired) { - _taskLoadProfile.run(value.id); - } - } else { - _taskLoadProfile.run(value.id); - } - if(value.subscribers == null || (value.subscribers ?: 0) <= 0L) _textMetadata.visibility = View.GONE; else { @@ -87,25 +67,6 @@ class CreatorViewHolder(private val _viewGroup: ViewGroup, private val _tiny: Bo _authorLink = value; } - private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - val dp_61 = 61.dp(itemView.context.resources); - - val profile = cachedPolycentricProfile?.profile; - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_61 * dp_61) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(_authorLink?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); - } - - if (profile != null) { - _textName.text = profile.systemState.username; - } - } - companion object { private const val TAG = "CreatorViewHolder"; } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt index 2b6350c4..da491cf6 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt @@ -27,14 +27,6 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. private var _subscription: Subscription? = null; private var _channel: SerializedChannel? = null; - private val _taskLoadProfile = TaskHandler( - StateApp.instance.scopeGetter, - { PolycentricCache.instance.getProfileAsync(it) }) - .success { onProfileLoaded(it, true) } - .exception { - Logger.w(TAG, "Failed to load profile.", it); - }; - val onClick = Event1(); init { @@ -47,44 +39,14 @@ class SubscriptionBarViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter. } override fun bind(value: Subscription) { - _taskLoadProfile.cancel(); - _channel = value.channel; _creatorThumbnail.setThumbnail(value.channel.thumbnail, false); _name.text = value.channel.name; - val cachedProfile = PolycentricCache.instance.getCachedProfile(value.channel.url, true); - if (cachedProfile != null) { - onProfileLoaded(cachedProfile, false); - if (cachedProfile.expired) { - _taskLoadProfile.run(value.channel.id); - } - } else { - _taskLoadProfile.run(value.channel.id); - } - _subscription = value; } - private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - val dp_55 = 55.dp(itemView.context.resources) - val profile = cachedPolycentricProfile?.profile; - val avatar = profile?.systemState?.avatar?.selectBestImage(dp_55 * dp_55) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; - - if (avatar != null) { - _creatorThumbnail.setThumbnail(avatar, animate); - } else { - _creatorThumbnail.setThumbnail(_channel?.thumbnail, animate); - _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); - } - - if (profile != null) { - _name.text = profile.systemState.username; - } - } - companion object { private const val TAG = "SubscriptionBarViewHolder"; } diff --git a/app/src/main/res/layout/list_locked_thumbnail.xml b/app/src/main/res/layout/list_locked_thumbnail.xml index 462d65d4..f00e28f3 100644 --- a/app/src/main/res/layout/list_locked_thumbnail.xml +++ b/app/src/main/res/layout/list_locked_thumbnail.xml @@ -254,7 +254,7 @@ - - - - + app:layout_constraintTop_toBottomOf="@id/text_playlist_name" /> - - - - - - - - - - Date: Wed, 5 Mar 2025 17:04:48 +0100 Subject: [PATCH 027/128] Implemented new ApiMethods calls. --- .../platformplayer/Extensions_Polycentric.kt | 40 +- .../PolycentricCreateProfileActivity.kt | 6 +- .../PolycentricImportProfileActivity.kt | 4 +- .../activities/PolycentricProfileActivity.kt | 6 +- .../platformplayer/dialogs/CommentDialog.kt | 2 +- .../channel/tab/ChannelAboutFragment.kt | 6 +- .../channel/tab/ChannelContentsFragment.kt | 2 +- .../channel/tab/ChannelListFragment.kt | 2 +- .../tab/ChannelMonetizationFragment.kt | 2 +- .../channel/tab/IChannelTabFragment.kt | 2 +- .../mainactivity/main/ChannelFragment.kt | 54 +-- .../mainactivity/main/CommentsFragment.kt | 2 +- .../mainactivity/main/PostDetailFragment.kt | 30 +- .../mainactivity/main/VideoDetailView.kt | 62 +-- .../topbar/NavigationTopBarFragment.kt | 2 +- .../images/PolycentricModelLoader.java | 17 +- .../polycentric/PolycentricCache.kt | 353 ------------------ .../futo/platformplayer/states/StateCache.kt | 10 +- .../futo/platformplayer/states/StateMeta.kt | 2 +- .../platformplayer/states/StatePolycentric.kt | 56 +-- .../states/StateSubscriptionGroups.kt | 41 +- .../states/StateSubscriptions.kt | 7 - .../stores/CachedPolycentricProfileStorage.kt | 31 -- .../platformplayer/views/MonetizationView.kt | 5 +- .../futo/platformplayer/views/SupportView.kt | 2 +- .../views/adapters/ChannelViewPagerAdapter.kt | 2 +- .../views/adapters/CommentViewHolder.kt | 19 +- .../CommentWithReferenceViewHolder.kt | 2 +- .../views/adapters/PlaylistView.kt | 1 - .../views/adapters/SubscriptionViewHolder.kt | 1 - .../adapters/feedtypes/PreviewPostView.kt | 1 - .../adapters/feedtypes/PreviewVideoView.kt | 1 - .../viewholders/CreatorBarViewHolder.kt | 1 - .../adapters/viewholders/CreatorViewHolder.kt | 1 - .../viewholders/SubscriptionBarViewHolder.kt | 1 - .../SubscriptionGroupListViewHolder.kt | 1 - .../views/others/CreatorThumbnail.kt | 4 +- .../views/overlays/SupportOverlay.kt | 2 +- .../views/overlays/WebviewOverlay.kt | 2 - .../views/segments/CommentsList.kt | 2 +- dep/polycentricandroid | 2 +- 41 files changed, 124 insertions(+), 665 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/stores/CachedPolycentricProfileStorage.kt diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt index 74f1372e..139407ed 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Polycentric.kt @@ -1,13 +1,13 @@ package com.futo.platformplayer import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.states.AnnouncementType import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.states.StatePlatform import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.Store import com.futo.polycentric.core.SystemState +import com.futo.polycentric.core.base64UrlToByteArray import userpackage.Protocol import kotlin.math.abs import kotlin.math.min @@ -40,33 +40,25 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) } } +fun String.getDataLinkFromUrl(): Protocol.URLInfoDataLink? { + val urlData = if (this.startsWith("polycentric://")) { + this.substring("polycentric://".length) + } else this; + + val urlBytes = urlData.base64UrlToByteArray(); + val urlInfo = Protocol.URLInfo.parseFrom(urlBytes); + if (urlInfo.urlType != 4L) { + return null + } + + val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body); + return dataLink +} + fun Protocol.Claim.resolveChannelUrl(): String? { return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) } fun Protocol.Claim.resolveChannelUrls(): List { return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) -} - -suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() { - val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system)) - if (!systemState.servers.contains(PolycentricCache.SERVER)) { - Logger.w("Backfill", "Polycentric prod server not added, adding it.") - addServer(PolycentricCache.SERVER) - } - - val exceptions = fullyBackfillServers() - for (pair in exceptions) { - val server = pair.key - val exception = pair.value - - StateAnnouncement.instance.registerAnnouncement( - "backfill-failed", - "Backfill failed", - "Failed to backfill server $server. $exception", - AnnouncementType.SESSION_RECURRING - ); - - Logger.e("Backfill", "Failed to backfill server $server.", exception) - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt index d5fce50b..f7432c05 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricCreateProfileActivity.kt @@ -11,16 +11,16 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.LoaderView +import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.Store +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -87,7 +87,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() { Logger.e(TAG, "Failed to save process secret to secret storage.", e) } - processHandle.addServer(PolycentricCache.SERVER); + processHandle.addServer(ApiMethods.SERVER); processHandle.setUsername(username); StatePolycentric.instance.setProcessHandle(processHandle); } catch (e: Throwable) { diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt index 825463b3..ab6d70a3 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricImportProfileActivity.kt @@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.overlays.LoaderOverlay +import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.KeyPair import com.futo.polycentric.core.Process import com.futo.polycentric.core.ProcessSecret @@ -145,7 +145,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() { } StatePolycentric.instance.setProcessHandle(processHandle); - processHandle.fullyBackfillClient(PolycentricCache.SERVER); + processHandle.fullyBackfillClient(ApiMethods.SERVER); withContext(Dispatchers.Main) { startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java)); finish(); diff --git a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt index e296a118..3493363e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/PolycentricProfileActivity.kt @@ -21,10 +21,8 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.dp -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.setNavigationBarColorAndIcons @@ -32,8 +30,10 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.overlays.LoaderOverlay +import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.Store import com.futo.polycentric.core.SystemState +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl import com.futo.polycentric.core.toBase64Url import com.futo.polycentric.core.toURLInfoSystemLinkUrl @@ -145,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - processHandle.fullyBackfillClient(PolycentricCache.SERVER) + processHandle.fullyBackfillClient(ApiMethods.SERVER) withContext(Dispatchers.Main) { updateUI(); diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt index 9d0282f0..794c8537 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/CommentDialog.kt @@ -22,7 +22,6 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.dp -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp @@ -30,6 +29,7 @@ import com.futo.platformplayer.states.StatePolycentric import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.Store import com.futo.polycentric.core.SystemState +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.android.material.button.MaterialButton diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt index 8a412f75..777443c5 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelAboutFragment.kt @@ -13,7 +13,6 @@ import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.dp import com.futo.platformplayer.fixHtmlLinks -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.selectBestImage @@ -21,6 +20,7 @@ import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.views.platform.PlatformLinkView +import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.toName import com.futo.polycentric.core.toURLInfoSystemLinkUrl @@ -134,9 +134,7 @@ class ChannelAboutFragment : Fragment, IChannelTabFragment { } } if(!map.containsKey("Harbor")) - this.context?.let { - map.set("Harbor", polycentricProfile.getHarborUrl(it)); - } + map.set("Harbor", polycentricProfile.getHarborUrl()); if (map.isNotEmpty()) setLinks(map, if (polycentricProfile.systemState.username.isNotBlank()) polycentricProfile.systemState.username else _lastChannel?.name ?: "") 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 a7e313e3..b26c9b35 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 @@ -29,7 +29,6 @@ import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.fragment.mainactivity.main.FeedView -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform @@ -39,6 +38,7 @@ import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter +import com.futo.polycentric.core.PolycentricProfile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.math.max diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt index 807fbd90..dc32acaa 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelListFragment.kt @@ -16,12 +16,12 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.viewholders.CreatorViewHolder +import com.futo.polycentric.core.PolycentricProfile class ChannelListFragment : Fragment, IChannelTabFragment { private var _channels: ArrayList = arrayListOf(); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt index 53268d16..fd401042 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/ChannelMonetizationFragment.kt @@ -8,8 +8,8 @@ import android.widget.TextView import androidx.fragment.app.Fragment import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.views.SupportView +import com.futo.polycentric.core.PolycentricProfile class ChannelMonetizationFragment : Fragment, IChannelTabFragment { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt index 2b615d25..3821b34c 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/channel/tab/IChannelTabFragment.kt @@ -1,7 +1,7 @@ package com.futo.platformplayer.fragment.channel.tab import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.polycentric.core.PolycentricProfile interface IChannelTabFragment { fun setChannel(channel: IPlatformChannel) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 817a8ca2..6be65482 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -42,7 +42,6 @@ import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.states.StatePlatform @@ -55,29 +54,14 @@ import com.futo.platformplayer.views.adapters.ChannelViewPagerAdapter import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.futo.platformplayer.views.subscriptions.SubscribeButton -import com.futo.polycentric.core.OwnedClaim -import com.futo.polycentric.core.PublicKey -import com.futo.polycentric.core.Store -import com.futo.polycentric.core.SystemState -import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable - -@Serializable -data class PolycentricProfile( - val system: PublicKey, val systemState: SystemState, val ownedClaims: List -) { - fun getHarborUrl(context: Context): String{ - val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system)); - val url = system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable()); - return "https://harbor.social/" + url.substring("polycentric://".length); - } -} class ChannelFragment : MainFragment() { override val isMainView: Boolean = true @@ -144,15 +128,14 @@ class ChannelFragment : MainFragment() { private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {} - private val _taskLoadPolycentricProfile: TaskHandler + private val _taskLoadPolycentricProfile: TaskHandler private val _taskGetChannel: TaskHandler init { inflater.inflate(R.layout.fragment_channel, this) - _taskLoadPolycentricProfile = - TaskHandler({ fragment.lifecycleScope }, + _taskLoadPolycentricProfile = TaskHandler({ fragment.lifecycleScope }, { id -> - return@TaskHandler PolycentricCache.instance.getProfileAsync(id) + return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!) }).success { setPolycentricProfile(it, animate = true) }.exception { Logger.w(TAG, "Failed to load polycentric profile.", it) } @@ -328,7 +311,7 @@ class ChannelFragment : MainFragment() { _creatorThumbnail.setThumbnail(parameter.thumbnail, true) Glide.with(_imageBanner).clear(_imageBanner) - loadPolycentricProfile(parameter.id, parameter.url) + loadPolycentricProfile(parameter.id) } _url = parameter.url @@ -342,7 +325,7 @@ class ChannelFragment : MainFragment() { _creatorThumbnail.setThumbnail(parameter.channel.thumbnail, true) Glide.with(_imageBanner).clear(_imageBanner) - loadPolycentricProfile(parameter.channel.id, parameter.channel.url) + loadPolycentricProfile(parameter.channel.id) } _url = parameter.channel.url @@ -359,16 +342,8 @@ class ChannelFragment : MainFragment() { _tabs.selectTab(_tabs.getTabAt(selectedTabIndex)) } - private fun loadPolycentricProfile(id: PlatformID, url: String) { - val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(url, true) - if (cachedPolycentricProfile != null) { - setPolycentricProfile(cachedPolycentricProfile, animate = true) - if (cachedPolycentricProfile.expired) { - _taskLoadPolycentricProfile.run(id) - } - } else { - _taskLoadPolycentricProfile.run(id) - } + private fun loadPolycentricProfile(id: PlatformID) { + _taskLoadPolycentricProfile.run(id) } private fun setLoading(isLoading: Boolean) { @@ -533,20 +508,13 @@ class ChannelFragment : MainFragment() { private fun setPolycentricProfileOr(url: String, or: () -> Unit) { setPolycentricProfile(null, animate = false) - - val cachedProfile = channel?.let { PolycentricCache.instance.getCachedProfile(url) } - if (cachedProfile != null) { - setPolycentricProfile(cachedProfile, animate = false) - } else { - or() - } + or() } private fun setPolycentricProfile( - cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean + profile: PolycentricProfile?, animate: Boolean ) { val dp35 = 35.dp(resources) - val profile = cachedPolycentricProfile?.profile val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)?.let { it.toURLInfoSystemLinkUrl( profile.system.toProto(), it.process, profile.systemState.servers.toList() diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt index dce725f6..0dae227d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/CommentsFragment.kt @@ -23,7 +23,6 @@ import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.constructs.TaskHandler -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform @@ -32,6 +31,7 @@ import com.futo.platformplayer.views.adapters.CommentWithReferenceViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.polycentric.core.PublicKey +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.net.UnknownHostException diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index 86555b11..d4dc1672 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -33,10 +33,8 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.fixHtmlWhitespace -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform @@ -47,7 +45,6 @@ import com.futo.platformplayer.views.adapters.ChannelTab import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.others.CreatorThumbnail -import com.futo.platformplayer.views.others.Toggle import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.platform.PlatformIndicator @@ -57,6 +54,8 @@ import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import com.google.android.flexbox.FlexboxLayout import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.shape.CornerFamily @@ -112,7 +111,7 @@ class PostDetailFragment : MainFragment { private var _isLoading = false; private var _post: IPlatformPostDetails? = null; private var _postOverview: IPlatformPost? = null; - private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null; + private var _polycentricProfile: PolycentricProfile? = null; private var _version = 0; private var _isRepliesVisible: Boolean = false; private var _repliesAnimator: ViewPropertyAnimator? = null; @@ -169,7 +168,7 @@ class PostDetailFragment : MainFragment { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); @@ -274,7 +273,7 @@ class PostDetailFragment : MainFragment { }; _buttonStore.setOnClickListener { - _polycentricProfile?.profile?.systemState?.store?.let { + _polycentricProfile?.systemState?.store?.let { try { val uri = Uri.parse(it); val intent = Intent(Intent.ACTION_VIEW); @@ -334,7 +333,7 @@ class PostDetailFragment : MainFragment { } try { - val queryReferencesResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, ref, null,null, + val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null, arrayListOf( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType( ContentType.OPINION.value).setValue( @@ -604,16 +603,8 @@ class PostDetailFragment : MainFragment { private fun fetchPolycentricProfile() { val author = _post?.author ?: _postOverview?.author ?: return; - val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(author.url, true); - if (cachedPolycentricProfile != null) { - setPolycentricProfile(cachedPolycentricProfile, animate = false); - if (cachedPolycentricProfile.expired) { - _taskLoadPolycentricProfile.run(author.id); - } - } else { setPolycentricProfile(null, animate = false); _taskLoadPolycentricProfile.run(author.id); - } } private fun setChannelMeta(value: IPlatformPost?) { @@ -639,17 +630,18 @@ class PostDetailFragment : MainFragment { _repliesOverlay.cleanup(); } - private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - _polycentricProfile = cachedPolycentricProfile; + private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { + _polycentricProfile = polycentricProfile; - if (cachedPolycentricProfile?.profile == null) { + val pp = _polycentricProfile; + if (pp == null) { _layoutMonetization.visibility = View.GONE; _creatorThumbnail.setHarborAvailable(false, animate, null); return; } _layoutMonetization.visibility = View.VISIBLE; - _creatorThumbnail.setHarborAvailable(true, animate, cachedPolycentricProfile.profile.system.toProto()); + _creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto()); } private fun fetchPost() { 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 afda7722..8d930838 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 @@ -94,12 +94,10 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.fixHtmlLinks import com.futo.platformplayer.fixHtmlWhitespace -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.AnnouncementType @@ -158,6 +156,8 @@ import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.protobuf.ByteString import kotlinx.coroutines.Dispatchers @@ -294,7 +294,7 @@ class VideoDetailView : ConstraintLayout { private set; private var _historicalPosition: Long = 0; private var _commentsCount = 0; - private var _polycentricProfile: PolycentricCache.CachedPolycentricProfile? = null; + private var _polycentricProfile: PolycentricProfile? = null; private var _slideUpOverlay: SlideUpMenuOverlay? = null; private var _autoplayVideo: IPlatformVideo? = null @@ -409,12 +409,12 @@ class VideoDetailView : ConstraintLayout { }; _monetization.onSupportTap.subscribe { - _container_content_support.setPolycentricProfile(_polycentricProfile?.profile); + _container_content_support.setPolycentricProfile(_polycentricProfile); switchContentView(_container_content_support); }; _monetization.onStoreTap.subscribe { - _polycentricProfile?.profile?.systemState?.store?.let { + _polycentricProfile?.systemState?.store?.let { try { val uri = Uri.parse(it); val intent = Intent(Intent.ACTION_VIEW); @@ -1236,16 +1236,8 @@ class VideoDetailView : ConstraintLayout { _creatorThumbnail.setThumbnail(video.author.thumbnail, false); _channelName.text = video.author.name; - val cachedPolycentricProfile = PolycentricCache.instance.getCachedProfile(video.author.url, true); - if (cachedPolycentricProfile != null) { - setPolycentricProfile(cachedPolycentricProfile, animate = false); - if (cachedPolycentricProfile.expired) { - _taskLoadPolycentricProfile.run(video.author.id); - } - } else { - setPolycentricProfile(null, animate = false); - _taskLoadPolycentricProfile.run(video.author.id); - } + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(video.author.id); _player.clear(); @@ -1405,11 +1397,8 @@ class VideoDetailView : ConstraintLayout { setTabIndex(2, true) } else { when (Settings.instance.comments.defaultCommentSection) { - 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex( - 0, - true - ) else setTabIndex(1, true); - 1 -> setTabIndex(1, true); + 0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true) + 1 -> setTabIndex(1, true) 2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true) } } @@ -1447,16 +1436,8 @@ class VideoDetailView : ConstraintLayout { _buttonSubscribe.setSubscribeChannel(video.author.url); setDescription(video.description.fixHtmlLinks()); _creatorThumbnail.setThumbnail(video.author.thumbnail, false); - - - val cachedPolycentricProfile = - PolycentricCache.instance.getCachedProfile(video.author.url, true); - if (cachedPolycentricProfile != null) { - setPolycentricProfile(cachedPolycentricProfile, animate = false); - } else { - setPolycentricProfile(null, animate = false); - _taskLoadPolycentricProfile.run(video.author.id); - } + setPolycentricProfile(null, animate = false); + _taskLoadPolycentricProfile.run(video.author.id); _platform.setPlatformFromClientID(video.id.pluginId); val subTitleSegments: ArrayList = ArrayList(); @@ -1485,7 +1466,7 @@ class VideoDetailView : ConstraintLayout { fragment.lifecycleScope.launch(Dispatchers.IO) { try { val queryReferencesResponse = ApiMethods.getQueryReferences( - PolycentricCache.SERVER, ref, null, null, + ApiMethods.SERVER, ref, null, null, arrayListOf( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() .setFromType(ContentType.OPINION.value).setValue( @@ -1501,10 +1482,8 @@ class VideoDetailView : ConstraintLayout { val likes = queryReferencesResponse.countsList[0]; val dislikes = queryReferencesResponse.countsList[1]; - val hasLiked = - StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; - val hasDisliked = - StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; withContext(Dispatchers.Main) { _rating.visibility = View.VISIBLE; @@ -2805,13 +2784,12 @@ class VideoDetailView : ConstraintLayout { } } - private fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) { - _polycentricProfile = cachedPolycentricProfile; + private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) { + _polycentricProfile = profile val dp_35 = 35.dp(context.resources) - val profile = cachedPolycentricProfile?.profile; val avatar = profile?.systemState?.avatar?.selectBestImage(dp_35 * dp_35) - ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }; + ?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) } if (avatar != null) { _creatorThumbnail.setThumbnail(avatar, animate); @@ -2820,12 +2798,12 @@ class VideoDetailView : ConstraintLayout { _creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto()); } - val username = cachedPolycentricProfile?.profile?.systemState?.username + val username = profile?.systemState?.username if (username != null) { _channelName.text = username } - _monetization.setPolycentricProfile(cachedPolycentricProfile); + _monetization.setPolycentricProfile(profile); } fun setProgressBarOverlayed(isOverlayed: Boolean?) { @@ -3013,7 +2991,7 @@ class VideoDetailView : ConstraintLayout { Logger.w(TAG, "Failed to load recommendations.", it); }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { PolycentricCache.instance.getProfileAsync(it) }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt index 4348edac..3309c851 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/topbar/NavigationTopBarFragment.kt @@ -14,9 +14,9 @@ import com.futo.platformplayer.R import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.views.casting.CastButton +import com.futo.polycentric.core.PolycentricProfile class NavigationTopBarFragment : TopFragment() { private var _buttonBack: ImageButton? = null; diff --git a/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java b/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java index ded61ffc..c0155a33 100644 --- a/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java +++ b/app/src/main/java/com/futo/platformplayer/images/PolycentricModelLoader.java @@ -1,5 +1,7 @@ package com.futo.platformplayer.images; +import static com.futo.platformplayer.Extensions_PolycentricKt.getDataLinkFromUrl; + import android.util.Log; import androidx.annotation.NonNull; @@ -12,10 +14,14 @@ import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoaderFactory; import com.bumptech.glide.load.model.MultiModelLoaderFactory; import com.bumptech.glide.signature.ObjectKey; -import com.futo.platformplayer.polycentric.PolycentricCache; +import com.futo.polycentric.core.ApiMethods; import kotlin.Unit; +import kotlinx.coroutines.CoroutineScopeKt; import kotlinx.coroutines.Deferred; +import kotlinx.coroutines.Dispatchers; +import userpackage.Protocol; + import java.lang.Exception; import java.nio.ByteBuffer; import java.util.concurrent.CancellationException; @@ -60,7 +66,14 @@ public class PolycentricModelLoader implements ModelLoader { @Override public void loadData(@NonNull Priority priority, @NonNull DataFetcher.DataCallback callback) { Log.i("PolycentricModelLoader", this._model); - _deferred = PolycentricCache.getInstance().getDataAsync(_model); + + Protocol.URLInfoDataLink dataLink = getDataLinkFromUrl(_model); + if (dataLink == null) { + callback.onLoadFailed(new Exception("Data link cannot be null")); + return; + } + + _deferred = ApiMethods.Companion.getDataFromServerAndReassemble(CoroutineScopeKt.CoroutineScope(Dispatchers.getIO()), dataLink); _deferred.invokeOnCompletion(throwable -> { if (throwable != null) { Log.e("PolycentricModelLoader", "getDataAsync failed throwable: " + throwable.toString()); diff --git a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt b/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt deleted file mode 100644 index abe4ca8e..00000000 --- a/app/src/main/java/com/futo/platformplayer/polycentric/PolycentricCache.kt +++ /dev/null @@ -1,353 +0,0 @@ -package com.futo.platformplayer.polycentric - -import com.futo.platformplayer.api.media.PlatformID -import com.futo.platformplayer.constructs.BatchedTaskHandler -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile -import com.futo.platformplayer.getNowDiffSeconds -import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.resolveChannelUrls -import com.futo.platformplayer.serializers.OffsetDateTimeSerializer -import com.futo.platformplayer.states.StatePolycentric -import com.futo.platformplayer.stores.CachedPolycentricProfileStorage -import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.polycentric.core.ApiMethods -import com.futo.polycentric.core.ContentType -import com.futo.polycentric.core.OwnedClaim -import com.futo.polycentric.core.PublicKey -import com.futo.polycentric.core.SignedEvent -import com.futo.polycentric.core.StorageTypeSystemState -import com.futo.polycentric.core.SystemState -import com.futo.polycentric.core.base64ToByteArray -import com.futo.polycentric.core.base64UrlToByteArray -import com.futo.polycentric.core.getClaimIfValid -import com.futo.polycentric.core.getValidClaims -import com.google.protobuf.ByteString -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.serialization.Serializable -import userpackage.Protocol -import java.nio.ByteBuffer -import java.time.OffsetDateTime -import kotlin.system.measureTimeMillis - -class PolycentricCache { - data class CachedOwnedClaims(val ownedClaims: List?, val creationTime: OffsetDateTime = OffsetDateTime.now()) { - val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS - } - @Serializable - data class CachedPolycentricProfile(val profile: PolycentricProfile?, @Serializable(with = OffsetDateTimeSerializer::class) val creationTime: OffsetDateTime = OffsetDateTime.now()) { - val expired get() = creationTime.getNowDiffSeconds() > CACHE_EXPIRATION_SECONDS - } - - private val _cache = hashMapOf() - private val _profileCache = hashMapOf() - private val _profileUrlCache: CachedPolycentricProfileStorage; - private val _scope = CoroutineScope(Dispatchers.IO); - init { - Logger.i(TAG, "Initializing Polycentric cache"); - val time = measureTimeMillis { - _profileUrlCache = FragmentedStorage.get("profileUrlCache") - } - Logger.i(TAG, "Initialized Polycentric cache (${_profileUrlCache.map.size}, ${time}ms)"); - } - - private val _taskGetProfile = BatchedTaskHandler(_scope, - { system -> - val signedEventsList = ApiMethods.getQueryLatest( - SERVER, - system.toProto(), - listOf( - ContentType.BANNER.value, - ContentType.AVATAR.value, - ContentType.USERNAME.value, - ContentType.DESCRIPTION.value, - ContentType.STORE.value, - ContentType.SERVER.value, - ContentType.STORE_DATA.value, - ContentType.PROMOTION_BANNER.value, - ContentType.PROMOTION.value, - ContentType.MEMBERSHIP_URLS.value, - ContentType.DONATION_DESTINATIONS.value - ) - ).eventsList.map { e -> SignedEvent.fromProto(e) }; - - val signedProfileEvents = signedEventsList.groupBy { e -> e.event.contentType } - .map { (_, events) -> events.maxBy { it.event.unixMilliseconds ?: 0 } }; - - val storageSystemState = StorageTypeSystemState.create() - for (signedEvent in signedProfileEvents) { - storageSystemState.update(signedEvent.event) - } - - val signedClaimEvents = ApiMethods.getQueryIndex( - SERVER, - system.toProto(), - ContentType.CLAIM.value, - limit = 200 - ).eventsList.map { e -> SignedEvent.fromProto(e) }; - - val ownedClaims: ArrayList = arrayListOf() - for (signedEvent in signedClaimEvents) { - if (signedEvent.event.contentType != ContentType.CLAIM.value) { - continue; - } - - val response = ApiMethods.getQueryReferences( - SERVER, - Protocol.Reference.newBuilder() - .setReference(signedEvent.toPointer().toProto().toByteString()) - .setReferenceType(2) - .build(), - null, - Protocol.QueryReferencesRequestEvents.newBuilder() - .setFromType(ContentType.VOUCH.value) - .build() - ); - - val ownedClaim = response.itemsList.map { SignedEvent.fromProto(it.event) }.getClaimIfValid(signedEvent); - if (ownedClaim != null) { - ownedClaims.add(ownedClaim); - } - } - - Logger.i(TAG, "Retrieved profile (ownedClaims = $ownedClaims)"); - val systemState = SystemState.fromStorageTypeSystemState(storageSystemState); - return@BatchedTaskHandler CachedPolycentricProfile(PolycentricProfile(system, systemState, ownedClaims)); - }, - { system -> return@BatchedTaskHandler getCachedProfile(system); }, - { system, result -> - synchronized(_cache) { - _profileCache[system] = result; - - if (result.profile != null) { - for (claim in result.profile.ownedClaims) { - val urls = claim.claim.resolveChannelUrls(); - for (url in urls) - _profileUrlCache.map[url] = result; - } - } - - _profileUrlCache.save(); - } - }); - - private val _batchTaskGetClaims = BatchedTaskHandler(_scope, - { id -> - val resolved = if (id.claimFieldType == -1) ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.value!!) - else ApiMethods.getResolveClaim(SERVER, system, id.claimType.toLong(), id.claimFieldType.toLong(), id.value!!); - Logger.v(TAG, "getResolveClaim(url = $SERVER, system = $system, id = $id, claimType = ${id.claimType}, matchAnyField = ${id.value})"); - val protoEvents = resolved.matchesList.flatMap { arrayListOf(it.claim).apply { addAll(it.proofChainList) } } - val resolvedEvents = protoEvents.map { i -> SignedEvent.fromProto(i) }; - return@BatchedTaskHandler CachedOwnedClaims(resolvedEvents.getValidClaims()); - }, - { id -> return@BatchedTaskHandler getCachedValidClaims(id); }, - { id, result -> - synchronized(_cache) { - _cache[id] = result; - } - }); - - private val _batchTaskGetData = BatchedTaskHandler(_scope, - { - val dataLink = getDataLinkFromUrl(it) ?: throw Exception("Only URLInfoDataLink is supported"); - return@BatchedTaskHandler ApiMethods.getDataFromServerAndReassemble(dataLink); - }, - { return@BatchedTaskHandler null }, - { _, _ -> }); - - fun getCachedValidClaims(id: PlatformID, ignoreExpired: Boolean = false): CachedOwnedClaims? { - if (!StatePolycentric.instance.enabled || id.claimType <= 0) { - return CachedOwnedClaims(null); - } - - synchronized(_cache) { - val cached = _cache[id] - if (cached == null) { - return null - } - - if (!ignoreExpired && cached.expired) { - return null; - } - - return cached; - } - } - - //TODO: Review all return null in this file, perhaps it should be CachedX(null) instead - fun getValidClaimsAsync(id: PlatformID): Deferred { - if (!StatePolycentric.instance.enabled || id.value == null || id.claimType <= 0) { - return _scope.async { CachedOwnedClaims(null) }; - } - - Logger.v(TAG, "getValidClaims (id: $id)") - val def = _batchTaskGetClaims.execute(id); - def.invokeOnCompletion { - if (it == null) { - return@invokeOnCompletion - } - - handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = { - //Cache failed result - synchronized(_cache) { - _cache[id] = CachedOwnedClaims(null); - } - }) - }; - return def; - } - - fun getDataAsync(url: String): Deferred { - StatePolycentric.instance.ensureEnabled() - return _batchTaskGetData.execute(url); - } - - fun getCachedProfile(url: String, ignoreExpired: Boolean = false): CachedPolycentricProfile? { - if (!StatePolycentric.instance.enabled) { - return CachedPolycentricProfile(null) - } - - synchronized (_profileCache) { - val cached = _profileUrlCache.get(url) ?: return null; - if (!ignoreExpired && cached.expired) { - return null; - } - - return cached; - } - } - - fun getCachedProfile(system: PublicKey, ignoreExpired: Boolean = false): CachedPolycentricProfile? { - if (!StatePolycentric.instance.enabled) { - return CachedPolycentricProfile(null) - } - - synchronized(_profileCache) { - val cached = _profileCache[system] ?: return null; - if (!ignoreExpired && cached.expired) { - return null; - } - - return cached; - } - } - - suspend fun getProfileAsync(id: PlatformID, urlNullCache: String? = null): CachedPolycentricProfile? { - if (!StatePolycentric.instance.enabled || id.claimType <= 0) { - return CachedPolycentricProfile(null); - } - - val cachedClaims = getCachedValidClaims(id); - if (cachedClaims != null) { - if (!cachedClaims.ownedClaims.isNullOrEmpty()) { - Logger.v(TAG, "getProfileAsync (id: $id) != null (with cached valid claims)") - return getProfileAsync(cachedClaims.ownedClaims.first().system).await(); - } else { - return null; - } - } else { - Logger.v(TAG, "getProfileAsync (id: $id) no cached valid claims, will be retrieved") - - val claims = getValidClaimsAsync(id).await() - if (!claims.ownedClaims.isNullOrEmpty()) { - Logger.v(TAG, "getProfileAsync (id: $id) != null (with retrieved valid claims)") - return getProfileAsync(claims.ownedClaims.first().system).await() - } else { - synchronized (_cache) { - if (urlNullCache != null) { - _profileUrlCache.setAndSave(urlNullCache, CachedPolycentricProfile(null)) - } - } - return null; - } - } - } - - fun getProfileAsync(system: PublicKey): Deferred { - if (!StatePolycentric.instance.enabled) { - return _scope.async { CachedPolycentricProfile(null) }; - } - - Logger.i(TAG, "getProfileAsync (system: ${system})") - val def = _taskGetProfile.execute(system); - def.invokeOnCompletion { - if (it == null) { - return@invokeOnCompletion - } - - handleException(it, handleNetworkException = { /* Do nothing (do not cache) */ }, handleOtherException = { - //Cache failed result - synchronized(_cache) { - val cachedProfile = CachedPolycentricProfile(null); - _profileCache[system] = cachedProfile; - } - }) - }; - return def; - } - - private fun handleException(e: Throwable, handleNetworkException: () -> Unit, handleOtherException: () -> Unit) { - val isNetworkException = when(e) { - is java.net.UnknownHostException, - is java.net.SocketTimeoutException, - is java.net.ConnectException -> true - else -> when(e.cause) { - is java.net.UnknownHostException, - is java.net.SocketTimeoutException, - is java.net.ConnectException -> true - else -> false - } - } - if (isNetworkException) { - handleNetworkException() - } else { - handleOtherException() - } - } - - companion object { - private val system = Protocol.PublicKey.newBuilder() - .setKeyType(1) - .setKey(ByteString.copyFrom("gX0eCWctTm6WHVGot4sMAh7NDAIwWsIM5tRsOz9dX04=".base64ToByteArray())) //Production key - //.setKey(ByteString.copyFrom("LeQkzn1j625YZcZHayfCmTX+6ptrzsA+CdAyq+BcEdQ".base64ToByteArray())) //Test key koen-futo - .build(); - - private const val TAG = "PolycentricCache" - const val SERVER = "https://srv1-prod.polycentric.io" - private var _instance: PolycentricCache? = null; - private val CACHE_EXPIRATION_SECONDS = 60 * 5; - - @JvmStatic - val instance: PolycentricCache - get(){ - if(_instance == null) - _instance = PolycentricCache(); - return _instance!!; - }; - - fun finish() { - _instance?.let { - _instance = null; - it._scope.cancel("PolycentricCache finished"); - } - } - - fun getDataLinkFromUrl(it: String): Protocol.URLInfoDataLink? { - val urlData = if (it.startsWith("polycentric://")) { - it.substring("polycentric://".length) - } else it; - - val urlBytes = urlData.base64UrlToByteArray(); - val urlInfo = Protocol.URLInfo.parseFrom(urlBytes); - if (urlInfo.urlType != 4L) { - return null - } - - val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body); - return dataLink - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt index 7d91c9ce..8157ed2a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateCache.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateCache.kt @@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.structures.DedupContentPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.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 @@ -50,14 +49,7 @@ class StateCache { 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() + .map { it.channel.url } .distinct() .filter { StatePlatform.instance.hasEnabledChannelClient(it) }; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt b/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt index b20866bf..bd45b205 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateMeta.kt @@ -14,7 +14,7 @@ class StateMeta { return when(lastCommentSection.value){ "Polycentric" -> 0; "Platform" -> 1; - else -> 1 + else -> 0 } } fun setLastCommentSection(value: Int) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index e98aff0c..9d6f7437 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -21,9 +21,7 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.awaitFirstDeferred import com.futo.platformplayer.dp -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricStorage import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.selectBestImage @@ -33,6 +31,7 @@ import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ClaimType import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.PublicKey import com.futo.polycentric.core.SignedEvent @@ -234,34 +233,7 @@ class StatePolycentric { if (!enabled) { return Pair(false, listOf(url)); } - var polycentricProfile: PolycentricProfile? = null; - try { - val polycentricCached = PolycentricCache.instance.getCachedProfile(url, cacheOnly) - polycentricProfile = polycentricCached?.profile; - if (polycentricCached == null && channelId != null) { - Logger.i("StateSubscriptions", "Get polycentric profile not cached"); - if(!cacheOnly) { - polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId, if(doCacheNull) url else null) }?.profile; - didUpdate = true; - } - } else { - Logger.i("StateSubscriptions", "Get polycentric profile cached"); - } - } - catch(ex: Throwable) { - Logger.w(StateSubscriptions.TAG, "Polycentric getCachedProfile failed for subscriptions", ex); - //TODO: Some way to communicate polycentric failing without blocking here - } - if(polycentricProfile != null) { - val urls = polycentricProfile.ownedClaims.groupBy { it.claim.claimType } - .mapNotNull { it.value.firstOrNull()?.claim?.resolveChannelUrl() }.toMutableList(); - if(urls.any { it.equals(url, true) }) - return Pair(didUpdate, urls); - else - return Pair(didUpdate, listOf(url) + urls); - } - else - return Pair(didUpdate, listOf(url)); + return Pair(didUpdate, listOf(url)); } fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager? { @@ -325,7 +297,7 @@ class StatePolycentric { id = PlatformID("polycentric", author, null, ClaimType.POLYCENTRIC.value.toInt()), name = systemState.username, url = author, - thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, + thumbnail = systemState.avatar?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(system.toProto(), img.process, listOf(ApiMethods.SERVER)) }, subscribers = null ), msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content, @@ -349,7 +321,7 @@ class StatePolycentric { suspend fun getLikesDislikesReplies(reference: Protocol.Reference): LikesDislikesReplies { ensureEnabled() - val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, + val response = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, null, null, listOf( Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() @@ -382,7 +354,7 @@ class StatePolycentric { } val pointer = Protocol.Pointer.parseFrom(reference.reference) - val events = ApiMethods.getEvents(PolycentricCache.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder() + val events = ApiMethods.getEvents(ApiMethods.SERVER, pointer.system, Protocol.RangesForSystem.newBuilder() .addRangesForProcesses(Protocol.RangesForProcess.newBuilder() .setProcess(pointer.process) .addRanges(Protocol.Range.newBuilder() @@ -400,11 +372,11 @@ class StatePolycentric { } val post = Protocol.Post.parseFrom(ev.content); - val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER)); + val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(ApiMethods.SERVER)); val dp_25 = 25.dp(StateApp.instance.context.resources) val profileEvents = ApiMethods.getQueryLatest( - PolycentricCache.SERVER, + ApiMethods.SERVER, ev.system.toProto(), listOf( ContentType.AVATAR.value, @@ -433,7 +405,7 @@ class StatePolycentric { id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()), name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown", url = systemLinkUrl, - thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, + thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(ApiMethods.SERVER)) }, subscribers = null ), msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content, @@ -445,12 +417,12 @@ class StatePolycentric { ) } - suspend fun getCommentPager(contextUrl: String, reference: Protocol.Reference, extraByteReferences: List? = null): IPager { + suspend fun getCommentPager(contextUrl: String, reference: Reference, extraByteReferences: List? = null): IPager { if (!enabled) { return EmptyPager() } - val response = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, null, + val response = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, null, Protocol.QueryReferencesRequestEvents.newBuilder() .setFromType(ContentType.POST.value) .addAllCountLwwElementReferences(arrayListOf( @@ -486,7 +458,7 @@ class StatePolycentric { } override suspend fun nextPageAsync() { - val nextPageResponse = ApiMethods.getQueryReferences(PolycentricCache.SERVER, reference, _cursor, + val nextPageResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, reference, _cursor, Protocol.QueryReferencesRequestEvents.newBuilder() .setFromType(ContentType.POST.value) .addAllCountLwwElementReferences(arrayListOf( @@ -534,7 +506,7 @@ class StatePolycentric { return@mapNotNull LazyComment(scope.async(_commentPoolDispatcher){ Logger.i(TAG, "Fetching comment data for [" + ev.system.key.toBase64() + "]"); val profileEvents = ApiMethods.getQueryLatest( - PolycentricCache.SERVER, + ApiMethods.SERVER, ev.system.toProto(), listOf( ContentType.AVATAR.value, @@ -558,7 +530,7 @@ class StatePolycentric { val unixMilliseconds = ev.unixMilliseconds //TODO: Don't use single hardcoded sderver here - val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(PolycentricCache.SERVER)); + val systemLinkUrl = ev.system.systemToURLInfoSystemLinkUrl(listOf(ApiMethods.SERVER)); val dp_25 = 25.dp(StateApp.instance.context.resources) return@async PolycentricPlatformComment( contextUrl = contextUrl, @@ -566,7 +538,7 @@ class StatePolycentric { id = PlatformID("polycentric", systemLinkUrl, null, ClaimType.POLYCENTRIC.value.toInt()), name = nameEvent?.event?.lwwElement?.value?.decodeToString() ?: "Unknown", url = systemLinkUrl, - thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(PolycentricCache.SERVER)) }, + thumbnail = imageBundle?.selectBestImage(dp_25 * dp_25)?.let { img -> img.toURLInfoSystemLinkUrl(ev.system.toProto(), img.process, listOf(ApiMethods.SERVER)) }, subscribers = null ), msg = if (post.content.count() > PolycentricPlatformComment.MAX_COMMENT_SIZE) post.content.substring(0, PolycentricPlatformComment.MAX_COMMENT_SIZE) else post.content, diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt index 7da01216..f979251d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptionGroups.kt @@ -1,54 +1,17 @@ package com.futo.platformplayer.states -import com.futo.platformplayer.Settings -import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.api.media.models.ResultCapabilities -import com.futo.platformplayer.api.media.models.channels.IPlatformChannel -import com.futo.platformplayer.api.media.models.channels.SerializedChannel -import com.futo.platformplayer.api.media.models.contents.IPlatformContent -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.constructs.Event0 -import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.constructs.Event2 -import com.futo.platformplayer.engine.exceptions.PluginException -import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException -import com.futo.platformplayer.engine.exceptions.ScriptCriticalException -import com.futo.platformplayer.exceptions.ChannelException -import com.futo.platformplayer.findNonRuntimeException -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile -import com.futo.platformplayer.getNowDiffDays import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup -import com.futo.platformplayer.polycentric.PolycentricCache -import com.futo.platformplayer.resolveChannelUrl -import com.futo.platformplayer.states.StateHistory.Companion import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringDateMapStorage -import com.futo.platformplayer.stores.SubscriptionStorage -import com.futo.platformplayer.stores.v2.ReconstructStore -import com.futo.platformplayer.stores.v2.ManagedStore -import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm -import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage -import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.OffsetDateTime -import java.util.concurrent.ExecutionException -import java.util.concurrent.ForkJoinPool -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 /*** * Used to maintain subscription groups 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 52fb9f2e..65892a1e 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -15,7 +15,6 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringDateMapStorage @@ -335,12 +334,6 @@ class StateSubscriptions { return true; } - //TODO: This causes issues, because what if the profile is not cached yet when the susbcribe button is loaded for example? - val cachedProfile = PolycentricCache.instance.getCachedProfile(urls.first(), true)?.profile; - if (cachedProfile != null) { - return cachedProfile.ownedClaims.any { c -> _subscriptions.hasItem { s -> c.claim.resolveChannelUrl() == s.channel.url } }; - } - return false; } } diff --git a/app/src/main/java/com/futo/platformplayer/stores/CachedPolycentricProfileStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/CachedPolycentricProfileStorage.kt deleted file mode 100644 index b9c036ac..00000000 --- a/app/src/main/java/com/futo/platformplayer/stores/CachedPolycentricProfileStorage.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.futo.platformplayer.stores - -import com.futo.platformplayer.polycentric.PolycentricCache -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -@kotlinx.serialization.Serializable -class CachedPolycentricProfileStorage : FragmentedStorageFileJson() { - var map: HashMap = hashMapOf(); - - override fun encode(): String { - val encoded = Json.encodeToString(this); - return encoded; - } - - fun get(key: String) : PolycentricCache.CachedPolycentricProfile? { - return map[key]; - } - - fun setAndSave(key: String, value: PolycentricCache.CachedPolycentricProfile) : PolycentricCache.CachedPolycentricProfile { - map[key] = value; - save(); - return value; - } - - fun setAndSaveBlocking(key: String, value: PolycentricCache.CachedPolycentricProfile) : PolycentricCache.CachedPolycentricProfile { - map[key] = value; - saveBlocking(); - return value; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt index 62da748b..b109acb5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/MonetizationView.kt @@ -16,11 +16,11 @@ import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.adapters.viewholders.StoreItemViewHolder import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.polycentric.core.PolycentricProfile import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -125,8 +125,7 @@ class MonetizationView : LinearLayout { } } - fun setPolycentricProfile(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?) { - val profile = cachedPolycentricProfile?.profile; + fun setPolycentricProfile(profile: PolycentricProfile?) { if (profile != null) { if (profile.systemState.store.isNotEmpty()) { _buttonStore.visibility = View.VISIBLE; diff --git a/app/src/main/java/com/futo/platformplayer/views/SupportView.kt b/app/src/main/java/com/futo/platformplayer/views/SupportView.kt index ad3017e7..c85d3450 100644 --- a/app/src/main/java/com/futo/platformplayer/views/SupportView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/SupportView.kt @@ -14,10 +14,10 @@ import androidx.core.view.isVisible import androidx.core.view.size import com.bumptech.glide.Glide import com.futo.platformplayer.R -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.views.buttons.BigButton +import com.futo.polycentric.core.PolycentricProfile import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.google.android.material.imageview.ShapeableImageView diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt index 665829db..3bd06903 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelViewPagerAdapter.kt @@ -17,7 +17,7 @@ import com.futo.platformplayer.fragment.channel.tab.ChannelListFragment import com.futo.platformplayer.fragment.channel.tab.ChannelMonetizationFragment import com.futo.platformplayer.fragment.channel.tab.ChannelPlaylistsFragment import com.futo.platformplayer.fragment.channel.tab.IChannelTabFragment -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile +import com.futo.polycentric.core.PolycentricProfile import com.google.android.material.tabs.TabLayout diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt index fe9c6079..74f7d53c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentViewHolder.kt @@ -18,8 +18,6 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikes import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.fixHtmlLinks -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions -import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePolycentric @@ -29,6 +27,7 @@ import com.futo.platformplayer.views.LoaderView import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.pills.PillButton import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.Opinion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -81,24 +80,18 @@ class CommentViewHolder : ViewHolder { throw Exception("Not implemented for non polycentric comments") } - if (args.hasLiked) { - args.processHandle.opinion(c.reference, Opinion.like); + val newOpinion: Opinion = if (args.hasLiked) { + Opinion.like } else if (args.hasDisliked) { - args.processHandle.opinion(c.reference, Opinion.dislike); + Opinion.dislike } else { - args.processHandle.opinion(c.reference, Opinion.neutral); + Opinion.neutral } _layoutComment.alpha = if (args.dislikes > 2 && args.dislikes.toFloat() / (args.likes + args.dislikes).toFloat() >= 0.7f) 0.5f else 1.0f; StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServersAnnounceExceptions(); - Logger.i(TAG, "Finished backfill"); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to backfill servers.", e) - } + ApiMethods.setOpinion(args.processHandle, c.reference, newOpinion) } StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt index db66a1a9..c4b9a51e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/CommentWithReferenceViewHolder.kt @@ -16,7 +16,6 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.fixHtmlLinks -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod import com.futo.platformplayer.states.StateApp @@ -26,6 +25,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail import com.futo.platformplayer.views.pills.PillButton import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.polycentric.core.Opinion +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.IdentityHashMap diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt index c8d86b14..c9cb8b73 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/PlaylistView.kt @@ -16,7 +16,6 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.others.CreatorThumbnail diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt index 05a3f5e7..a9e7110a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionViewHolder.kt @@ -15,7 +15,6 @@ import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.toHumanTimeIndicator diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt index cbf0b8a6..1d90e09c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewPostView.kt @@ -30,7 +30,6 @@ import com.futo.platformplayer.dp import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.views.FeedStyle 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 f2123832..75d332e5 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 @@ -24,7 +24,6 @@ import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.loadThumbnails import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt index 897718bf..9a171df6 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorBarViewHolder.kt @@ -11,7 +11,6 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.AnyAdapter diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt index b5784d4c..93576fec 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/CreatorViewHolder.kt @@ -12,7 +12,6 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.toHumanNumber diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt index da491cf6..e56fcf3a 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionBarViewHolder.kt @@ -12,7 +12,6 @@ import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.dp import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.AnyAdapter diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt index 19fe8a30..4f601d26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/viewholders/SubscriptionGroupListViewHolder.kt @@ -19,7 +19,6 @@ import com.futo.platformplayer.dp import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.AnyAdapter diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt index 2f592123..472a516f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -11,8 +11,8 @@ import androidx.constraintlayout.widget.ConstraintLayout import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.getDataLinkFromUrl import com.futo.platformplayer.images.GlideHelper.Companion.crossfade -import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.views.IdenticonView import userpackage.Protocol @@ -68,7 +68,7 @@ class CreatorThumbnail : ConstraintLayout { if (url.startsWith("polycentric://")) { try { - val dataLink = PolycentricCache.getDataLinkFromUrl(url) + val dataLink = url.getDataLinkFromUrl() setHarborAvailable(true, animate, dataLink?.system); } catch (e: Throwable) { setHarborAvailable(false, animate, null); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/SupportOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/SupportOverlay.kt index dfc59e05..e451806c 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/SupportOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/SupportOverlay.kt @@ -5,8 +5,8 @@ import android.util.AttributeSet import android.widget.LinearLayout import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event0 -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.views.SupportView +import com.futo.polycentric.core.PolycentricProfile class SupportOverlay : LinearLayout { val onClose = Event0(); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt index 06deadb8..27befb1e 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/WebviewOverlay.kt @@ -6,9 +6,7 @@ import android.webkit.WebView import android.widget.LinearLayout import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event0 -import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.views.SupportView class WebviewOverlay : LinearLayout { val onClose = Event0(); diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt index 965d3014..a1ccd142 100644 --- a/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/segments/CommentsList.kt @@ -22,12 +22,12 @@ import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException -import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.views.adapters.CommentViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.net.UnknownHostException diff --git a/dep/polycentricandroid b/dep/polycentricandroid index 44edd69e..f87f00ab 160000 --- a/dep/polycentricandroid +++ b/dep/polycentricandroid @@ -1 +1 @@ -Subproject commit 44edd69ece9cac4a6dd95a84ca91299e44f3650a +Subproject commit f87f00ab9e1262e300246b8963591bdf3a8fada7 From 7c256782118997ce4d4c9476b30a40da67005160 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 10 Mar 2025 22:12:17 +0100 Subject: [PATCH 028/128] Subgroup sub image url, ImageVariable default error icon on fail to load --- .../mainactivity/main/SubscriptionGroupFragment.kt | 2 ++ .../com/futo/platformplayer/models/ImageVariable.kt | 12 ++++++++++-- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- dep/polycentricandroid | 2 +- 7 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt index 9fd4d7d6..a2875647 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SubscriptionGroupFragment.kt @@ -256,6 +256,8 @@ class SubscriptionGroupFragment : MainFragment() { val sub = StateSubscriptions.instance.getSubscription(sub) ?: StateSubscriptions.instance.getSubscriptionOther(sub); if(sub != null && sub.channel.thumbnail != null) { g.image = ImageVariable.fromUrl(sub.channel.thumbnail!!); + if(g.image != null) + g.image!!.subscriptionUrl = sub.channel.url; g.image?.setImageView(_imageGroup); g.image?.setImageView(_imageGroupBackground); break; diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 1de1f917..00594df7 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -7,6 +7,7 @@ import android.widget.ImageView import com.bumptech.glide.Glide import com.futo.platformplayer.PresetImages import com.futo.platformplayer.R +import com.futo.platformplayer.logging.Logger import kotlinx.serialization.Contextual import kotlinx.serialization.Transient import java.io.File @@ -18,7 +19,8 @@ data class ImageVariable( @Transient @Contextual private val bitmap: Bitmap? = null, - val presetName: String? = null) { + val presetName: String? = null, + var subscriptionUrl: String? = null) { @SuppressLint("DiscouragedApi") fun setImageView(imageView: ImageView, fallbackResId: Int = -1) { @@ -63,7 +65,13 @@ data class ImageVariable( return ImageVariable(null, null, null, str); } fun fromFile(file: File): ImageVariable { - return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath)); + try { + return ImageVariable.fromBitmap(BitmapFactory.decodeFile(file.absolutePath)); + } + catch(ex: Throwable) { + Logger.e("ImageVariable", "Unsupported image format? " + ex.message, ex); + return fromResource(R.drawable.ic_error_pred); + } } } } \ No newline at end of file diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 0d05e35c..9c36c457 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d +Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 857c147b..6e0fe924 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf +Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 0d05e35c..9c36c457 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 0d05e35cfc81acfa78594c91c381b79694aaf86d +Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 857c147b..6e0fe924 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 857c147b3a3d3e9d0a79c47f1bd5813e08ed2daf +Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 diff --git a/dep/polycentricandroid b/dep/polycentricandroid index 44edd69e..f87f00ab 160000 --- a/dep/polycentricandroid +++ b/dep/polycentricandroid @@ -1 +1 @@ -Subproject commit 44edd69ece9cac4a6dd95a84ca91299e44f3650a +Subproject commit f87f00ab9e1262e300246b8963591bdf3a8fada7 From 9165a9f7cbc3af0466ee6c67a00be9715cb32fb0 Mon Sep 17 00:00:00 2001 From: Kai DeLorenzo Date: Mon, 17 Mar 2025 23:24:15 +0000 Subject: [PATCH 029/128] Add : support for login button selector --- .../java/com/futo/platformplayer/activities/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt index 36041919..6ea7bd67 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/LoginActivity.kt @@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() { companion object { private val TAG = "LoginActivity"; - private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*"); + private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*"); private var _callback: ((SourceAuth?) -> Unit)? = null; From 54d58df4b6080396162f438c12a82961b9c3087d Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 21 Mar 2025 02:23:55 +0100 Subject: [PATCH 030/128] Sync watch later on initial connection, Original audio boolean support, priority audio support, setting to prefer original audio --- .../java/com/futo/platformplayer/Settings.kt | 4 +++- .../futo/platformplayer/UISlideOverlays.kt | 2 +- .../models/streams/sources/AudioUrlSource.kt | 7 +++++-- .../streams/sources/HLSVariantUrlSource.kt | 1 + .../models/streams/sources/IAudioSource.kt | 1 + .../streams/sources/LocalAudioSource.kt | 1 + .../js/models/sources/JSAudioUrlSource.kt | 3 +++ .../sources/JSDashManifestRawAudioSource.kt | 2 ++ .../sources/JSHLSManifestAudioSource.kt | 2 ++ .../platformplayer/helpers/VideoHelper.kt | 10 ++++++++-- .../com/futo/platformplayer/parsers/HLS.kt | 4 ++-- .../platformplayer/states/StatePlaylists.kt | 19 +++++++++++-------- .../sync/internal/SyncSession.kt | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 14 files changed, 44 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c95947ea..c421c9a3 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -356,7 +356,7 @@ class Settings : FragmentedStorageFileJson() { var playback = PlaybackSettings(); @Serializable class PlaybackSettings { - @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1) + @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2) @DropdownFieldOptionsId(R.array.audio_languages) var primaryLanguage: Int = 0; @@ -380,6 +380,8 @@ class Settings : FragmentedStorageFileJson() { else -> null } } + @FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1) + var preferOriginalAudio: Boolean = true; //= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage]; diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 67497b1e..a1aa71b7 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -402,7 +402,7 @@ class UISlideOverlays { UIDialogs.toast(container.context, "Variant video HLS playlist download started") slideUpMenuOverlay.hide() } else if (source is IHLSManifestAudioSource) { - StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null) + StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null) UIDialogs.toast(container.context, "Variant audio HLS playlist download started") slideUpMenuOverlay.hide() } else { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt index 67548b89..a4d2cb55 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/AudioUrlSource.kt @@ -13,7 +13,8 @@ class AudioUrlSource( override val codec: String = "", override val language: String = Language.UNKNOWN, override val duration: Long? = null, - override var priority: Boolean = false + override var priority: Boolean = false, + override var original: Boolean = false ) : IAudioUrlSource, IStreamMetaDataSource{ override var streamMetaData: StreamMetaData? = null; @@ -36,7 +37,9 @@ class AudioUrlSource( source.container, source.codec, source.language, - source.duration + source.duration, + source.priority, + source.original ); ret.streamMetaData = streamData; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt index 36df5fb2..854cf9b8 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/HLSVariantUrlSource.kt @@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource( override val language: String, override val duration: Long?, override val priority: Boolean, + override val original: Boolean, val url: String ) : IAudioUrlSource { override fun getAudioUrl(): String { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt index eca17e47..f2c95b08 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/IAudioSource.kt @@ -8,4 +8,5 @@ interface IAudioSource { val language : String; val duration : Long?; val priority: Boolean; + val original: Boolean; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt index 254b9731..1f616307 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/sources/LocalAudioSource.kt @@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource { override val duration: Long? = null; override var priority: Boolean = false; + override val original: Boolean = false; val filePath : String; val fileSize: Long; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt index 09de1f35..0edc4f73 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSAudioUrlSource.kt @@ -21,6 +21,8 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource { override var priority: Boolean = false; + override var original: Boolean = false; + constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) { val contextName = "AudioUrlSource"; val config = plugin.config; @@ -35,6 +37,7 @@ open class JSAudioUrlSource : IAudioUrlSource, JSSource { name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}"; priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false; + original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; } override fun getAudioUrl() : String { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt index 93ed6a01..ae35207b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt @@ -23,6 +23,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS override val bitrate: Int; override val duration: Long; override val priority: Boolean; + override var original: Boolean = false; override val language: String; @@ -45,6 +46,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0; priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false; language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN; + original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; hasGenerate = _obj.has("generate"); } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt index 41948802..9e328df3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSHLSManifestAudioSource.kt @@ -21,6 +21,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { override val language: String; override var priority: Boolean = false; + override var original: Boolean = false; constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) { val contextName = "HLSAudioSource"; @@ -32,6 +33,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource { language = _obj.getOrThrow(config, "language", contextName); priority = obj.getOrNull(config, "priority", contextName) ?: false; + original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false; } diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 87e8f051..522647de 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -9,6 +9,7 @@ import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.dash.manifest.DashManifestParser import androidx.media3.exoplayer.source.MediaSource +import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -85,12 +86,17 @@ class VideoHelper { return selectBestAudioSource((desc as VideoUnMuxedSourceDescriptor).audioSources.toList(), prefContainers, prefLanguage, targetBitrate); } - fun selectBestAudioSource(altSources : Iterable, prefContainers : Array, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? { + fun selectBestAudioSource(sources : Iterable, prefContainers : Array, preferredLanguage: String? = null, targetBitrate: Long? = null) : IAudioSource? { + val hasPriority = sources.any { it.priority }; + var altSources = if(hasPriority) sources.filter { it.priority } else sources; + val hasOriginal = altSources.any { it.original }; + if(hasOriginal && Settings.instance.playback.preferOriginalAudio) + altSources = altSources.filter { it.original }; val languageToFilter = if(preferredLanguage != null && altSources.any { it.language == preferredLanguage }) { preferredLanguage } else { if(altSources.any { it.language == Language.ENGLISH }) - Language.ENGLISH + Language.ENGLISH; else Language.UNKNOWN; } diff --git a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt index 9d1a3faa..7d57a151 100644 --- a/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt +++ b/app/src/main/java/com/futo/platformplayer/parsers/HLS.kt @@ -119,7 +119,7 @@ class HLS { return if (source is IHLSManifestSource) { listOf() } else if (source is IHLSManifestAudioSource) { - listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, url)) + listOf(HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, url)) } else { throw NotImplementedError() } @@ -340,7 +340,7 @@ class HLS { val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ") return@mapNotNull when (it.type) { - "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, it.uri) + "AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri) else -> null } } 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 4a41760a..e2054c90 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -184,7 +184,7 @@ class StatePlaylists { wasNew = true; _watchlistStore.saveAsync(video); if(orderPosition == -1) - _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values) .toTypedArray()); + _watchlistOrderStore.set(*(listOf(video.url) + _watchlistOrderStore.values).toTypedArray()); else { val existing = _watchlistOrderStore.getAllValues().toMutableList(); existing.add(orderPosition, video.url); @@ -230,17 +230,20 @@ class StatePlaylists { } } + public fun getWatchLaterSyncPacket(orderOnly: Boolean = false): SyncWatchLaterPackage{ + return SyncWatchLaterPackage( + if (orderOnly) listOf() else getWatchLater(), + if (orderOnly) mapOf() else _watchLaterAdds.all(), + if (orderOnly) mapOf() else _watchLaterRemovals.all(), + getWatchLaterLastReorderTime().toEpochSecond(), + _watchlistOrderStore.values.toList() + ) + } private fun broadcastWatchLater(orderOnly: Boolean = false) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { StateSync.instance.broadcastJsonData( - GJSyncOpcodes.syncWatchLater, SyncWatchLaterPackage( - if (orderOnly) listOf() else getWatchLater(), - if (orderOnly) mapOf() else _watchLaterAdds.all(), - if (orderOnly) mapOf() else _watchLaterRemovals.all(), - getWatchLaterLastReorderTime().toEpochSecond(), - _watchlistOrderStore.values.toList() - ) + GJSyncOpcodes.syncWatchLater, getWatchLaterSyncPacket(orderOnly) ); } catch (e: Throwable) { Logger.w(TAG, "Failed to broadcast watch later", e) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 8b5621e0..e4273d63 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -232,6 +232,8 @@ class SyncSession : IAuthorizable { sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) + sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); + val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); if(recentHistory.size > 0) sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4df1905..8f5605c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -447,6 +447,8 @@ Preferred Preview Quality Default quality while previewing a video in a feed Primary Language + Prefer Original Audio + Use original audio instead of preferred language when it is known Default Comment Section Hide Recommendations Fully hide the recommendations tab. From 7bd687331b62222d1bbfc772ad66f26661863054 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 24 Mar 2025 20:33:17 +0100 Subject: [PATCH 031/128] AddSource by url support, submods --- .../activities/AddSourceOptionsActivity.kt | 23 ++- .../layout/activity_add_source_options.xml | 142 ++++++++++-------- app/src/stable/assets/sources/kick | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 10 files changed, 106 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt index 46872559..f237638e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/AddSourceOptionsActivity.kt @@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity import com.futo.platformplayer.* import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.buttons.BigButton +import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput import com.google.zxing.integration.android.IntentIntegrator class AddSourceOptionsActivity : AppCompatActivity() { lateinit var _buttonBack: ImageButton; + lateinit var _overlayContainer: FrameLayout; lateinit var _buttonQR: BigButton; lateinit var _buttonBrowse: BigButton; lateinit var _buttonURL: BigButton; @@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() { setContentView(R.layout.activity_add_source_options); setNavigationBarColorAndIcons(); + _overlayContainer = findViewById(R.id.overlay_container); _buttonBack = findViewById(R.id.button_back); _buttonQR = findViewById(R.id.option_qr); @@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() { } _buttonURL.onClick.subscribe { - UIDialogs.toast(this, getString(R.string.not_implemented_yet)); + val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json"); + UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", { + + val content = nameInput.text; + + val url = if (content.startsWith("https://")) { + content + } else if (content.startsWith("grayjay://plugin/")) { + content.substring("grayjay://plugin/".length) + } else { + UIDialogs.toast(this, getString(R.string.not_a_plugin_url)) + return@showOverlay; + } + + val intent = Intent(this, AddSourceActivity::class.java).apply { + data = Uri.parse(url); + }; + startActivity(intent); + }, nameInput) } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_source_options.xml b/app/src/main/res/layout/activity_add_source_options.xml index 541d489d..e9c1b001 100644 --- a/app/src/main/res/layout/activity_add_source_options.xml +++ b/app/src/main/res/layout/activity_add_source_options.xml @@ -1,5 +1,5 @@ - - + android:layout_height="match_parent" + android:orientation="vertical"> - - - + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingTop="20dp" + android:paddingBottom="15dp"> - + + - + android:layout_weight="1"> - + + + + + + + + + + + - - - - - - \ No newline at end of file + android:layout_height="match_parent" + android:visibility="gone" /> + \ No newline at end of file diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index 2046944c..3a0efd1f 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14 +Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23 diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index ae47f2ea..bff981c3 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096 +Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 9c36c457..331dd929 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 +Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 6e0fe924..ae7b62f4 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 +Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index 2046944c..3a0efd1f 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 2046944c18f48c15dfbea82f3f89d7ba6dce5e14 +Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23 diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index ae47f2ea..bff981c3 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit ae47f2eaacaf2879405435358965c47eb3d48096 +Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 9c36c457..331dd929 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 9c36c457463fc2a452f76eb74465e9e234cdaf69 +Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 6e0fe924..ae7b62f4 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 6e0fe9245143336a31954d678cdd22cbc3e0e115 +Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 From 034b8b15aedcc411557d0976decd1b831a92a982 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 26 Mar 2025 23:28:32 +0100 Subject: [PATCH 032/128] WIP SubsExchange --- .../java/com/futo/platformplayer/Settings.kt | 3 + .../states/StateSubscriptions.kt | 19 ++- .../SmartSubscriptionAlgorithm.kt | 6 +- .../SubscriptionFetchAlgorithm.kt | 5 +- .../SubscriptionsTaskFetchAlgorithm.kt | 59 ++++++++- .../subsexchange/ChannelRequest.kt | 8 ++ .../subsexchange/ChannelResolve.kt | 13 ++ .../subsexchange/ChannelResult.kt | 17 +++ .../subsexchange/ExchangeContract.kt | 17 +++ .../subsexchange/ExchangeContractResolve.kt | 10 ++ .../subsexchange/SubsExchangeClient.kt | 118 ++++++++++++++++++ .../main/res/layout/fragment_downloads.xml | 2 +- app/src/main/res/values/strings.xml | 2 + 13 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt create mode 100644 app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index c421c9a3..989997c3 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -294,6 +294,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5) var showSubscriptionGroups: Boolean = true; + @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) + var useSubscriptionExchange: Boolean = false; + @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; 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 65892a1e..f0a56b41 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.states +import SubsExchangeClient import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -18,6 +19,7 @@ import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.resolveChannelUrl import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringDateMapStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.SubscriptionStorage import com.futo.platformplayer.stores.v2.ReconstructStore @@ -67,10 +69,24 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); + private val _subsExchangeServer = "https://exchange.grayjay.app/"; + private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); + init { global.onUpdateProgress.subscribe { progress, total -> onFeedProgress.emit(null, progress, total); } + if(_subscriptionKey.value.isNullOrBlank()) + generateNewSubsExchangeKey(); + } + + fun generateNewSubsExchangeKey(){ + _subscriptionKey.setAndSave(SubsExchangeClient.createPrivateKey()); + } + fun getSubsExchangeClient(): SubsExchangeClient { + if(_subscriptionKey.value.isNullOrBlank()) + throw IllegalStateException("No valid subscription exchange key set"); + return SubsExchangeClient(_subsExchangeServer, _subscriptionKey.value); } fun getOldestUpdateTime(): OffsetDateTime { @@ -359,7 +375,8 @@ class StateSubscriptions { } fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair, List> { - val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool); + val exchangeClient = if(Settings.instance.subscriptions.useSubscriptionExchange) getSubsExchangeClient() else null; + val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient); if(onNewCacheHit != null) algo.onNewCacheHit.subscribe(onNewCacheHit) diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt index dfed7fd2..de89d195 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.platforms.js.JSClient @@ -15,8 +16,9 @@ class SmartSubscriptionAlgorithm( scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, - threadPool: ForkJoinPool? = null -): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool) { + threadPool: ForkJoinPool? = null, + subsExchangeClient: SubsExchangeClient? = null +): SubscriptionsTaskFetchAlgorithm(scope, allowFailure, withCacheFallback, threadPool, subsExchangeClient) { override fun getSubscriptionTasks(subs: Map>): List { val allTasks: List = subs.flatMap { entry -> val sub = entry.key; diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt index a34f0e33..269a75f5 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionFetchAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.structures.IPager @@ -33,11 +34,11 @@ abstract class SubscriptionFetchAlgorithm( companion object { public val TAG = "SubscriptionAlgorithm"; - fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null): SubscriptionFetchAlgorithm { + fun getAlgorithm(algo: SubscriptionFetchAlgorithms, scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = false, pool: ForkJoinPool? = null, withExchangeClient: SubsExchangeClient? = null): SubscriptionFetchAlgorithm { return when(algo) { SubscriptionFetchAlgorithms.CACHE -> CachedSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, 50); SubscriptionFetchAlgorithms.SIMPLE -> SimpleSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); - SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool); + SubscriptionFetchAlgorithms.SMART -> SmartSubscriptionAlgorithm(scope, allowFailure, withCacheFallback, pool, withExchangeClient); } } } 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 eff83030..dfec6fdf 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import SubsExchangeClient import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities @@ -10,6 +11,7 @@ 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.api.media.structures.PlatformContentPager import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException @@ -24,6 +26,8 @@ import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions +import com.futo.platformplayer.subsexchange.ChannelRequest +import com.futo.platformplayer.subsexchange.ChannelResolve import kotlinx.coroutines.CoroutineScope import java.time.OffsetDateTime import java.util.concurrent.ExecutionException @@ -35,7 +39,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( scope: CoroutineScope, allowFailure: Boolean = false, withCacheFallback: Boolean = true, - _threadPool: ForkJoinPool? = null + _threadPool: ForkJoinPool? = null, + private val subsExchangeClient: SubsExchangeClient? = null ) : SubscriptionFetchAlgorithm(scope, allowFailure, withCacheFallback, _threadPool) { @@ -45,7 +50,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( } override fun getSubscriptions(subs: Map>): Result { - val tasks = getSubscriptionTasks(subs); + var tasks = getSubscriptionTasks(subs).toMutableList() val tasksGrouped = tasks.groupBy { it.client } @@ -70,6 +75,21 @@ abstract class SubscriptionsTaskFetchAlgorithm( val exs: ArrayList = arrayListOf(); + + + val liveTasks = tasks.filter { !it.fromPeek && !it.fromCache }; + val contract = subsExchangeClient?.requestContract(*liveTasks.map { ChannelRequest(it.url) }.toTypedArray()); + var providedTasks: MutableList? = null; + if(contract != null && contract.provided.size > 0){ + providedTasks = mutableListOf() + for(task in tasks.toList()){ + if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { + providedTasks.add(task); + tasks.remove(task); + } + } + } + val failedPlugins = mutableListOf(); val cachedChannels = mutableListOf() val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); @@ -104,6 +124,39 @@ abstract class SubscriptionsTaskFetchAlgorithm( }; } } + + //Resolve Subscription Exchange + if(contract != null) { + try { + val resolve = subsExchangeClient?.resolveContract( + contract, + *taskResults.filter { it.pager != null }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults() + ) + }.toTypedArray() + ); + if (resolve != null) { + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); + } + } + } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + } + } + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") //Cache pagers grouped by channel @@ -173,6 +226,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex); } } + + //Intercepts task.fromCache & task.fromPeek synchronized(cachedChannels) { if(task.fromCache || task.fromPeek) { finished++; diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt new file mode 100644 index 00000000..cde158d5 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt @@ -0,0 +1,8 @@ +package com.futo.platformplayer.subsexchange + +import kotlinx.serialization.Serializable + +@Serializable +class ChannelRequest( + var url: String +); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt new file mode 100644 index 00000000..eaedc191 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ChannelResolve( + var channelUrl: String, + var content: List, + var channel: IPlatformChannel? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt new file mode 100644 index 00000000..f55f2451 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.api.media.models.channels.IPlatformChannel +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ChannelResult( + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var dateTime: OffsetDateTime, + var channelUrl: String, + var content: List, + var channel: IPlatformChannel? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt new file mode 100644 index 00000000..6618c3cb --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -0,0 +1,17 @@ +package com.futo.platformplayer.subsexchange + +import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.Serializable +import java.time.OffsetDateTime + +@Serializable +class ExchangeContract( + var id: String, + var requests: List, + var provided: List = listOf(), + var required: List = listOf(), + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var expired: OffsetDateTime = OffsetDateTime.MIN, + var contractVersion: Int = 1 +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt new file mode 100644 index 00000000..30550d51 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt @@ -0,0 +1,10 @@ +package com.futo.platformplayer.subsexchange + +import kotlinx.serialization.Serializable + +@Serializable +data class ExchangeContractResolve( + val publicKey: String, + val signature: String, + val data: String +) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt new file mode 100644 index 00000000..2fcfaf3f --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -0,0 +1,118 @@ +import com.futo.platformplayer.subsexchange.ChannelRequest +import com.futo.platformplayer.subsexchange.ChannelResolve +import com.futo.platformplayer.subsexchange.ChannelResult +import com.futo.platformplayer.subsexchange.ExchangeContract +import com.futo.platformplayer.subsexchange.ExchangeContractResolve +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.Base64 +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import java.security.KeyPairGenerator +import java.security.spec.PKCS8EncodedKeySpec + + +class SubsExchangeClient(private val server: String, private val privateKey: String) { + + private val publicKey: String = extractPublicKey(privateKey) + + // Endpoints + + // Endpoint: Contract + fun requestContract(vararg channels: ChannelRequest): ExchangeContract { + val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + return Json.decodeFromString(data) + } + suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { + val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + return Json.decodeFromString(data) + } + + // Endpoint: Resolve + fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { + val contractResolve = convertResolves(*resolves) + val result = post("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") + return Json.decodeFromString(result) + } + suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { + val contractResolve = convertResolves(*resolves) + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") + return Json.decodeFromString(result) + } + + + private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve { + val data = Json.encodeToString(resolves) + val signature = createSignature(data, privateKey) + + return ExchangeContractResolve( + publicKey = publicKey, + signature = signature, + data = data + ) + } + + // IO methods + private fun post(query: String, body: String, contentType: String): String { + val url = URL("$server$query") + with(url.openConnection() as HttpURLConnection) { + requestMethod = "POST" + setRequestProperty("Content-Type", contentType) + doOutput = true + OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body) } + + InputStreamReader(inputStream, StandardCharsets.UTF_8).use { + return it.readText() + } + } + } + private suspend fun postAsync(query: String, body: String, contentType: String): String { + return withContext(Dispatchers.IO) { + post(query, body, contentType) + } + } + + // Crypto methods + companion object { + fun createPrivateKey(): String { + val rsa = KeyFactory.getInstance("RSA") + val keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + val keyPair = keyPairGenerator.generateKeyPair(); + return Base64.getEncoder().encodeToString(keyPair.private.encoded); + } + + fun extractPublicKey(privateKey: String): String { + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey + val publicKeyObj: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey; + return Base64.getEncoder().encodeToString(publicKeyObj.encoded) + } + + fun createSignature(data: String, privateKey: String): String { + val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) + val keyFactory = KeyFactory.getInstance("RSA") + val rsaPrivateKey = keyFactory.generatePrivate(keySpec) as RSAPrivateKey + + val signature = Signature.getInstance("SHA256withRSA") + signature.initSign(rsaPrivateKey) + signature.update(data.toByteArray(Charsets.UTF_8)) + + val signatureBytes = signature.sign() + return Base64.getEncoder().encodeToString(signatureBytes) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index 2b971a0b..c1b12c0b 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -168,7 +168,7 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:background="@drawable/background_button_round" - android:hint="Seach.." /> + android:hint="Search.." /> Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter Groups Show Subscription Groups + Use Subscription Exchange (Experimental) + Uses a centralized crowd-sourced server to significantly reduce the required requests for subscriptions, in exchange you submit your subscriptions to the server. If subscription groups should be shown above your subscriptions to filter Preview Feed Items When the preview feedstyle is used, if items should auto-preview when scrolling over them From 3c05521a5b6986a8f2967d725cbb2d659afafc16 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Thu, 27 Mar 2025 23:25:13 +0100 Subject: [PATCH 033/128] Chapter Overlay --- .../mainactivity/main/VideoDetailView.kt | 33 ++++++ .../views/adapters/ChapterViewHolder.kt | 91 ++++++++++++++++ .../views/overlays/ChaptersOverlay.kt | 72 ++++++++++++ .../views/segments/ChaptersList.kt | 103 ++++++++++++++++++ .../main/res/layout/fragview_video_detail.xml | 6 + app/src/main/res/layout/list_chapter.xml | 81 ++++++++++++++ app/src/main/res/layout/overlay_chapters.xml | 34 ++++++ .../main/res/layout/view_chapters_list.xml | 31 ++++++ app/src/main/res/values/strings.xml | 1 + 9 files changed, 452 insertions(+) create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt create mode 100644 app/src/main/res/layout/list_chapter.xml create mode 100644 app/src/main/res/layout/overlay_chapters.xml create mode 100644 app/src/main/res/layout/view_chapters_list.xml 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 8d930838..db5c6060 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 @@ -132,6 +132,7 @@ import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout import com.futo.platformplayer.views.casting.CastView import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.overlays.ChaptersOverlay import com.futo.platformplayer.views.overlays.DescriptionOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.QueueEditorOverlay @@ -147,6 +148,7 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.segments.ChaptersList import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer @@ -195,6 +197,8 @@ class VideoDetailView : ConstraintLayout { private var _liveChat: LiveChatManager? = null; private var _videoResumePositionMilliseconds : Long = 0L; + private var _chapters: List? = null; + private val _player: FutoVideoPlayer; private val _cast: CastView; private val _playerProgress: PlayerControlView; @@ -263,6 +267,7 @@ class VideoDetailView : ConstraintLayout { private val _container_content_liveChat: LiveChatOverlay; private val _container_content_browser: WebviewOverlay; private val _container_content_support: SupportOverlay; + private val _container_content_chapters: ChaptersOverlay; private var _container_content_current: View; @@ -374,6 +379,7 @@ class VideoDetailView : ConstraintLayout { _container_content_liveChat = findViewById(R.id.videodetail_container_livechat); _container_content_support = findViewById(R.id.videodetail_container_support); _container_content_browser = findViewById(R.id.videodetail_container_webview) + _container_content_chapters = findViewById(R.id.videodetail_container_chapters); _addCommentView = findViewById(R.id.add_comment_view); _commentsList = findViewById(R.id.comments_list); @@ -686,6 +692,11 @@ class VideoDetailView : ConstraintLayout { _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_chapters.onClose.subscribe { switchContentView(_container_content_main); }; + + _container_content_chapters.onClick.subscribe { + handleSeek(it.timeStart.toLong() * 1000); + } _description_viewMore.setOnClickListener { switchContentView(_container_content_description); @@ -865,6 +876,21 @@ class VideoDetailView : ConstraintLayout { }; } }, + _chapters?.let { + if(it != null && it.size > 0) + RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { + video?.let { + try { + _container_content_chapters.setChapters(_chapters); + switchContentView(_container_content_chapters); + } + catch(ex: Throwable) { + + } + } + } + else null + }, if(video?.isLive ?: false) RoundButton(context, R.drawable.ic_chat, context.getString(R.string.live_chat), TAG_LIVECHAT) { video?.let { @@ -1340,10 +1366,12 @@ class VideoDetailView : ConstraintLayout { val chapters = null ?: StatePlatform.instance.getContentChapters(video.url); _player.setChapters(chapters); _cast.setChapters(chapters); + _chapters = _player.getChapters(); } catch (ex: Throwable) { Logger.e(TAG, "Failed to get chapters", ex); _player.setChapters(null); _cast.setChapters(null); + _chapters = null; /*withContext(Dispatchers.Main) { UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); @@ -1382,6 +1410,10 @@ class VideoDetailView : ConstraintLayout { ); } } + + fragment.lifecycleScope.launch(Dispatchers.Main) { + updateMoreButtons(); + } }; } @@ -3077,6 +3109,7 @@ class VideoDetailView : ConstraintLayout { const val TAG_SHARE = "share"; const val TAG_OVERLAY = "overlay"; const val TAG_LIVECHAT = "livechat"; + const val TAG_CHAPTERS = "chapters"; const val TAG_OPEN = "open"; const val TAG_SEND_TO_DEVICE = "send_to_device"; const val TAG_MORE = "MORE"; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt new file mode 100644 index 00000000..0b7efd4a --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChapterViewHolder.kt @@ -0,0 +1,91 @@ +package com.futo.platformplayer.views.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.api.media.models.chapters.ChapterType +import com.futo.platformplayer.api.media.models.chapters.IChapter +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.LazyComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanDuration +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.LoaderView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.pills.PillButton +import com.futo.platformplayer.views.pills.PillRatingLikesDislikes +import com.futo.polycentric.core.ApiMethods +import com.futo.polycentric.core.Opinion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ChapterViewHolder : ViewHolder { + + private val _layoutChapter: ConstraintLayout; + + private val _containerChapter: ConstraintLayout; + + private val _textTitle: TextView; + private val _textTimestamp: TextView; + private val _textMeta: TextView; + + var onClick = Event1(); + var chapter: IChapter? = null + private set; + + constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_chapter, viewGroup, false)) { + _layoutChapter = itemView.findViewById(R.id.layout_chapter); + _containerChapter = itemView.findViewById(R.id.chapter_container); + + _containerChapter.setOnClickListener { + chapter?.let { + onClick.emit(it); + } + } + + _textTitle = itemView.findViewById(R.id.text_title); + _textTimestamp = itemView.findViewById(R.id.text_timestamp); + _textMeta = itemView.findViewById(R.id.text_meta); + } + + fun bind(chapter: IChapter) { + _textTitle.text = chapter.name; + _textTimestamp.text = chapter.timeStart.toLong().toHumanTime(false); + + if(chapter.type == ChapterType.NORMAL) { + _textMeta.isVisible = false; + } + else { + _textMeta.isVisible = true; + when(chapter.type) { + ChapterType.SKIP -> _textMeta.text = "(Skip)"; + ChapterType.SKIPPABLE -> _textMeta.text = "(Manual Skip)" + ChapterType.SKIPONCE -> _textMeta.text = "(Skip Once)" + else -> _textMeta.isVisible = false; + }; + } + this.chapter = chapter; + } + + companion object { + private const val TAG = "CommentViewHolder"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt new file mode 100644 index 00000000..becdb87b --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/ChaptersOverlay.kt @@ -0,0 +1,72 @@ +package com.futo.platformplayer.views.overlays + +import android.content.Context +import android.net.Uri +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.models.chapters.IChapter +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fixHtmlLinks +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.views.behavior.NonScrollingTextView +import com.futo.platformplayer.views.comments.AddCommentView +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.segments.ChaptersList +import com.futo.platformplayer.views.segments.CommentsList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import userpackage.Protocol + +class ChaptersOverlay : LinearLayout { + val onClose = Event0(); + val onClick = Event1(); + + private val _topbar: OverlayTopbar; + private val _chaptersList: ChaptersList; + private var _onChapterClicked: ((chapter: IChapter) -> Unit)? = null; + private val _layoutItems: LinearLayout + + constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { + inflate(context, R.layout.overlay_chapters, this) + _layoutItems = findViewById(R.id.layout_items) + _topbar = findViewById(R.id.topbar); + _chaptersList = findViewById(R.id.chapters_list); + _chaptersList.onChapterClick.subscribe(onClick::emit); + _topbar.onClose.subscribe(this, onClose::emit); + _topbar.setInfo(context.getString(R.string.chapters), ""); + } + + fun setChapters(chapters: List?) { + _chaptersList?.setChapters(chapters ?: listOf()); + } + + + fun cleanup() { + _topbar.onClose.remove(this); + _onChapterClicked = null; + } + + companion object { + private const val TAG = "ChaptersOverlay" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt b/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt new file mode 100644 index 00000000..50d7ba04 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/segments/ChaptersList.kt @@ -0,0 +1,103 @@ +package com.futo.platformplayer.views.segments + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.futo.platformplayer.R +import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.api.media.models.chapters.IChapter +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +import com.futo.platformplayer.api.media.models.comments.LazyComment +import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.structures.IAsyncPager +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.TaskHandler +import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.views.adapters.ChapterViewHolder +import com.futo.platformplayer.views.adapters.CommentViewHolder +import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.UnknownHostException + +class ChaptersList : ConstraintLayout { + private val _llmReplies: LinearLayoutManager; + + private val _adapterChapters: InsertedViewAdapterWithLoader; + private val _recyclerChapters: RecyclerView; + private val _chapters: ArrayList = arrayListOf(); + private val _prependedView: FrameLayout; + private var _readonly: Boolean = false; + private val _layoutScrollToTop: FrameLayout; + + var onChapterClick = Event1(); + var onCommentsLoaded = Event1(); + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + LayoutInflater.from(context).inflate(R.layout.view_chapters_list, this, true); + + _recyclerChapters = findViewById(R.id.recycler_chapters); + + _layoutScrollToTop = findViewById(R.id.layout_scroll_to_top); + _layoutScrollToTop.setOnClickListener { + _recyclerChapters.smoothScrollToPosition(0) + } + _layoutScrollToTop.visibility = View.GONE + + _prependedView = FrameLayout(context); + _prependedView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); + + _adapterChapters = InsertedViewAdapterWithLoader(context, arrayListOf(_prependedView), arrayListOf(), + childCountGetter = { _chapters.size }, + childViewHolderBinder = { viewHolder, position -> viewHolder.bind(_chapters[position]); }, + childViewHolderFactory = { viewGroup, _ -> + val holder = ChapterViewHolder(viewGroup); + holder.onClick.subscribe { c -> onChapterClick.emit(c) }; + return@InsertedViewAdapterWithLoader holder; + } + ); + + _llmReplies = LinearLayoutManager(context); + _recyclerChapters.layoutManager = _llmReplies; + _recyclerChapters.adapter = _adapterChapters; + } + + fun addChapter(chapter: IChapter) { + _chapters.add(0, chapter); + _adapterChapters.notifyItemRangeInserted(_adapterChapters.childToParentPosition(0), 1); + } + + fun setPrependedView(view: View) { + _prependedView.removeAllViews(); + _prependedView.addView(view); + } + + fun setChapters(chapters: List) { + _chapters.clear(); + _chapters.addAll(chapters); + _adapterChapters.notifyDataSetChanged(); + } + + fun clear() { + _chapters.clear(); + _adapterChapters.notifyDataSetChanged(); + } + + companion object { + private const val TAG = "CommentsList"; + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_video_detail.xml b/app/src/main/res/layout/fragview_video_detail.xml index d5062c06..7b8c1dc2 100644 --- a/app/src/main/res/layout/fragview_video_detail.xml +++ b/app/src/main/res/layout/fragview_video_detail.xml @@ -579,6 +579,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/overlay_chapters.xml b/app/src/main/res/layout/overlay_chapters.xml new file mode 100644 index 00000000..6664847c --- /dev/null +++ b/app/src/main/res/layout/overlay_chapters.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_chapters_list.xml b/app/src/main/res/layout/view_chapters_list.xml new file mode 100644 index 00000000..8fda13f3 --- /dev/null +++ b/app/src/main/res/layout/view_chapters_list.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b1b6d9e..256fe1ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -665,6 +665,7 @@ Failed to load post. replies Replies + Chapters Plugin settings saved Plugin settings These settings are defined by the plugin From 5a9fcd6fab10b2895ce031d3b1cfed8b120decf4 Mon Sep 17 00:00:00 2001 From: Stefan <84-stefancruz@users.noreply.gitlab.futo.org> Date: Fri, 28 Mar 2025 19:13:55 +0000 Subject: [PATCH 034/128] add ted talks plugin --- .gitmodules | 6 ++++++ app/src/stable/assets/sources/tedtalks | 1 + app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/assets/sources/tedtalks | 1 + app/src/unstable/res/raw/plugin_config.json | 3 ++- 5 files changed, 12 insertions(+), 2 deletions(-) create mode 160000 app/src/stable/assets/sources/tedtalks create mode 160000 app/src/unstable/assets/sources/tedtalks diff --git a/.gitmodules b/.gitmodules index 5f6ab0dc..c906834c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -88,3 +88,9 @@ [submodule "app/src/unstable/assets/sources/apple-podcasts"] path = app/src/unstable/assets/sources/apple-podcasts url = ../plugins/apple-podcasts.git +[submodule "app/src/stable/assets/sources/tedtalks"] + path = app/src/stable/assets/sources/tedtalks + url = ../plugins/tedtalks.git +[submodule "app/src/unstable/assets/sources/tedtalks"] + path = app/src/unstable/assets/sources/tedtalks + url = ../plugins/tedtalks.git diff --git a/app/src/stable/assets/sources/tedtalks b/app/src/stable/assets/sources/tedtalks new file mode 160000 index 00000000..4e490737 --- /dev/null +++ b/app/src/stable/assets/sources/tedtalks @@ -0,0 +1 @@ +Subproject commit 4e490737a02491b52611af321582af8bead7d506 diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index d98fc987..2b851fa5 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -13,7 +13,8 @@ "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", - "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json" + "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", + "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/assets/sources/tedtalks b/app/src/unstable/assets/sources/tedtalks new file mode 160000 index 00000000..4e490737 --- /dev/null +++ b/app/src/unstable/assets/sources/tedtalks @@ -0,0 +1 @@ +Subproject commit 4e490737a02491b52611af321582af8bead7d506 diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index cfbf3e87..bdea11d0 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -13,7 +13,8 @@ "4e365633-6d3f-4267-8941-fdc36631d813": "sources/spotify/build/SpotifyConfig.json", "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", - "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json" + "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", + "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" From b652597924aa053c11493b98713babc66b74d052 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 28 Mar 2025 21:40:17 +0100 Subject: [PATCH 035/128] chapters ui on text press --- .../mainactivity/main/VideoDetailView.kt | 30 +++++++++++++------ .../views/video/FutoVideoPlayer.kt | 8 +++++ 2 files changed, 29 insertions(+), 9 deletions(-) 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 db5c6060..47ac35d2 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 @@ -404,6 +404,10 @@ class VideoDetailView : ConstraintLayout { _monetization = findViewById(R.id.monetization); _player.attachPlayer(); + _player.onChapterClicked.subscribe { + showChaptersUI(); + }; + _buttonSubscribe.onSubscribed.subscribe { _slideUpOverlay = UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); @@ -863,6 +867,22 @@ class VideoDetailView : ConstraintLayout { _cast.stopAllGestures(); } + fun showChaptersUI(){ + video?.let { + try { + _chapters?.let { + if(it.size == 0) + return@let; + _container_content_chapters.setChapters(_chapters); + switchContentView(_container_content_chapters); + } + } + catch(ex: Throwable) { + + } + } + } + fun updateMoreButtons() { val isLimitedVersion = video?.url != null && StatePlatform.instance.getContentClientOrNull(video!!.url)?.let { if (it is JSClient) @@ -879,15 +899,7 @@ class VideoDetailView : ConstraintLayout { _chapters?.let { if(it != null && it.size > 0) RoundButton(context, R.drawable.ic_list, "Chapters", TAG_CHAPTERS) { - video?.let { - try { - _container_content_chapters.setChapters(_chapters); - switchContentView(_container_content_chapters); - } - catch(ex: Throwable) { - - } - } + showChaptersUI(); } else null }, diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index ec0345fb..1daa7808 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -145,6 +145,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onVideoClicked = Event0(); val onTimeBarChanged = Event2(); + val onChapterClicked = Event1(); + @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.video_view, this, true); @@ -185,6 +187,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); + _control_chapter.setOnClickListener { + _currentChapter?.let { + onChapterClicked.emit(it); + } + } + val castVisibility = if (Settings.instance.casting.enabled) View.VISIBLE else View.GONE _control_cast.visibility = castVisibility _control_cast_fullscreen.visibility = castVisibility From 7f7ebafa461ba1daada79080d4ffdc1316efe95e Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 31 Mar 2025 20:02:49 +0200 Subject: [PATCH 036/128] Resume on playback error instead of reseting, dont error on empty author url, subs exchange fixes --- .../fragment/mainactivity/main/VideoDetailView.kt | 7 +++++-- .../subscription/SubscriptionsTaskFetchAlgorithm.kt | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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 47ac35d2..25b03cb1 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 @@ -1907,7 +1907,7 @@ class VideoDetailView : ConstraintLayout { else null; withContext(Dispatchers.Main) { video = newDetails; - _player.setSource(newVideoSource, newAudioSource, true, true); + _player.setSource(newVideoSource, newAudioSource, true, true, true); } } } catch (e: Throwable) { @@ -2645,7 +2645,10 @@ class VideoDetailView : ConstraintLayout { } onChannelClicked.subscribe { - fragment.navigate(it) + if(it.url.isNotBlank()) + fragment.navigate(it) + else + UIDialogs.appToast("No author url present"); } onAddToWatchLaterClicked.subscribe(this) { 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 dfec6fdf..630cef65 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.structures.PlatformContentPager import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException -import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment @@ -77,10 +76,10 @@ abstract class SubscriptionsTaskFetchAlgorithm( - val liveTasks = tasks.filter { !it.fromPeek && !it.fromCache }; - val contract = subsExchangeClient?.requestContract(*liveTasks.map { ChannelRequest(it.url) }.toTypedArray()); + val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null; var providedTasks: MutableList? = null; - if(contract != null && contract.provided.size > 0){ + if(contract != null && contract.provided.isNotEmpty()){ providedTasks = mutableListOf() for(task in tasks.toList()){ if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { @@ -130,7 +129,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( try { val resolve = subsExchangeClient?.resolveContract( contract, - *taskResults.filter { it.pager != null }.map { + *taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) }.map { ChannelResolve( it.task.url, it.pager!!.getResults() From c1993ffa0367aa55bcdc8790bafd7a65292f9bc5 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 1 Apr 2025 00:56:24 +0200 Subject: [PATCH 037/128] SubsExchange fixes --- .../media/models/contents/IPlatformContent.kt | 1 + .../models/video/SerializedPlatformVideo.kt | 3 +- .../platformplayer/models/HistoryVideo.kt | 2 + .../serializers/OffsetDateTimeSerializer.kt | 12 +++++ .../states/StateSubscriptions.kt | 11 +++- .../SubscriptionsTaskFetchAlgorithm.kt | 22 +++++--- .../subsexchange/ChannelRequest.kt | 4 +- .../subsexchange/ChannelResolve.kt | 8 ++- .../subsexchange/ChannelResult.kt | 8 ++- .../subsexchange/ExchangeContract.kt | 13 ++++- .../subsexchange/ExchangeContractResolve.kt | 4 ++ .../subsexchange/SubsExchangeClient.kt | 53 +++++++++++++++---- .../platformplayer/RequireMigrationTests.kt | 2 + 13 files changed, 119 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index 554a4723..edb1caa3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.contents import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import kotlinx.serialization.Serializable import java.time.OffsetDateTime interface IPlatformContent { 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 49b6265b..68bb5cb9 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 @@ -14,6 +14,7 @@ import java.time.OffsetDateTime @kotlinx.serialization.Serializable open class SerializedPlatformVideo( + override val contentType: ContentType = ContentType.MEDIA, override val id: PlatformID, override val name: String, override val thumbnails: Thumbnails, @@ -27,7 +28,6 @@ open class SerializedPlatformVideo( override val viewCount: Long, override val isShort: Boolean = false ) : IPlatformVideo, SerializedPlatformContent { - override val contentType: ContentType = ContentType.MEDIA; override val isLive: Boolean = false; @@ -44,6 +44,7 @@ open class SerializedPlatformVideo( companion object { fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { return SerializedPlatformVideo( + ContentType.MEDIA, video.id, video.name, video.thumbnails, diff --git a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt index b491f95f..80574968 100644 --- a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.models import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import java.time.LocalDateTime @@ -46,6 +47,7 @@ class HistoryVideo { val name = str.substring(indexNext + 3); val video = resolve?.invoke(url) ?: SerializedPlatformVideo( + ContentType.MEDIA, id = PlatformID.asUrlID(url), name = name, thumbnails = Thumbnails(), diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index 31fbaadd..faee4e3b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MIN; return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); } +} +class OffsetDateTimeStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: OffsetDateTime) { + encoder.encodeString(value.toString()); + } + override fun deserialize(decoder: Decoder): OffsetDateTime { + val str = decoder.decodeString(); + + return OffsetDateTime.parse(str); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index f0a56b41..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -375,7 +375,16 @@ class StateSubscriptions { } fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair, List> { - val exchangeClient = if(Settings.instance.subscriptions.useSubscriptionExchange) getSubsExchangeClient() else null; + var exchangeClient: SubsExchangeClient? = null; + if(Settings.instance.subscriptions.useSubscriptionExchange) { + try { + exchangeClient = getSubsExchangeClient(); + } + catch(ex: Throwable){ + Logger.e(TAG, "Failed to get subs exchange client: ${ex.message}", ex); + } + } + val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient); if(onNewCacheHit != null) algo.onNewCacheHit.subscribe(onNewCacheHit) 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 630cef65..15235017 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -5,6 +5,9 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities 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.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo 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.DedupContentPager @@ -78,8 +81,10 @@ abstract class SubscriptionsTaskFetchAlgorithm( val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null; + if(contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); var providedTasks: MutableList? = null; - if(contract != null && contract.provided.isNotEmpty()){ + if(contract != null && contract.required.isNotEmpty()){ providedTasks = mutableListOf() for(task in tasks.toList()){ if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { @@ -127,16 +132,18 @@ abstract class SubscriptionsTaskFetchAlgorithm( //Resolve Subscription Exchange if(contract != null) { try { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() val resolve = subsExchangeClient?.resolveContract( contract, - *taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults() - ) - }.toTypedArray() + *resolves ); if (resolve != null) { + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") for(result in resolve){ val task = providedTasks?.find { it.url == result.channelUrl }; if(task != null) { @@ -153,6 +160,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( } catch(ex: Throwable) { //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); } } diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt index cde158d5..a7939ae4 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt @@ -1,8 +1,10 @@ package com.futo.platformplayer.subsexchange +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable class ChannelRequest( - var url: String + @SerialName("ChannelUrl") + var channelUrl: String ); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt index eaedc191..7bf5e022 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt @@ -2,12 +2,18 @@ package com.futo.platformplayer.subsexchange import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @Serializable class ChannelResolve( + @SerialName("ChannelUrl") var channelUrl: String, - var content: List, + @SerialName("Content") + var content: List, + @SerialName("Channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt index f55f2451..c13f101c 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -2,16 +2,22 @@ package com.futo.platformplayer.subsexchange import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @Serializable class ChannelResult( @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + @SerialName("DateTime") var dateTime: OffsetDateTime, + @SerialName("ChannelUrl") var channelUrl: String, - var content: List, + @SerialName("Content") + var content: List, + @SerialName("Channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt index 6618c3cb..d357c8b7 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -2,16 +2,27 @@ package com.futo.platformplayer.subsexchange import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer +import com.google.gson.annotations.SerializedName +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer import java.time.OffsetDateTime @Serializable class ExchangeContract( + @SerialName("ID") var id: String, + @SerialName("Requests") var requests: List, + @SerialName("Provided") var provided: List = listOf(), + @SerialName("Required") var required: List = listOf(), - @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + @SerialName("Expire") + @kotlinx.serialization.Serializable(with = OffsetDateTimeStringSerializer::class) var expired: OffsetDateTime = OffsetDateTime.MIN, + @SerialName("ContractVersion") var contractVersion: Int = 1 ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt index 30550d51..8f42e0c3 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt @@ -1,10 +1,14 @@ package com.futo.platformplayer.subsexchange +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ExchangeContractResolve( + @SerialName("PublicKey") val publicKey: String, + @SerialName("Signature") val signature: String, + @SerialName("Data") val data: String ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 2fcfaf3f..a58e17b0 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -1,3 +1,5 @@ +import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResult @@ -19,13 +21,19 @@ import java.util.Base64 import java.io.InputStreamReader import java.io.OutputStream import java.io.OutputStreamWriter +import java.math.BigInteger import java.nio.charset.StandardCharsets import java.security.KeyPairGenerator import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec class SubsExchangeClient(private val server: String, private val privateKey: String) { + private val json = Json { + ignoreUnknownKeys = true + } + private val publicKey: String = extractPublicKey(privateKey) // Endpoints @@ -43,18 +51,18 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Resolve fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = post("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") - return Json.decodeFromString(result) + val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") - return Json.decodeFromString(result) + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + return Serializer.json.decodeFromString(result) } private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve { - val data = Json.encodeToString(resolves) + val data = Serializer.json.encodeToString(resolves) val signature = createSignature(data, privateKey) return ExchangeContractResolve( @@ -66,15 +74,31 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // IO methods private fun post(query: String, body: String, contentType: String): String { - val url = URL("$server$query") + val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true - OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body) } + OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() } + + val status = responseCode; + Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); + + if(status == 200) + InputStreamReader(inputStream, StandardCharsets.UTF_8).use { + return it.readText() + } + else { + var errorStr = ""; + try { + errorStr = InputStreamReader(errorStream, StandardCharsets.UTF_8).use { + return@use it.readText() + } + } + catch(ex: Throwable){} + + throw Exception("Exchange server resulted in code ${status}:\n" + errorStr); - InputStreamReader(inputStream, StandardCharsets.UTF_8).use { - return it.readText() } } } @@ -98,8 +122,15 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)) val keyFactory = KeyFactory.getInstance("RSA") val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey - val publicKeyObj: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey; - return Base64.getEncoder().encodeToString(publicKeyObj.encoded) + val publicKeyObj: PublicKey? = keyFactory.generatePublic(RSAPublicKeySpec(privateKeyObj.modulus, BigInteger.valueOf(65537))); + var publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyObj?.encoded); + var pem = "-----BEGIN PUBLIC KEY-----" + while(publicKeyBase64.length > 0) { + val length = Math.min(publicKeyBase64.length, 64); + pem += "\n" + publicKeyBase64.substring(0, length); + publicKeyBase64 = publicKeyBase64.substring(length); + } + return pem + "\n-----END PUBLIC KEY-----"; } fun createSignature(data: String, privateKey: String): String { diff --git a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt index 1b5eabc8..3118194b 100644 --- a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt +++ b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt @@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.serializers.FlexibleBooleanSerializer @@ -39,6 +40,7 @@ class RequireMigrationTests { val viewCount = 1000L return SerializedPlatformVideo( + ContentType.MEDIA, platformId, name, thumbnails, From b545545712a75be2e6d6606863a04e9e04be17d0 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 1 Apr 2025 01:01:45 +0200 Subject: [PATCH 038/128] Remove dep --- .../com/futo/platformplayer/subsexchange/ExchangeContract.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt index d357c8b7..41a6bf75 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -4,7 +4,6 @@ import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer import com.google.gson.annotations.SerializedName -import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Serializer From dd6bde97a968ae9816cfb91298fab75073143c10 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 2 Apr 2025 22:53:54 +0200 Subject: [PATCH 039/128] Playlists sort and search support, Playlist search support, wip local playback, other fixes --- ...> DownloadedVideoMuxedSourceDescriptor.kt} | 2 +- .../api/media/platforms/local/LocalClient.kt | 5 + .../local/models/LocalVideoDetails.kt | 85 ++++++++++++++ .../models/LocalVideoMuxedSourceDescriptor.kt | 13 +++ .../platforms/local/models/MediaStoreVideo.kt | 25 +++++ .../models/sources/LocalVideoFileSource.kt | 31 ++++++ .../platformplayer/downloads/VideoLocal.kt | 4 +- .../mainactivity/main/DownloadsFragment.kt | 2 +- .../mainactivity/main/PlaylistsFragment.kt | 79 ++++++++++++- .../mainactivity/main/VideoListEditorView.kt | 43 ++++++- .../platformplayer/helpers/VideoHelper.kt | 33 ++++++ .../platformplayer/models/ImageVariable.kt | 7 ++ .../SubscriptionsTaskFetchAlgorithm.kt | 34 ++++-- .../views/overlays/ImageVariableOverlay.kt | 6 +- app/src/main/res/drawable/ic_search_off.xml | 10 ++ .../main/res/layout/fragment_playlists.xml | 105 +++++++++++++----- .../res/layout/fragment_video_list_editor.xml | 48 ++++++-- app/src/main/res/values/strings.xml | 10 ++ 18 files changed, 481 insertions(+), 61 deletions(-) rename app/src/main/java/com/futo/platformplayer/api/media/models/streams/{LocalVideoMuxedSourceDescriptor.kt => DownloadedVideoMuxedSourceDescriptor.kt} (89%) create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt create mode 100644 app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt create mode 100644 app/src/main/res/drawable/ic_search_off.xml diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt similarity index 89% rename from app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt rename to app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt index b5309931..df96de13 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/streams/LocalVideoMuxedSourceDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/streams/DownloadedVideoMuxedSourceDescriptor.kt @@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.downloads.VideoLocal -class LocalVideoMuxedSourceDescriptor( +class DownloadedVideoMuxedSourceDescriptor( private val video: VideoLocal ) : VideoMuxedSourceDescriptor() { override val videoSources: Array get() = video.videoSource.toTypedArray(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt new file mode 100644 index 00000000..3f6e5b82 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt @@ -0,0 +1,5 @@ +package com.futo.platformplayer.api.media.platforms.local + +class LocalClient { + //TODO +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt new file mode 100644 index 00000000..1c169e64 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoDetails.kt @@ -0,0 +1,85 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import com.futo.platformplayer.api.media.IPlatformClient +import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.Thumbnails +import com.futo.platformplayer.api.media.models.comments.IPlatformComment +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.playback.IPlaybackTracker +import com.futo.platformplayer.api.media.models.ratings.IRating +import com.futo.platformplayer.api.media.models.ratings.RatingLikes +import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource +import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource +import com.futo.platformplayer.api.media.structures.IPager +import com.futo.platformplayer.downloads.VideoLocal +import java.io.File +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId + +class LocalVideoDetails: IPlatformVideoDetails { + + override val contentType: ContentType get() = ContentType.UNKNOWN; + + override val id: PlatformID; + override val name: String; + override val author: PlatformAuthorLink; + + override val datetime: OffsetDateTime?; + + override val url: String; + override val shareUrl: String; + override val rating: IRating = RatingLikes(0); + override val description: String = ""; + + override val video: IVideoSourceDescriptor; + override val preview: IVideoSourceDescriptor? = null; + override val live: IVideoSource? = null; + override val dash: IDashManifestSource? = null; + override val hls: IHLSManifestSource? = null; + override val subtitles: List = listOf() + + override val thumbnails: Thumbnails; + override val duration: Long; + override val viewCount: Long = 0; + override val isLive: Boolean = false; + override val isShort: Boolean = false; + + constructor(file: File) { + id = PlatformID("Local", file.path, "LOCAL") + name = file.name; + author = PlatformAuthorLink.UNKNOWN; + + url = file.canonicalPath; + shareUrl = ""; + + duration = 0; + thumbnails = Thumbnails(arrayOf()); + + datetime = OffsetDateTime.ofInstant( + Instant.ofEpochMilli(file.lastModified()), + ZoneId.systemDefault() + ); + video = LocalVideoMuxedSourceDescriptor(LocalVideoFileSource(file)); + } + + override fun getComments(client: IPlatformClient): IPager? { + return null; + } + + override fun getPlaybackTracker(): IPlaybackTracker? { + return null; + } + + override fun getContentRecommendations(client: IPlatformClient): IPager? { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt new file mode 100644 index 00000000..da8ae431 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/LocalVideoMuxedSourceDescriptor.kt @@ -0,0 +1,13 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource +import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource +import com.futo.platformplayer.downloads.VideoLocal + +class LocalVideoMuxedSourceDescriptor( + private val video: LocalVideoFileSource +) : VideoMuxedSourceDescriptor() { + override val videoSources: Array get() = arrayOf(video); +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt new file mode 100644 index 00000000..52876b90 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/MediaStoreVideo.kt @@ -0,0 +1,25 @@ +package com.futo.platformplayer.api.media.platforms.local.models + +import android.content.Context +import android.database.Cursor +import android.provider.MediaStore +import android.provider.MediaStore.Video + +class MediaStoreVideo { + + + companion object { + val URI = MediaStore.Files.getContentUri("external"); + val PROJECTION = arrayOf(Video.Media._ID, Video.Media.TITLE, Video.Media.DURATION, Video.Media.HEIGHT, Video.Media.WIDTH, Video.Media.MIME_TYPE); + val ORDER = MediaStore.Video.Media.TITLE; + + fun readMediaStoreVideo(cursor: Cursor) { + + } + + fun query(context: Context, selection: String, args: Array, order: String? = null): Cursor? { + val cursor = context.contentResolver.query(URI, PROJECTION, selection, args, order ?: ORDER, null); + return cursor; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt new file mode 100644 index 00000000..9e2f7792 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/models/sources/LocalVideoFileSource.kt @@ -0,0 +1,31 @@ +package com.futo.platformplayer.api.media.platforms.local.models.sources + +import android.content.Context +import android.provider.MediaStore +import android.provider.MediaStore.Video +import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource +import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource +import com.futo.platformplayer.helpers.VideoHelper +import java.io.File + +class LocalVideoFileSource: IVideoSource { + + + override val name: String; + override val width: Int; + override val height: Int; + override val container: String; + override val codec: String = "" + override val bitrate: Int = 0 + override val duration: Long; + override val priority: Boolean = false; + + constructor(file: File) { + name = file.name; + width = 0; + height = 0; + container = VideoHelper.videoExtensionToMimetype(file.extension) ?: ""; + duration = 0; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt index 578a5812..06095058 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoLocal.kt @@ -10,7 +10,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker import com.futo.platformplayer.api.media.models.ratings.IRating import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor -import com.futo.platformplayer.api.media.models.streams.LocalVideoMuxedSourceDescriptor +import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource @@ -57,7 +57,7 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem { override val video: IVideoSourceDescriptor get() = if(audioSource.isNotEmpty()) LocalVideoUnMuxedSourceDescriptor(this) else - LocalVideoMuxedSourceDescriptor(this); + DownloadedVideoMuxedSourceDescriptor(this); override val preview: IVideoSourceDescriptor? get() = videoSerialized.preview; override val live: IVideoSource? get() = videoSerialized.live; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index 440aa235..d402a6e2 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -229,7 +229,7 @@ class DownloadsFragment : MainFragment() { fun filterDownloads(vids: List): List{ var vidsToReturn = vids; if(!_listDownloadSearch.text.isNullOrEmpty()) - vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) }; + vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; if(!ordering.isNullOrEmpty()) { vidsToReturn = when(ordering){ "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index bcc01ed1..9d188415 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -6,12 +6,17 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageButton import android.widget.LinearLayout +import android.widget.Spinner import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -21,11 +26,13 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay import com.google.android.material.appbar.AppBarLayout import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.time.OffsetDateTime class PlaylistsFragment : MainFragment() { @@ -65,6 +72,7 @@ class PlaylistsFragment : MainFragment() { private val _fragment: PlaylistsFragment; var watchLater: ArrayList = arrayListOf(); + var allPlaylists: ArrayList = arrayListOf(); var playlists: ArrayList = arrayListOf(); private var _appBar: AppBarLayout; private var _adapterWatchLater: VideoListHorizontalAdapter; @@ -72,12 +80,20 @@ class PlaylistsFragment : MainFragment() { private var _layoutWatchlist: ConstraintLayout; private var _slideUpOverlay: SlideUpMenuOverlay? = null; + private var _listPlaylistsSearch: EditText; + + private var _ordering: String? = null; + + constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { _fragment = fragment; inflater.inflate(R.layout.fragment_playlists, this); + _listPlaylistsSearch = findViewById(R.id.playlists_search); + watchLater = ArrayList(); playlists = ArrayList(); + allPlaylists = ArrayList(); val recyclerWatchLater = findViewById(R.id.recycler_watch_later); @@ -105,6 +121,7 @@ class PlaylistsFragment : MainFragment() { buttonCreatePlaylist.setOnClickListener { _slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById(R.id.overlay_create_playlist)) { val playlist = Playlist(it, arrayListOf()); + allPlaylists.add(0, playlist); playlists.add(0, playlist); StatePlaylists.instance.createOrUpdatePlaylist(playlist); @@ -120,6 +137,34 @@ class PlaylistsFragment : MainFragment() { _appBar = findViewById(R.id.app_bar); _layoutWatchlist = findViewById(R.id.layout_watchlist); + + _listPlaylistsSearch.addTextChangedListener { + updatePlaylistsFiltering(); + } + val spinnerSortBy: Spinner = findViewById(R.id.spinner_sortby); + spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { + it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); + }; + spinnerSortBy.setSelection(0); + spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { + when(pos) { + 0 -> _ordering = "nameAsc" + 1 -> _ordering = "nameDesc" + 2 -> _ordering = "dateEditAsc" + 3 -> _ordering = "dateEditDesc" + 4 -> _ordering = "dateCreateAsc" + 5 -> _ordering = "dateCreateDesc" + 6 -> _ordering = "datePlayAsc" + 7 -> _ordering = "datePlayDesc" + else -> _ordering = null + } + updatePlaylistsFiltering() + } + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + }; + + findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate(context.getString(R.string.watch_later)); }; StatePlaylists.instance.onWatchLaterChanged.subscribe(this) { fragment.lifecycleScope.launch(Dispatchers.Main) { @@ -134,10 +179,12 @@ class PlaylistsFragment : MainFragment() { @SuppressLint("NotifyDataSetChanged") fun onShown() { + allPlaylists.clear(); playlists.clear() - playlists.addAll( + allPlaylists.addAll( StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) } ); + playlists.addAll(filterPlaylists(allPlaylists)); _adapterPlaylist.notifyDataSetChanged(); updateWatchLater(); @@ -157,6 +204,32 @@ class PlaylistsFragment : MainFragment() { return false; } + private fun updatePlaylistsFiltering() { + val toFilter = allPlaylists ?: return; + playlists.clear(); + playlists.addAll(filterPlaylists(toFilter)); + _adapterPlaylist.notifyDataSetChanged(); + } + private fun filterPlaylists(pls: List): List { + var playlistsToReturn = pls; + if(!_listPlaylistsSearch.text.isNullOrEmpty()) + playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; + if(!_ordering.isNullOrEmpty()){ + playlistsToReturn = when(_ordering){ + "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } + "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; + "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; + "dateEditDesc" -> playlistsToReturn.sortedByDescending { it.dateUpdate ?: OffsetDateTime.MIN } + "dateCreateAsc" -> playlistsToReturn.sortedBy { it.dateCreation ?: OffsetDateTime.MAX }; + "dateCreateDesc" -> playlistsToReturn.sortedByDescending { it.dateCreation ?: OffsetDateTime.MIN } + "datePlayAsc" -> playlistsToReturn.sortedBy { it.datePlayed ?: OffsetDateTime.MAX }; + "datePlayDesc" -> playlistsToReturn.sortedByDescending { it.datePlayed ?: OffsetDateTime.MIN } + else -> playlistsToReturn + } + } + return playlistsToReturn; + } + private fun updateWatchLater() { val watchList = StatePlaylists.instance.getWatchLater(); if (watchList.isNotEmpty()) { @@ -164,7 +237,7 @@ class PlaylistsFragment : MainFragment() { _appBar.let { appBar -> val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; - layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 230.0f, resources.displayMetrics).toInt(); + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 315.0f, resources.displayMetrics).toInt(); appBar.layoutParams = layoutParams; } } else { @@ -172,7 +245,7 @@ class PlaylistsFragment : MainFragment() { _appBar.let { appBar -> val layoutParams: CoordinatorLayout.LayoutParams = appBar.layoutParams as CoordinatorLayout.LayoutParams; - layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25.0f, resources.displayMetrics).toInt(); + layoutParams.height = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 110.0f, resources.displayMetrics).toInt(); appBar.layoutParams = layoutParams; }; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index b458a093..441f7421 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -9,6 +9,7 @@ import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.core.view.isVisible import androidx.core.view.setPadding import com.bumptech.glide.Glide import com.futo.platformplayer.R @@ -22,6 +23,7 @@ import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.lists.VideoListEditorView abstract class VideoListEditorView : LinearLayout { @@ -37,9 +39,15 @@ abstract class VideoListEditorView : LinearLayout { protected var _buttonExport: ImageButton; private var _buttonShare: ImageButton; private var _buttonEdit: ImageButton; + private var _buttonSearch: ImageButton; + + private var _search: SearchView; private var _onShare: (()->Unit)? = null; + private var _loadedVideos: List? = null; + private var _loadedVideosCanEdit: Boolean = false; + constructor(inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_video_list_editor, this); @@ -57,6 +65,26 @@ abstract class VideoListEditorView : LinearLayout { _buttonDownload.visibility = View.GONE; _buttonExport = findViewById(R.id.button_export); _buttonExport.visibility = View.GONE; + _buttonSearch = findViewById(R.id.button_search); + + _search = findViewById(R.id.search_bar); + _search.visibility = View.GONE; + _search.onSearchChanged.subscribe { + updateVideoFilters(); + } + + _buttonSearch.setOnClickListener { + if(_search.isVisible) { + _search.visibility = View.GONE; + _search.textSearch.text = ""; + updateVideoFilters(); + _buttonSearch.setImageResource(R.drawable.ic_search); + } + else { + _search.visibility = View.VISIBLE; + _buttonSearch.setImageResource(R.drawable.ic_search_off); + } + } _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; @@ -171,9 +199,22 @@ abstract class VideoListEditorView : LinearLayout { .load(R.drawable.placeholder_video_thumbnail) .into(_imagePlaylistThumbnail) } - + _loadedVideos = videos; + _loadedVideosCanEdit = canEdit; _videoListEditorView.setVideos(videos, canEdit); } + fun filterVideos(videos: List): List { + var toReturn = videos; + val searchStr = _search.textSearch.text + if(!searchStr.isNullOrBlank()) + toReturn = toReturn.filter { it.name.contains(searchStr, true) || it.author.name.contains(searchStr, true) }; + return toReturn; + } + + fun updateVideoFilters() { + val videos = _loadedVideos ?: return; + _videoListEditorView.setVideos(filterVideos(videos), _loadedVideosCanEdit); + } protected fun setButtonDownloadVisible(isVisible: Boolean) { _buttonDownload.visibility = if (isVisible) View.VISIBLE else View.GONE; diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index 522647de..cad49efd 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -214,5 +214,38 @@ class VideoHelper { } else return 0; } + + fun mediaExtensionToMimetype(extension: String): String? { + return videoExtensionToMimetype(extension) ?: audioExtensionToMimetype(extension); + } + fun videoExtensionToMimetype(extension: String): String? { + val extensionTrimmed = extension.trim('.').lowercase(); + return when (extensionTrimmed) { + "mp4" -> return "video/mp4"; + "webm" -> return "video/webm"; + "m3u8" -> return "video/x-mpegURL"; + "3gp" -> return "video/3gpp"; + "mov" -> return "video/quicktime"; + "mkv" -> return "video/x-matroska"; + "mp4a" -> return "audio/vnd.apple.mpegurl"; + "mpga" -> return "audio/mpga"; + "mp3" -> return "audio/mp3"; + "webm" -> return "audio/webm"; + "3gp" -> return "audio/3gpp"; + else -> null; + } + } + fun audioExtensionToMimetype(extension: String): String? { + val extensionTrimmed = extension.trim('.').lowercase(); + return when (extensionTrimmed) { + "mkv" -> return "audio/x-matroska"; + "mp4a" -> return "audio/vnd.apple.mpegurl"; + "mpga" -> return "audio/mpga"; + "mp3" -> return "audio/mp3"; + "webm" -> return "audio/webm"; + "3gp" -> return "audio/3gpp"; + else -> null; + } + } } } diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 00594df7..97fe6408 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -8,6 +8,7 @@ import com.bumptech.glide.Glide import com.futo.platformplayer.PresetImages import com.futo.platformplayer.R import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateSubscriptions import kotlinx.serialization.Contextual import kotlinx.serialization.Transient import java.io.File @@ -35,6 +36,12 @@ data class ImageVariable( } else if(!url.isNullOrEmpty()) { Glide.with(imageView) .load(url) + .error(if(!subscriptionUrl.isNullOrBlank()) StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail else null) + .placeholder(R.drawable.placeholder_channel_thumbnail) + .into(imageView); + } else if(!subscriptionUrl.isNullOrEmpty()) { + Glide.with(imageView) + .load(StateSubscriptions.instance.getSubscription(subscriptionUrl!!)?.channel?.thumbnail) .placeholder(R.drawable.placeholder_channel_thumbnail) .into(imageView); } else if(!presetName.isNullOrEmpty()) { 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 15235017..123b0320 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -30,6 +30,7 @@ import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve +import com.futo.platformplayer.subsexchange.ExchangeContract import kotlinx.coroutines.CoroutineScope import java.time.OffsetDateTime import java.util.concurrent.ExecutionException @@ -77,22 +78,31 @@ abstract class SubscriptionsTaskFetchAlgorithm( val exs: ArrayList = arrayListOf(); - - - val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; - val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null; - if(contract?.provided?.isNotEmpty() == true) - Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + var contract: ExchangeContract? = null; var providedTasks: MutableList? = null; - if(contract != null && contract.required.isNotEmpty()){ - providedTasks = mutableListOf() - for(task in tasks.toList()){ - if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { - providedTasks.add(task); - tasks.remove(task); + + try { + val contractableTasks = + tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + contract = + if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { + ChannelRequest(it.url) + }.toTypedArray()) else null; + if (contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + if (contract != null && contract.required.isNotEmpty()) { + providedTasks = mutableListOf() + for (task in tasks.toList()) { + if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { + providedTasks.add(task); + tasks.remove(task); + } } } } + catch(ex: Throwable){ + Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); + } val failedPlugins = mutableListOf(); val cachedChannels = mutableListOf() diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt index 5d5a6a50..b8fb8a77 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/ImageVariableOverlay.kt @@ -98,7 +98,11 @@ class ImageVariableOverlay: ConstraintLayout { UIDialogs.toast(context, "No thumbnail found"); return@subscribe; } - _selected = ImageVariable(it.channel.thumbnail); + val channelUrl = it.channel.url; + _selected = ImageVariable(it.channel.thumbnail).let { + it.subscriptionUrl = channelUrl; + return@let it; + } updateSelected(); }; }; diff --git a/app/src/main/res/drawable/ic_search_off.xml b/app/src/main/res/drawable/ic_search_off.xml new file mode 100644 index 00000000..08810c6c --- /dev/null +++ b/app/src/main/res/drawable/ic_search_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml index cd2050b6..b8ca23df 100644 --- a/app/src/main/res/layout/fragment_playlists.xml +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -13,7 +13,7 @@ @@ -87,7 +87,7 @@ - - + + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + + + - @@ -136,7 +183,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" - app:layout_constraintTop_toBottomOf="@id/text_view_all" + app:layout_constraintTop_toBottomOf="@id/playlists_filter_container" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" android:paddingTop="10dp" diff --git a/app/src/main/res/layout/fragment_video_list_editor.xml b/app/src/main/res/layout/fragment_video_list_editor.xml index a906421b..f86b69e4 100644 --- a/app/src/main/res/layout/fragment_video_list_editor.xml +++ b/app/src/main/res/layout/fragment_video_list_editor.xml @@ -30,7 +30,7 @@ android:orientation="vertical"> + android:layout_height="wrap_content"> + @@ -116,6 +132,8 @@ app:layout_constraintLeft_toLeftOf="@id/container_buttons" app:layout_constraintBottom_toTopOf="@id/container_buttons" /> + + + app:srcCompat="@drawable/ic_search" + app:tint="@color/white" + android:padding="5dp" + android:scaleType="fitCenter" /> + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 256fe1ef..9d82ff85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -973,6 +973,16 @@ Release Date (Oldest) Release Date (Newest) + + Name (Ascending) + Name (Descending) + Modified Date (Oldest) + Modified Date (Newest) + Creation Date (Oldest) + Creation Date (Newest) + Play Date (Oldest) + Play Date (Newest) + Preview List From 0a59e04f199946a67edcef6a945999cd495957ea Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 2 Apr 2025 23:40:37 +0200 Subject: [PATCH 040/128] Fix ui offset issue when opening video through search url --- .../mainactivity/main/ContentSearchResultsFragment.kt | 11 +++++++++-- .../fragment/mainactivity/main/SuggestionsFragment.kt | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index a078eb0c..b8b0b567 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.structures.IPager @@ -160,8 +161,14 @@ class ContentSearchResultsFragment : MainFragment() { navigate(it); else if(StatePlatform.instance.hasEnabledChannelClient(it)) navigate(it); - else - navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); + } + } } else setQuery(it, true); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index 95055d0a..a07de94e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger @@ -122,8 +123,14 @@ class SuggestionsFragment : MainFragment { navigate(it); else if(StatePlatform.instance.hasEnabledChannelClient(it)) navigate(it); - else - navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); + } + } } else navigate(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); From 7d64003d1c97669dceb885847fd11ea27c78d56b Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Fri, 4 Apr 2025 00:37:26 +0200 Subject: [PATCH 041/128] Feed filter loading improved, home filters support, various peripheral stuff --- .../java/com/futo/platformplayer/Settings.kt | 5 +- .../futo/platformplayer/UISlideOverlays.kt | 27 ++-- .../media/models/contents/IPlatformContent.kt | 1 + .../models/video/SerializedPlatformVideo.kt | 2 + .../api/media/structures/IRefreshPager.kt | 2 +- .../api/media/structures/ReusablePager.kt | 122 +++++++++++++++++- .../fragment/mainactivity/main/FeedView.kt | 57 ++++++-- .../mainactivity/main/HomeFragment.kt | 114 +++++++++++++--- .../serializers/PlatformContentSerializer.kt | 4 +- .../states/StateSubscriptions.kt | 2 +- .../stores/StringArrayStorage.kt | 15 +++ .../SubscriptionsTaskFetchAlgorithm.kt | 1 + .../subsexchange/ChannelResult.kt | 9 +- .../subsexchange/SubsExchangeClient.kt | 1 + .../futo/platformplayer/views/ToggleBar.kt | 30 ++++- .../views/others/ToggleTagView.kt | 35 ++++- .../overlays/slideup/SlideUpMenuOverlay.kt | 7 + app/src/main/res/layout/view_toggle_bar.xml | 6 +- app/src/main/res/layout/view_toggle_tag.xml | 31 +++-- app/src/main/res/values/strings.xml | 2 + 20 files changed, 411 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 989997c3..f68a60f9 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -205,7 +205,7 @@ class Settings : FragmentedStorageFileJson() { var home = HomeSettings(); @Serializable class HomeSettings { - @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5) + @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3) @DropdownFieldOptionsId(R.array.feed_style) var homeFeedStyle: Int = 1; @@ -216,6 +216,9 @@ class Settings : FragmentedStorageFileJson() { return FeedStyle.THUMBNAIL; } + @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) + var showHomeFilters: Boolean = true; + @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index a1aa71b7..874ffd4f 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -1148,7 +1148,7 @@ class UISlideOverlays { container.context.getString(R.string.decide_which_buttons_should_be_pinned), tag = "", call = { - showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) { + showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order), (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }, { val selected = it .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } } .filter { it != null } @@ -1156,7 +1156,7 @@ class UISlideOverlays { .toList(); onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) }); - } + }); }, invokeParent = false )) @@ -1164,29 +1164,40 @@ class UISlideOverlays { return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() }; } - - fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit) { + fun showOrderOverlay(container: ViewGroup, title: String, options: List>, onOrdered: (List)->Unit, description: String? = null) { val selection: MutableList = mutableListOf(); var overlay: SlideUpMenuOverlay? = null; overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true, - options.map { SlideUpMenuItem( + listOf( + if(!description.isNullOrEmpty()) SlideUpMenuGroup(container.context, "", description, "", listOf()) else null, + ).filterNotNull() + + (options.map { SlideUpMenuItem( container.context, R.drawable.ic_move_up, it.first, "", tag = it.second, call = { + val overlayItem = overlay?.getSlideUpItemByTag(it.second); if(overlay!!.selectOption(null, it.second, true, true)) { - if(!selection.contains(it.second)) + if(!selection.contains(it.second)) { selection.add(it.second); - } else + if(overlayItem != null) { + overlayItem.setSubText(selection.indexOf(it.second).toString()); + } + } + } else { selection.remove(it.second); + if(overlayItem != null) { + overlayItem.setSubText(""); + } + } }, invokeParent = false ) - }); + })); overlay.onOK.subscribe { onOrdered.invoke(selection); overlay.hide(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index edb1caa3..a823316a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.contents import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.models.PlatformAuthorLink import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames import java.time.OffsetDateTime interface IPlatformContent { 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 68bb5cb9..c9e02d92 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 @@ -10,6 +10,7 @@ import com.futo.polycentric.core.combineHashCodes import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNames import java.time.OffsetDateTime @kotlinx.serialization.Serializable @@ -20,6 +21,7 @@ open class SerializedPlatformVideo( override val thumbnails: Thumbnails, override val author: PlatformAuthorLink, @kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class) + @JsonNames("datetime", "dateTime") override val datetime: OffsetDateTime? = null, override val url: String, override val shareUrl: String = "", diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt index 34a9e41a..375f9343 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/IRefreshPager.kt @@ -6,7 +6,7 @@ import com.futo.platformplayer.constructs.Event1 * A RefreshPager represents a pager that can be modified overtime (eg. By getting more results later, by recreating the pager) * When the onPagerChanged event is emitted, a new pager instance is passed, or requested via getCurrentPager */ -interface IRefreshPager { +interface IRefreshPager: IPager { val onPagerChanged: Event1>; val onPagerError: Event1; diff --git a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt index 45f6aea5..ee1b39f2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/structures/ReusablePager.kt @@ -1,5 +1,7 @@ package com.futo.platformplayer.api.media.structures +import com.futo.platformplayer.api.media.structures.ReusablePager.Window +import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.logging.Logger /** @@ -9,8 +11,8 @@ import com.futo.platformplayer.logging.Logger * A "Window" is effectively a pager that just reads previous results from the shared results, but when the end is reached, it will call nextPage on the parent if possible for new results. * This allows multiple Windows to exist of the same pager, without messing with position, or duplicate requests */ -class ReusablePager: INestedPager, IPager { - private val _pager: IPager; +open class ReusablePager: INestedPager, IReusablePager { + protected var _pager: IPager; val previousResults = arrayListOf(); constructor(subPager: IPager) { @@ -44,7 +46,7 @@ class ReusablePager: INestedPager, IPager { return previousResults; } - fun getWindow(): Window { + override fun getWindow(): Window { return Window(this); } @@ -95,4 +97,118 @@ class ReusablePager: INestedPager, IPager { return ReusablePager(this); } } +} + + +public class ReusableRefreshPager: INestedPager, IReusablePager { + protected var _pager: IRefreshPager; + val previousResults = arrayListOf(); + + private var _currentPage: IPager; + + + val onPagerChanged = Event1>() + val onPagerError = Event1() + + constructor(subPager: IRefreshPager) { + this._pager = subPager; + _currentPage = this; + synchronized(previousResults) { + previousResults.addAll(subPager.getResults()); + } + _pager.onPagerError.subscribe(onPagerError::emit); + _pager.onPagerChanged.subscribe { + _currentPage = it; + synchronized(previousResults) { + previousResults.clear(); + previousResults.addAll(it.getResults()); + } + + onPagerChanged.emit(_currentPage); + }; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + if(query(_pager)) + return _pager; + else if(_pager is INestedPager<*>) + return (_pager as INestedPager).findPager(query); + return null; + } + + override fun hasMorePages(): Boolean { + return _pager.hasMorePages(); + } + + override fun nextPage() { + _pager.nextPage(); + } + + override fun getResults(): List { + val results = _pager.getResults(); + synchronized(previousResults) { + previousResults.addAll(results); + } + return previousResults; + } + + override fun getWindow(): RefreshWindow { + return RefreshWindow(this); + } + + + class RefreshWindow: IPager, INestedPager, IRefreshPager { + private val _parent: ReusableRefreshPager; + private var _position: Int = 0; + private var _read: Int = 0; + + private var _currentResults: List; + + override val onPagerChanged = Event1>(); + override val onPagerError = Event1(); + + + override fun getCurrentPager(): IPager { + return _parent.getWindow(); + } + + constructor(parent: ReusableRefreshPager) { + _parent = parent; + + synchronized(_parent.previousResults) { + _currentResults = _parent.previousResults.toList(); + _read += _currentResults.size; + } + parent.onPagerChanged.subscribe(onPagerChanged::emit); + parent.onPagerError.subscribe(onPagerError::emit); + } + + + override fun hasMorePages(): Boolean { + return _parent.previousResults.size > _read || _parent.hasMorePages(); + } + + override fun nextPage() { + synchronized(_parent.previousResults) { + if (_parent.previousResults.size <= _read) { + _parent.nextPage(); + _parent.getResults(); + } + _currentResults = _parent.previousResults.drop(_read).toList(); + _read += _currentResults.size; + } + } + + override fun getResults(): List { + return _currentResults; + } + + override fun findPager(query: (IPager) -> Boolean): IPager? { + return _parent.findPager(query); + } + } +} + +interface IReusablePager: IPager { + fun getWindow(): IPager; } \ No newline at end of file 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 af033c51..58868ee4 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 @@ -3,12 +3,15 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.content.Context import android.content.res.Configuration import android.graphics.Color +import android.util.DisplayMetrics +import android.view.Display import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -20,6 +23,7 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.others.TagsView @@ -28,7 +32,9 @@ import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.announcements.AnnouncementView import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.OffsetDateTime import kotlin.math.max @@ -68,6 +74,7 @@ abstract class FeedView : L private val _scrollListener: RecyclerView.OnScrollListener; private var _automaticNextPageCounter = 0; + private val _automaticBackoff = arrayOf(0, 500, 1000, 1000, 2000, 5000, 5000, 5000); constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder>? = null) : super(inflater.context) { this.fragment = fragment; @@ -129,6 +136,7 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { + if (it is IAsyncPager<*>) it.nextPageAsync(); else @@ -182,26 +190,53 @@ abstract class FeedView : L private fun ensureEnoughContentVisible(filteredResults: List) { val canScroll = if (recyclerData.results.isEmpty()) false else { + val height = resources.displayMetrics.heightPixels; + val layoutManager = recyclerData.layoutManager val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() - - if (firstVisibleItemPosition != RecyclerView.NO_POSITION) { - val firstVisibleView = layoutManager.findViewByPosition(firstVisibleItemPosition) - val itemHeight = firstVisibleView?.height ?: 0 - val occupiedSpace = recyclerData.results.size / recyclerData.layoutManager.spanCount * itemHeight - val recyclerViewHeight = _recyclerResults.height - Logger.i(TAG, "ensureEnoughContentVisible loadNextPage occupiedSpace=$occupiedSpace recyclerViewHeight=$recyclerViewHeight") - occupiedSpace >= recyclerViewHeight + val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; + val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); + val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; + if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { + false; + } + else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) { + false; } else { - false + true; } } + Logger.i(TAG, "ensureEnoughContentVisible loadNextPage canScroll=$canScroll _automaticNextPageCounter=$_automaticNextPageCounter") if (!canScroll || filteredResults.isEmpty()) { _automaticNextPageCounter++ - if(_automaticNextPageCounter <= 4) - loadNextPage() + if(_automaticNextPageCounter < _automaticBackoff.size) { + if(_automaticNextPageCounter > 0) { + val automaticNextPageCounterSaved = _automaticNextPageCounter; + fragment.lifecycleScope.launch(Dispatchers.Default) { + val backoff = _automaticBackoff[Math.min(_automaticBackoff.size - 1, _automaticNextPageCounter)]; + + withContext(Dispatchers.Main) { + setLoading(true); + } + delay(backoff.toLong()); + if(automaticNextPageCounterSaved == _automaticNextPageCounter) { + withContext(Dispatchers.Main) { + loadNextPage(); + } + } + else { + withContext(Dispatchers.Main) { + setLoading(false); + } + } + } + } + else + loadNextPage(); + } } else { + Logger.i(TAG, "ensureEnoughContentVisible automaticNextPageCounter reset"); _automaticNextPageCounter = 0; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index 9cdac8f9..d5e01461 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -5,22 +5,32 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.allViews +import androidx.core.view.contains import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.futo.platformplayer.* +import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.api.media.structures.IRefreshPager +import com.futo.platformplayer.api.media.structures.IReusablePager +import com.futo.platformplayer.api.media.structures.ReusablePager +import com.futo.platformplayer.api.media.structures.ReusableRefreshPager import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptExecutionException import com.futo.platformplayer.engine.exceptions.ScriptImplementationException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.NoResultsView import com.futo.platformplayer.views.ToggleBar @@ -91,6 +101,7 @@ class HomeFragment : MainFragment() { _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems); } + @SuppressLint("ViewConstructor") class HomeView : ContentFeedView { override val feedStyle: FeedStyle get() = Settings.instance.home.getHomeFeedStyle(); @@ -100,11 +111,20 @@ class HomeFragment : MainFragment() { private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar + private var _lastPager: IReusablePager? = null; + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) - .success { loadedResult(it); } + .success { + val wrappedPager = if(it is IRefreshPager) + ReusableRefreshPager(it); + else + ReusablePager(it); + _lastPager = wrappedPager; + loadedResult(wrappedPager.getWindow()); + } .exception { } .exception { Logger.w(ChannelFragment.TAG, "Plugin failure.", it); @@ -208,21 +228,81 @@ class HomeFragment : MainFragment() { private val _filterLock = Object(); private var _toggleRecent = false; + private var _toggleWatched = false; + private var _togglePluginsDisabled = mutableListOf(); + private var _togglesConfig = FragmentedStorage.get("home_toggles"); fun initializeToolbarContent() { - //Not stable enough with current viewport paging, doesn't work with less results, and reloads content instead of just re-filtering existing - /* - _toggleBar = ToggleBar(context).apply { - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); - } - synchronized(_filterLock) { - _toggleBar?.setToggles( - //TODO: loadResults needs to be replaced with an internal reload of the current content - ToggleBar.Toggle("Recent", _toggleRecent) { _toggleRecent = it; loadResults(false) } - ) - } + if(_toolbarContentView.allViews.any { it is ToggleBar }) + _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar }); - _toolbarContentView.addView(_toggleBar, 0); - */ + if(Settings.instance.home.showHomeFilters) { + + if (!_togglesConfig.any()) { + _togglesConfig.set("today", "watched", "plugins"); + _togglesConfig.save(); + } + _toggleBar = ToggleBar(context).apply { + layoutParams = + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + } + _togglePluginsDisabled.clear(); + synchronized(_filterLock) { + val buttonsPlugins = (if (_togglesConfig.contains("plugins")) + (StatePlatform.instance.getEnabledClients() + .map { plugin -> + ToggleBar.Toggle(plugin.name, plugin.icon, true, { + if (it) { + if (_togglePluginsDisabled.contains(plugin.id)) + _togglePluginsDisabled.remove(plugin.id); + } else { + if (!_togglePluginsDisabled.contains(plugin.id)) + _togglePluginsDisabled.add(plugin.id); + } + reloadForFilters(); + }).withTag("plugins") + }) + else listOf()) + val buttons = (listOf( + (if (_togglesConfig.contains("today")) + ToggleBar.Toggle("Today", _toggleRecent) { + _toggleRecent = it; reloadForFilters() + } + .withTag("today") else null), + (if (_togglesConfig.contains("watched")) + ToggleBar.Toggle("Unwatched", _toggleWatched) { + _toggleWatched = it; reloadForFilters() + } + .withTag("watched") else null), + ).filterNotNull() + buttonsPlugins) + .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() + + val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { + showOrderOverlay(_overlayContainer, + "Visible home filters", + listOf( + Pair("Plugins", "plugins"), + Pair("Today", "today"), + Pair("Watched", "watched") + ), + { + val newArray = it.map { it.toString() }.toTypedArray(); + _togglesConfig.set(*(if (newArray.any()) newArray else arrayOf("none"))); + _togglesConfig.save(); + initializeToolbarContent(); + }, + "Select which toggles you want to see in order. You can also choose to hide filters in the Grayjay Settings" + ); + }).asButton(); + + val buttonsOrder = (buttons + listOf(buttonSettings)).toTypedArray(); + _toggleBar?.setToggles(*buttonsOrder); + } + + _toolbarContentView.addView(_toggleBar, 0); + } + } + fun reloadForFilters() { + _lastPager?.let { loadedResult(it.getWindow()) }; } override fun filterResults(results: List): List { @@ -232,7 +312,11 @@ class HomeFragment : MainFragment() { if(StateMeta.instance.isCreatorHidden(it.author.url)) return@filter false; - if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 23) { + if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) + return@filter false; + if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) + return@filter false; + if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) { return@filter false; } 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 02a39160..db540ea1 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/PlatformContentSerializer.kt @@ -19,10 +19,10 @@ import kotlinx.serialization.json.jsonPrimitive class PlatformContentSerializer : JsonContentPolymorphicSerializer(SerializedPlatformContent::class) { override fun selectDeserializer(element: JsonElement): DeserializationStrategy { - val obj = element.jsonObject["contentType"]; + val obj = element.jsonObject["contentType"] ?: element.jsonObject["ContentType"]; //TODO: Remove this temporary fallback..at some point - if(obj == null && element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull != null) + if(obj == null && (element.jsonObject["isLive"]?.jsonPrimitive?.booleanOrNull ?: element.jsonObject["IsLive"]?.jsonPrimitive?.booleanOrNull) != null) return SerializedPlatformVideo.serializer(); if(obj?.jsonPrimitive?.isString != false) { 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 1d1acff6..c92c80b0 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -69,7 +69,7 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); - private val _subsExchangeServer = "https://exchange.grayjay.app/"; + private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/"; private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); init { diff --git a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt index be1e69e3..0d072bba 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/StringArrayStorage.kt @@ -41,4 +41,19 @@ class StringArrayStorage : FragmentedStorageFileJson() { return values.toList(); } } + fun any(): Boolean { + synchronized(values) { + return values.any(); + } + } + fun contains(v: String): Boolean { + synchronized(values) { + return values.contains(v); + } + } + fun indexOf(v: String): Int { + synchronized(values){ + return values.indexOf(v); + } + } } \ No newline at end of file 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 123b0320..ce0e19c2 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -153,6 +153,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( *resolves ); if (resolve != null) { + val invalids = resolve.filter { it.content.any { it.datetime == null } }; UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") for(result in resolve){ val task = providedTasks?.find { it.url == result.channelUrl }; diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt index c13f101c..957d415d 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer +import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.OffsetDateTime @@ -12,12 +13,12 @@ import java.time.OffsetDateTime @Serializable class ChannelResult( @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) - @SerialName("DateTime") + @SerialName("dateTime") var dateTime: OffsetDateTime, - @SerialName("ChannelUrl") + @SerialName("channelUrl") var channelUrl: String, - @SerialName("Content") + @SerialName("content") var content: List, - @SerialName("Channel") + @SerialName("channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index a58e17b0..0a55516f 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -52,6 +52,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + Logger.v("SubsExchangeClient", "Resolve:" + result); return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index be3d8df8..ef2eadbc 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -12,6 +12,7 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.channels.SerializedChannel import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.models.ImageVariable import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.states.StateSubscriptionGroups @@ -46,7 +47,12 @@ class ToggleBar : LinearLayout { _tagsContainer.removeAllViews(); for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { - this.setInfo(button.name, button.isActive); + if(button.icon > 0) + this.setInfo(button.icon, button.name, button.isActive, button.isButton); + else if(button.iconVariable != null) + this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); + else + this.setInfo(button.name, button.isActive, button.isButton); this.onClick.subscribe { button.action(it); }; }); } @@ -55,20 +61,42 @@ class ToggleBar : LinearLayout { class Toggle { val name: String; val icon: Int; + val iconVariable: ImageVariable?; val action: (Boolean)->Unit; val isActive: Boolean; + var isButton: Boolean = false + private set; + var tag: String? = null; + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) { + this.name = name; + this.icon = 0; + this.iconVariable = icon; + this.action = action; + this.isActive = isActive; + } constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { this.name = name; this.icon = icon; + this.iconVariable = null; this.action = action; this.isActive = isActive; } constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { this.name = name; this.icon = 0; + this.iconVariable = null; this.action = action; this.isActive = isActive; } + + fun asButton(): Toggle{ + isButton = true; + return this; + } + fun withTag(str: String): Toggle { + tag = str; + return this; + } } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 27c4e68d..65b13eb0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -4,19 +4,27 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.images.GlideHelper +import com.futo.platformplayer.models.ImageVariable class ToggleTagView : LinearLayout { private val _root: FrameLayout; private val _textTag: TextView; private var _text: String = ""; + private var _image: ImageView; var isActive: Boolean = false private set; + var isButton: Boolean = false + private set; var onClick = Event1(); @@ -24,7 +32,12 @@ class ToggleTagView : LinearLayout { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); _root = findViewById(R.id.root); _textTag = findViewById(R.id.text_tag); - _root.setOnClickListener { setToggle(!isActive); onClick.emit(isActive); } + _image = findViewById(R.id.image_tag); + _root.setOnClickListener { + if(!isButton) + setToggle(!isActive); + onClick.emit(isActive); + } } fun setToggle(isActive: Boolean) { @@ -39,9 +52,27 @@ class ToggleTagView : LinearLayout { } } - fun setInfo(text: String, isActive: Boolean) { + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { _text = text; _textTag.text = text; setToggle(isActive); + _image.setImageResource(imageResource); + _image.visibility = View.VISIBLE; + this.isButton = isButton; + } + fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { + _text = text; + _textTag.text = text; + setToggle(isActive); + image.setImageView(_image, R.drawable.ic_error_pred); + _image.visibility = View.VISIBLE; + this.isButton = isButton; + } + fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { + _image.visibility = View.GONE; + _text = text; + _textTag.text = text; + setToggle(isActive); + this.isButton = isButton; } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt index 89cf1359..58850998 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -113,6 +113,13 @@ class SlideUpMenuOverlay : RelativeLayout { _textOK.visibility = View.VISIBLE; } } + fun getSlideUpItemByTag(itemTag: Any?): SlideUpMenuItem? { + for(view in groupItems){ + if(view is SlideUpMenuItem && view.itemTag == itemTag) + return view; + } + return null; + } fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean { var didSelect = false; diff --git a/app/src/main/res/layout/view_toggle_bar.xml b/app/src/main/res/layout/view_toggle_bar.xml index 3da2f363..7b99d09f 100644 --- a/app/src/main/res/layout/view_toggle_bar.xml +++ b/app/src/main/res/layout/view_toggle_bar.xml @@ -3,14 +3,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - - + \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_tag.xml b/app/src/main/res/layout/view_toggle_tag.xml index 886f2de5..eca7010c 100644 --- a/app/src/main/res/layout/view_toggle_tag.xml +++ b/app/src/main/res/layout/view_toggle_tag.xml @@ -10,16 +10,27 @@ android:layout_marginTop="17dp" android:layout_marginBottom="8dp" android:id="@+id/root"> - - + android:layout_height="match_parent" + android:orientation="horizontal"> + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d82ff85..85549f27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -417,6 +417,8 @@ If subscription groups should be shown above your subscriptions to filter Preview Feed Items When the preview feedstyle is used, if items should auto-preview when scrolling over them + Show Home Filters + If the home filters should be shown above home Log Level Logging Sync Grayjay From b14518edb1e0caade4917d18ad79432c2ff5f2f4 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Sat, 5 Apr 2025 01:02:50 +0200 Subject: [PATCH 042/128] Home filter fixes, persistent sorts, subs exchange fixes, playlist video options --- .../java/com/futo/platformplayer/Settings.kt | 2 + .../mainactivity/main/DownloadsFragment.kt | 28 +++-- .../fragment/mainactivity/main/FeedView.kt | 3 + .../mainactivity/main/HomeFragment.kt | 52 ++++---- .../mainactivity/main/PlaylistFragment.kt | 4 + .../mainactivity/main/PlaylistsFragment.kt | 29 +++-- .../mainactivity/main/VideoDetailView.kt | 3 + .../mainactivity/main/VideoListEditorView.kt | 2 + .../mainactivity/main/WatchLaterFragment.kt | 3 + .../states/StateSubscriptions.kt | 2 +- .../SubscriptionsTaskFetchAlgorithm.kt | 118 +++++++++++------- .../subsexchange/SubsExchangeClient.kt | 8 +- .../views/adapters/VideoListEditorAdapter.kt | 2 + .../adapters/VideoListEditorViewHolder.kt | 7 ++ .../views/lists/VideoListEditorView.kt | 5 + .../views/others/ToggleTagView.kt | 3 + .../views/overlays/QueueEditorOverlay.kt | 6 + app/src/main/res/layout/list_playlist.xml | 42 +++++-- app/src/main/res/layout/view_toggle_tag.xml | 11 +- app/src/main/res/values/strings.xml | 2 + 20 files changed, 218 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index f68a60f9..cedc4316 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -218,6 +218,8 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4) var showHomeFilters: Boolean = true; + @FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5) + var showHomeFiltersPluginNames: Boolean = false; @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) var previewFeedItems: Boolean = true; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index d402a6e2..217165ae 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -21,6 +21,8 @@ import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.toHumanBytesSize import com.futo.platformplayer.toHumanDuration import com.futo.platformplayer.views.AnyInsertedAdapterView @@ -103,12 +105,15 @@ class DownloadsFragment : MainFragment() { private val _listDownloaded: AnyInsertedAdapterView; private var lastDownloads: List? = null; - private var ordering: String? = "nameAsc"; + private var ordering = FragmentedStorage.get("downloads_ordering") constructor(frag: DownloadsFragment, inflater: LayoutInflater): super(frag.requireContext()) { inflater.inflate(R.layout.fragment_downloads, this); _frag = frag; + if(ordering.value.isNullOrBlank()) + ordering.value = "nameAsc"; + _usageUsed = findViewById(R.id.downloads_usage_used); _usageAvailable = findViewById(R.id.downloads_usage_available); _usageProgress = findViewById(R.id.downloads_usage_progress); @@ -132,22 +137,23 @@ class DownloadsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.downloads_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - spinnerSortBy.setSelection(0); + val options = listOf("nameAsc", "nameDesc", "downloadDateAsc", "downloadDateDesc", "releasedAsc", "releasedDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { - 0 -> ordering = "nameAsc" - 1 -> ordering = "nameDesc" - 2 -> ordering = "downloadDateAsc" - 3 -> ordering = "downloadDateDesc" - 4 -> ordering = "releasedAsc" - 5 -> ordering = "releasedDesc" - else -> ordering = null + 0 -> ordering.setAndSave("nameAsc") + 1 -> ordering.setAndSave("nameDesc") + 2 -> ordering.setAndSave("downloadDateAsc") + 3 -> ordering.setAndSave("downloadDateDesc") + 4 -> ordering.setAndSave("releasedAsc") + 5 -> ordering.setAndSave("releasedDesc") + else -> ordering.setAndSave("") } updateContentFilters() } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; + spinnerSortBy.setSelection(Math.max(0, options.indexOf(ordering.value))); _listDownloaded = findViewById(R.id.list_downloaded) .asAnyWithTop(findViewById(R.id.downloads_top)) { @@ -230,8 +236,8 @@ class DownloadsFragment : MainFragment() { var vidsToReturn = vids; if(!_listDownloadSearch.text.isNullOrEmpty()) vidsToReturn = vids.filter { it.name.contains(_listDownloadSearch.text, true) || it.author.name.contains(_listDownloadSearch.text, true) }; - if(!ordering.isNullOrEmpty()) { - vidsToReturn = when(ordering){ + if(!ordering.value.isNullOrEmpty()) { + vidsToReturn = when(ordering.value){ "downloadDateAsc" -> vidsToReturn.sortedBy { it.downloadDate ?: OffsetDateTime.MAX }; "downloadDateDesc" -> vidsToReturn.sortedByDescending { it.downloadDate ?: OffsetDateTime.MIN }; "nameAsc" -> vidsToReturn.sortedBy { it.name.lowercase() } 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 58868ee4..17ca5510 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 @@ -240,6 +240,9 @@ abstract class FeedView : L _automaticNextPageCounter = 0; } } + fun resetAutomaticNextPageCounter(){ + _automaticNextPageCounter = 0; + } protected fun setTextCentered(text: String?) { _textCentered.text = text; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index d5e01461..a450a2ed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.allViews -import androidx.core.view.contains import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import com.futo.platformplayer.* @@ -29,6 +28,7 @@ import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.views.FeedStyle @@ -37,7 +37,6 @@ import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewHolder -import com.futo.platformplayer.views.announcements.AnnouncementView import com.futo.platformplayer.views.buttons.BigButton import kotlinx.coroutines.runBlocking import java.time.OffsetDateTime @@ -49,6 +48,12 @@ class HomeFragment : MainFragment() { private var _view: HomeView? = null; private var _cachedRecyclerData: FeedView.RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + private var _cachedLastPager: IReusablePager? = null + + private var _toggleRecent = false; + private var _toggleWatched = false; + private var _togglePluginsDisabled = mutableListOf(); + fun reloadFeed() { _view?.reloadFeed() @@ -74,7 +79,7 @@ class HomeFragment : MainFragment() { } override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val view = HomeView(this, inflater, _cachedRecyclerData); + val view = HomeView(this, inflater, _cachedRecyclerData, _cachedLastPager); _view = view; return view; } @@ -92,6 +97,7 @@ class HomeFragment : MainFragment() { val view = _view; if (view != null) { _cachedRecyclerData = view.recyclerData; + _cachedLastPager = view.lastPager; view.cleanup(); _view = null; } @@ -111,9 +117,10 @@ class HomeFragment : MainFragment() { private val _taskGetPager: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar - private var _lastPager: IReusablePager? = null; + var lastPager: IReusablePager? = null; - constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null) : super(fragment, inflater, cachedRecyclerData) { + constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null, cachedLastPager: IReusablePager? = null) : super(fragment, inflater, cachedRecyclerData) { + lastPager = cachedLastPager _taskGetPager = TaskHandler>({ fragment.lifecycleScope }, { StatePlatform.instance.getHomeRefresh(fragment.lifecycleScope) }) @@ -122,7 +129,8 @@ class HomeFragment : MainFragment() { ReusableRefreshPager(it); else ReusablePager(it); - _lastPager = wrappedPager; + lastPager = wrappedPager; + resetAutomaticNextPageCounter(); loadedResult(wrappedPager.getWindow()); } .exception { } @@ -227,9 +235,6 @@ class HomeFragment : MainFragment() { } private val _filterLock = Object(); - private var _toggleRecent = false; - private var _toggleWatched = false; - private var _togglePluginsDisabled = mutableListOf(); private var _togglesConfig = FragmentedStorage.get("home_toggles"); fun initializeToolbarContent() { if(_toolbarContentView.allViews.any { it is ToggleBar }) @@ -245,18 +250,19 @@ class HomeFragment : MainFragment() { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } - _togglePluginsDisabled.clear(); + fragment._togglePluginsDisabled.clear(); synchronized(_filterLock) { val buttonsPlugins = (if (_togglesConfig.contains("plugins")) (StatePlatform.instance.getEnabledClients() + .filter { it is JSClient && it.enableInHome } .map { plugin -> - ToggleBar.Toggle(plugin.name, plugin.icon, true, { + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { if (it) { - if (_togglePluginsDisabled.contains(plugin.id)) - _togglePluginsDisabled.remove(plugin.id); + if (fragment._togglePluginsDisabled.contains(plugin.id)) + fragment._togglePluginsDisabled.remove(plugin.id); } else { - if (!_togglePluginsDisabled.contains(plugin.id)) - _togglePluginsDisabled.add(plugin.id); + if (!fragment._togglePluginsDisabled.contains(plugin.id)) + fragment._togglePluginsDisabled.add(plugin.id); } reloadForFilters(); }).withTag("plugins") @@ -264,13 +270,13 @@ class HomeFragment : MainFragment() { else listOf()) val buttons = (listOf( (if (_togglesConfig.contains("today")) - ToggleBar.Toggle("Today", _toggleRecent) { - _toggleRecent = it; reloadForFilters() + ToggleBar.Toggle("Today", fragment._toggleRecent) { + fragment._toggleRecent = it; reloadForFilters() } .withTag("today") else null), (if (_togglesConfig.contains("watched")) - ToggleBar.Toggle("Unwatched", _toggleWatched) { - _toggleWatched = it; reloadForFilters() + ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { + fragment._toggleWatched = it; reloadForFilters() } .withTag("watched") else null), ).filterNotNull() + buttonsPlugins) @@ -302,7 +308,7 @@ class HomeFragment : MainFragment() { } } fun reloadForFilters() { - _lastPager?.let { loadedResult(it.getWindow()) }; + lastPager?.let { loadedResult(it.getWindow()) }; } override fun filterResults(results: List): List { @@ -312,11 +318,11 @@ class HomeFragment : MainFragment() { if(StateMeta.instance.isCreatorHidden(it.author.url)) return@filter false; - if(_toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) + if(fragment._toggleRecent && (it.datetime?.getNowDiffHours() ?: 0) > 25) return@filter false; - if(_toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) + if(fragment._toggleWatched && StateHistory.instance.isHistoryWatched(it.url, 0)) return@filter false; - if(_togglePluginsDisabled.any() && it.id.pluginId != null && _togglePluginsDisabled.contains(it.id.pluginId)) { + if(fragment._togglePluginsDisabled.any() && it.id.pluginId != null && fragment._togglePluginsDisabled.contains(it.id.pluginId)) { return@filter false; } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt index b58e3ee2..c56585b0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt @@ -326,6 +326,10 @@ class PlaylistFragment : MainFragment() { playlist.videos = ArrayList(playlist.videos.filter { it != video }); StatePlaylists.instance.createOrUpdatePlaylist(playlist); } + + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } override fun onVideoClicked(video: IPlatformVideo) { val playlist = _playlist; if (playlist != null) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt index 9d188415..58caabe1 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistsFragment.kt @@ -26,6 +26,8 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.views.SearchView import com.futo.platformplayer.views.adapters.* import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay @@ -82,7 +84,7 @@ class PlaylistsFragment : MainFragment() { private var _listPlaylistsSearch: EditText; - private var _ordering: String? = null; + private var _ordering = FragmentedStorage.get("playlists_ordering") constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { @@ -145,24 +147,25 @@ class PlaylistsFragment : MainFragment() { spinnerSortBy.adapter = ArrayAdapter(context, R.layout.spinner_item_simple, resources.getStringArray(R.array.playlists_sortby_array)).also { it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); }; - spinnerSortBy.setSelection(0); + val options = listOf("nameAsc", "nameDesc", "dateEditAsc", "dateEditDesc", "dateCreateAsc", "dateCreateDesc", "datePlayAsc", "datePlayDesc"); spinnerSortBy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when(pos) { - 0 -> _ordering = "nameAsc" - 1 -> _ordering = "nameDesc" - 2 -> _ordering = "dateEditAsc" - 3 -> _ordering = "dateEditDesc" - 4 -> _ordering = "dateCreateAsc" - 5 -> _ordering = "dateCreateDesc" - 6 -> _ordering = "datePlayAsc" - 7 -> _ordering = "datePlayDesc" - else -> _ordering = null + 0 -> _ordering.setAndSave("nameAsc") + 1 -> _ordering.setAndSave("nameDesc") + 2 -> _ordering.setAndSave("dateEditAsc") + 3 -> _ordering.setAndSave("dateEditDesc") + 4 -> _ordering.setAndSave("dateCreateAsc") + 5 -> _ordering.setAndSave("dateCreateDesc") + 6 -> _ordering.setAndSave("datePlayAsc") + 7 -> _ordering.setAndSave("datePlayDesc") + else -> _ordering.setAndSave("") } updatePlaylistsFiltering() } override fun onNothingSelected(parent: AdapterView<*>?) = Unit }; + spinnerSortBy.setSelection(Math.max(0, options.indexOf(_ordering.value))); findViewById(R.id.text_view_all).setOnClickListener { _fragment.navigate(context.getString(R.string.watch_later)); }; @@ -214,8 +217,8 @@ class PlaylistsFragment : MainFragment() { var playlistsToReturn = pls; if(!_listPlaylistsSearch.text.isNullOrEmpty()) playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) }; - if(!_ordering.isNullOrEmpty()){ - playlistsToReturn = when(_ordering){ + if(!_ordering.value.isNullOrEmpty()){ + playlistsToReturn = when(_ordering.value){ "nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() } "nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() }; "dateEditAsc" -> playlistsToReturn.sortedBy { it.dateUpdate ?: OffsetDateTime.MAX }; 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 25b03cb1..7176d125 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 @@ -693,6 +693,9 @@ class VideoDetailView : ConstraintLayout { _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; + _container_content_queue.onOptions.subscribe { + UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer); + } _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_support.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_browser.onClose.subscribe { switchContentView(_container_content_main); }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index 441f7421..c0383b89 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -104,6 +104,7 @@ abstract class VideoListEditorView : LinearLayout { videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); + videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); _videoListEditorView = videoListEditorView; @@ -122,6 +123,7 @@ abstract class VideoListEditorView : LinearLayout { open fun onShuffleClick() { } open fun onEditClick() { } open fun onVideoRemoved(video: IPlatformVideo) {} + open fun onVideoOptions(video: IPlatformVideo) {} open fun onVideoOrderChanged(videos : List) {} open fun onVideoClicked(video: IPlatformVideo) { diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt index 4d3c65bd..9d66c3f7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/WatchLaterFragment.kt @@ -103,6 +103,9 @@ class WatchLaterFragment : MainFragment() { StatePlaylists.instance.removeFromWatchLater(video, true); } } + override fun onVideoOptions(video: IPlatformVideo) { + UISlideOverlays.showVideoOptionsOverlay(video, overlayContainer); + } override fun onVideoClicked(video: IPlatformVideo) { val watchLater = StatePlaylists.instance.getWatchLater(); 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 c92c80b0..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -69,7 +69,7 @@ class StateSubscriptions { val onSubscriptionsChanged = Event2, Boolean>(); - private val _subsExchangeServer = "http://10.10.15.159"//"https://exchange.grayjay.app/"; + private val _subsExchangeServer = "https://exchange.grayjay.app/"; private val _subscriptionKey = FragmentedStorage.get("sub_exchange_key"); init { 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 ce0e19c2..1a1d503b 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -82,23 +82,30 @@ abstract class SubscriptionsTaskFetchAlgorithm( var providedTasks: MutableList? = null; try { - val contractableTasks = - tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; - contract = - if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { - ChannelRequest(it.url) - }.toTypedArray()) else null; - if (contract?.provided?.isNotEmpty() == true) - Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); - if (contract != null && contract.required.isNotEmpty()) { - providedTasks = mutableListOf() - for (task in tasks.toList()) { - if (!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { - providedTasks.add(task); - tasks.remove(task); + val contractingTime = measureTimeMillis { + val contractableTasks = + tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) }; + contract = + if (contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { + ChannelRequest(it.url) + }.toTypedArray()) else null; + if (contract?.provided?.isNotEmpty() == true) + Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}"); + if (contract != null && contract!!.required.isNotEmpty()) { + providedTasks = mutableListOf() + for (task in tasks.toList()) { + if (!task.fromCache && !task.fromPeek && contract!!.provided.contains(task.url)) { + providedTasks!!.add(task); + tasks.remove(task); + } } } } + if(contract != null) + Logger.i(TAG, "Subscription Exchange contract received in ${contractingTime}ms"); + else if(contractingTime > 100) + Logger.i(TAG, "Subscription Exchange contract failed to received in${contractingTime}ms"); + } catch(ex: Throwable){ Logger.e("SubscriptionsTaskFetchAlgorithm", "Failed to retrieve SubsExchange contract due to: " + ex.message, ex); @@ -109,6 +116,8 @@ abstract class SubscriptionsTaskFetchAlgorithm( val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels); val taskResults = arrayListOf(); + var resolveCount = 0; + var resolveTime = 0L; val timeTotal = measureTimeMillis { for(task in forkTasks) { try { @@ -137,51 +146,68 @@ abstract class SubscriptionsTaskFetchAlgorithm( } }; } - } - //Resolve Subscription Exchange - if(contract != null) { - try { - val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } - ) - }.toTypedArray() - val resolve = subsExchangeClient?.resolveContract( - contract, - *resolves - ); - if (resolve != null) { - val invalids = resolve.filter { it.content.any { it.datetime == null } }; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})") - for(result in resolve){ - val task = providedTasks?.find { it.url == result.channelUrl }; - if(task != null) { - taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); - providedTasks?.remove(task); + //Resolve Subscription Exchange + if(contract != null) { + try { + resolveTime = measureTimeMillis { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + val resolve = subsExchangeClient?.resolveContract( + contract!!, + *resolves + ); + if (resolve != null) { + resolveCount = resolves.size; + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks!!) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); + } } } + Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + } - if (providedTasks != null) { - for(task in providedTasks) { - taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); - } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); } } - catch(ex: Throwable) { - //TODO: fetch remainder after all? - Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); - } } - Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms") + Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms"); + if(resolveCount > 0) { + val selfFetchTime = timeTotal - resolveTime; + val selfFetchCount = tasks.count { !it.fromPeek && !it.fromCache }; + if(selfFetchCount > 0) { + val selfResolvePercentage = resolveCount.toDouble() / selfFetchCount; + val estimateSelfFetchTime = selfFetchTime + selfFetchTime * selfResolvePercentage; + val selfFetchDelta = timeTotal - estimateSelfFetchTime; + if(selfFetchDelta > 0) + UIDialogs.appToast("Subscription Exchange lost ${selfFetchDelta}ms (out of ${timeTotal}ms)", true); + else + UIDialogs.appToast("Subscription Exchange saved ${(selfFetchDelta * -1).toInt()}ms (out of ${timeTotal}ms)", true); + } + } //Cache pagers grouped by channel val groupedPagers = taskResults.groupBy { it.task.sub.channel.url } .map { entry -> val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null; - val liveTasks = entry.value.filter { !it.task.fromCache }; + val liveTasks = entry.value.filter { !it.task.fromCache && it.pager != null }; val cachedTasks = entry.value.filter { it.task.fromCache }; val livePager = if(liveTasks.isNotEmpty()) StateCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }) { onNewCacheHit.emit(sub!!, it); diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 0a55516f..6f38014d 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -28,7 +28,7 @@ import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPublicKeySpec -class SubsExchangeClient(private val server: String, private val privateKey: String) { +class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { private val json = Json { ignoreUnknownKeys = true @@ -40,7 +40,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Contract fun requestContract(vararg channels: ChannelRequest): ExchangeContract { - val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json", contractTimeout) return Json.decodeFromString(data) } suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { @@ -74,9 +74,11 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } // IO methods - private fun post(query: String, body: String, contentType: String): String { + private fun post(query: String, body: String, contentType: String, timeout: Int = 0): String { val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { + if(timeout > 0) + this.connectTimeout = timeout requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt index aa4ee66f..f7c313f5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorAdapter.kt @@ -14,6 +14,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter { val onClick = Event1(); val onRemove = Event1(); + val onOptions = Event1(); var canEdit = false private set; @@ -28,6 +29,7 @@ class VideoListEditorAdapter : RecyclerView.Adapter { val holder = VideoListEditorViewHolder(view, _touchHelper); holder.onRemove.subscribe { v -> onRemove.emit(v); }; + holder.onOptions.subscribe { v -> onOptions.emit(v); }; holder.onClick.subscribe { v -> onClick.emit(v); }; return holder; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 3cf3194b..77df0665 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -32,6 +32,7 @@ class VideoListEditorViewHolder : ViewHolder { private val _containerDuration: LinearLayout; private val _containerLive: LinearLayout; private val _imageRemove: ImageButton; + private val _imageOptions: ImageButton; private val _imageDragDrop: ImageButton; private val _platformIndicator: PlatformIndicator; private val _layoutDownloaded: FrameLayout; @@ -41,6 +42,7 @@ class VideoListEditorViewHolder : ViewHolder { val onClick = Event1(); val onRemove = Event1(); + val onOptions = Event1(); @SuppressLint("ClickableViewAccessibility") constructor(view: View, touchHelper: ItemTouchHelper? = null) : super(view) { @@ -54,6 +56,7 @@ class VideoListEditorViewHolder : ViewHolder { _containerDuration = view.findViewById(R.id.thumbnail_duration_container); _containerLive = view.findViewById(R.id.thumbnail_live_container); _imageRemove = view.findViewById(R.id.image_trash); + _imageOptions = view.findViewById(R.id.image_settings); _imageDragDrop = view.findViewById(R.id.image_drag_drop); _platformIndicator = view.findViewById(R.id.thumbnail_platform); _layoutDownloaded = view.findViewById(R.id.layout_downloaded); @@ -74,6 +77,10 @@ class VideoListEditorViewHolder : ViewHolder { val v = video ?: return@setOnClickListener; onRemove.emit(v); }; + _imageOptions?.setOnClickListener { + val v = video ?: return@setOnClickListener; + onOptions.emit(v); + } } fun bind(v: IPlatformVideo, canEdit: Boolean) { diff --git a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt index 3bfce0be..08d32ac3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/lists/VideoListEditorView.kt @@ -8,6 +8,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 @@ -22,6 +23,7 @@ class VideoListEditorView : FrameLayout { val onVideoOrderChanged = Event1>() val onVideoRemoved = Event1(); + val onVideoOptions = Event1(); val onVideoClicked = Event1(); val isEmpty get() = _videos.isEmpty(); @@ -54,6 +56,9 @@ class VideoListEditorView : FrameLayout { } }; + adapterVideos.onOptions.subscribe { v -> + onVideoOptions?.emit(v); + } adapterVideos.onRemove.subscribe { v -> val executeDelete = { synchronized(_videos) { diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 65b13eb0..059405ad 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -58,6 +58,7 @@ class ToggleTagView : LinearLayout { setToggle(isActive); _image.setImageResource(imageResource); _image.visibility = View.VISIBLE; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; } fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) { @@ -66,12 +67,14 @@ class ToggleTagView : LinearLayout { setToggle(isActive); image.setImageView(_image, R.drawable.ic_error_pred); _image.visibility = View.VISIBLE; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; this.isButton = isButton; } fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) { _image.visibility = View.GONE; _text = text; _textTag.text = text; + _textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE; setToggle(isActive); this.isButton = isButton; } diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt index a7181e90..53982097 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/QueueEditorOverlay.kt @@ -8,7 +8,9 @@ import android.widget.LinearLayout import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.R import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event0 +import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.views.lists.VideoListEditorView import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay @@ -23,6 +25,7 @@ class QueueEditorOverlay : LinearLayout { private val _overlayContainer: FrameLayout; + val onOptions = Event1(); val onClose = Event0(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { @@ -35,6 +38,9 @@ class QueueEditorOverlay : LinearLayout { _topbar.onClose.subscribe(this, onClose::emit); _editor.onVideoOrderChanged.subscribe { StatePlayer.instance.setQueueWithExisting(it) } + _editor.onVideoOptions.subscribe { v -> + onOptions?.emit(v); + } _editor.onVideoRemoved.subscribe { v -> StatePlayer.instance.removeFromQueue(v); _topbar.setInfo(context.getString(R.string.queue), "${StatePlayer.instance.queueSize} " + context.getString(R.string.videos)); diff --git a/app/src/main/res/layout/list_playlist.xml b/app/src/main/res/layout/list_playlist.xml index d51cdfc5..c9ea9927 100644 --- a/app/src/main/res/layout/list_playlist.xml +++ b/app/src/main/res/layout/list_playlist.xml @@ -135,7 +135,7 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintBottom_toTopOf="@id/text_author" android:layout_marginStart="10dp" /> @@ -152,7 +152,7 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toBottomOf="@id/text_video_name" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" app:layout_constraintBottom_toTopOf="@id/text_video_metadata" android:layout_marginStart="10dp" /> @@ -169,19 +169,35 @@ android:ellipsize="end" app:layout_constraintLeft_toRightOf="@id/layout_video_thumbnail" app:layout_constraintTop_toBottomOf="@id/text_author" - app:layout_constraintRight_toLeftOf="@id/image_trash" + app:layout_constraintRight_toLeftOf="@id/buttons" android:layout_marginStart="10dp" /> - + app:layout_constraintBottom_toBottomOf="@id/layout_video_thumbnail" > + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_toggle_tag.xml b/app/src/main/res/layout/view_toggle_tag.xml index eca7010c..5f285bd2 100644 --- a/app/src/main/res/layout/view_toggle_tag.xml +++ b/app/src/main/res/layout/view_toggle_tag.xml @@ -3,8 +3,8 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="wrap_content" android:layout_height="32dp" - android:paddingStart="15dp" - android:paddingEnd="15dp" + android:paddingStart="12dp" + android:paddingEnd="12dp" android:background="@drawable/background_pill" android:layout_marginEnd="6dp" android:layout_marginTop="17dp" @@ -19,12 +19,15 @@ android:visibility="gone" android:layout_width="24dp" android:layout_height="24dp" - android:layout_marginRight="5dp" - android:layout_marginTop="4dp" /> + android:layout_gravity="center" + android:layout_marginLeft="2.5dp" + android:layout_marginRight="2.5dp" /> When the preview feedstyle is used, if items should auto-preview when scrolling over them Show Home Filters If the home filters should be shown above home + Home filter Plugin Names + If home filters should show full plugin names or just icons Log Level Logging Sync Grayjay From 7b355139fb9fbc79578af84c5b65aabec41faa07 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 7 Apr 2025 23:31:00 +0200 Subject: [PATCH 043/128] Subscription persistence fixes, home toggle fixes, subs exchange gzip, etc --- .../java/com/futo/platformplayer/Utility.kt | 35 ++++++++ .../mainactivity/main/HomeFragment.kt | 38 ++++++--- .../main/SubscriptionsFeedFragment.kt | 41 ++++++---- .../SubscriptionsTaskFetchAlgorithm.kt | 80 ++++++++++++------- .../subsexchange/SubsExchangeClient.kt | 35 +++++--- .../futo/platformplayer/views/ToggleBar.kt | 10 +-- .../views/others/ToggleTagView.kt | 24 +++++- .../views/subscriptions/SubscriptionBar.kt | 8 +- 8 files changed, 191 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 64efb992..5cd5d26f 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -27,14 +27,18 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.others.PlatformLinkMovementMethod import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import java.time.OffsetDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz "; fun getRandomString(sizeOfRandomString: Int): String { @@ -279,3 +283,34 @@ fun ByteBuffer.toUtf8String(): String { get(remainingBytes) return String(remainingBytes, Charsets.UTF_8) } + + +fun ByteArray.toGzip(): ByteArray { + if (this == null || this.isEmpty()) return ByteArray(0) + + val gzipTimeStart = OffsetDateTime.now(); + + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzip -> + gzip.write(this) + } + val result = outputStream.toByteArray(); + Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms"); + return result; +} + +fun ByteArray.fromGzip(): ByteArray { + if (this == null || this.isEmpty()) return ByteArray(0) + + val inputStream = ByteArrayInputStream(this) + val outputStream = ByteArrayOutputStream() + + GZIPInputStream(inputStream).use { gzip -> + val buffer = ByteArray(1024) + var bytesRead: Int + while (gzip.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + return outputStream.toByteArray() +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt index a450a2ed..988d7a3f 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/HomeFragment.kt @@ -250,39 +250,53 @@ class HomeFragment : MainFragment() { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } - fragment._togglePluginsDisabled.clear(); + synchronized(_filterLock) { - val buttonsPlugins = (if (_togglesConfig.contains("plugins")) + var buttonsPlugins: List = listOf() + buttonsPlugins = (if (_togglesConfig.contains("plugins")) (StatePlatform.instance.getEnabledClients() .filter { it is JSClient && it.enableInHome } .map { plugin -> - ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { - if (it) { + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) plugin.name else "", plugin.icon, !fragment._togglePluginsDisabled.contains(plugin.id), { view, active -> + var dontSwap = false; + if (active) { if (fragment._togglePluginsDisabled.contains(plugin.id)) fragment._togglePluginsDisabled.remove(plugin.id); } else { - if (!fragment._togglePluginsDisabled.contains(plugin.id)) - fragment._togglePluginsDisabled.add(plugin.id); + if (!fragment._togglePluginsDisabled.contains(plugin.id)) { + val enabledClients = StatePlatform.instance.getEnabledClients(); + val availableAfterDisable = enabledClients.count { !fragment._togglePluginsDisabled.contains(it.id) && it.id != plugin.id }; + if(availableAfterDisable > 0) + fragment._togglePluginsDisabled.add(plugin.id); + else { + UIDialogs.appToast("Home needs atleast 1 plugin active"); + dontSwap = true; + } + } + } + if(!dontSwap) + reloadForFilters(); + else { + view.setToggle(!active); } - reloadForFilters(); }).withTag("plugins") }) else listOf()) val buttons = (listOf( (if (_togglesConfig.contains("today")) - ToggleBar.Toggle("Today", fragment._toggleRecent) { - fragment._toggleRecent = it; reloadForFilters() + ToggleBar.Toggle("Today", fragment._toggleRecent) { view, active -> + fragment._toggleRecent = active; reloadForFilters() } .withTag("today") else null), (if (_togglesConfig.contains("watched")) - ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { - fragment._toggleWatched = it; reloadForFilters() + ToggleBar.Toggle("Unwatched", fragment._toggleWatched) { view, active -> + fragment._toggleWatched = active; reloadForFilters() } .withTag("watched") else null), ).filterNotNull() + buttonsPlugins) .sortedBy { _togglesConfig.indexOf(it.tag ?: "") } ?: listOf() - val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { + val buttonSettings = ToggleBar.Toggle("", R.drawable.ic_settings, true, { view, active -> showOrderOverlay(_overlayContainer, "Visible home filters", listOf( 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 b4f51b14..83e39a88 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 @@ -18,6 +18,7 @@ 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.fragment.mainactivity.main.SubscriptionsFeedFragment.SubscriptionsFeedView.FeedFilterSettings import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.models.SubscriptionGroup @@ -56,6 +57,9 @@ class SubscriptionsFeedFragment : MainFragment() { private var _group: SubscriptionGroup? = null; private var _cachedRecyclerData: FeedView.RecyclerData, GridLayoutManager, IPager, IPlatformContent, IPlatformContent, InsertedViewHolder>? = null; + private val _filterLock = Object(); + private val _filterSettings = FragmentedStorage.get("subFeedFilter"); + override fun onShownWithView(parameter: Any?, isBack: Boolean) { super.onShownWithView(parameter, isBack); _view?.onShown(); @@ -184,8 +188,6 @@ class SubscriptionsFeedFragment : MainFragment() { return Json.encodeToString(this); } } - private val _filterLock = Object(); - private val _filterSettings = FragmentedStorage.get("subFeedFilter"); private var _bypassRateLimit = false; private val _lastExceptions: List? = null; @@ -284,13 +286,18 @@ class SubscriptionsFeedFragment : MainFragment() { fragment.navigate(g); }; - synchronized(_filterLock) { + synchronized(fragment._filterLock) { _subscriptionBar?.setToggles( - SubscriptionBar.Toggle(context.getString(R.string.videos), _filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), it); }, - SubscriptionBar.Toggle(context.getString(R.string.posts), _filterSettings.allowContentTypes.contains(ContentType.POST)) { toggleFilterContentType(ContentType.POST, it); }, - SubscriptionBar.Toggle(context.getString(R.string.live), _filterSettings.allowLive) { _filterSettings.allowLive = it; _filterSettings.save(); loadResults(false); }, - SubscriptionBar.Toggle(context.getString(R.string.planned), _filterSettings.allowPlanned) { _filterSettings.allowPlanned = it; _filterSettings.save(); loadResults(false); }, - SubscriptionBar.Toggle(context.getString(R.string.watched), _filterSettings.allowWatched) { _filterSettings.allowWatched = it; _filterSettings.save(); loadResults(false); } + SubscriptionBar.Toggle(context.getString(R.string.videos), fragment._filterSettings.allowContentTypes.contains(ContentType.MEDIA)) { view, active -> + toggleFilterContentTypes(listOf(ContentType.MEDIA, ContentType.NESTED_VIDEO), active); }, + SubscriptionBar.Toggle(context.getString(R.string.posts), fragment._filterSettings.allowContentTypes.contains(ContentType.POST)) { view, active -> + toggleFilterContentType(ContentType.POST, active); }, + SubscriptionBar.Toggle(context.getString(R.string.live), fragment._filterSettings.allowLive) { view, active -> + fragment._filterSettings.allowLive = active; fragment._filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle(context.getString(R.string.planned), fragment._filterSettings.allowPlanned) { view, active -> + fragment._filterSettings.allowPlanned = active; fragment._filterSettings.save(); loadResults(false); }, + SubscriptionBar.Toggle(context.getString(R.string.watched), fragment._filterSettings.allowWatched) { view, active -> + fragment._filterSettings.allowWatched = active; fragment._filterSettings.save(); loadResults(false); } ); } @@ -301,13 +308,13 @@ class SubscriptionsFeedFragment : MainFragment() { toggleFilterContentType(contentType, isTrue); } private fun toggleFilterContentType(contentType: ContentType, isTrue: Boolean) { - synchronized(_filterLock) { + synchronized(fragment._filterLock) { if(!isTrue) { - _filterSettings.allowContentTypes.remove(contentType); - } else if(!_filterSettings.allowContentTypes.contains(contentType)) { - _filterSettings.allowContentTypes.add(contentType) + fragment._filterSettings.allowContentTypes.remove(contentType); + } else if(!fragment._filterSettings.allowContentTypes.contains(contentType)) { + fragment._filterSettings.allowContentTypes.add(contentType) } - _filterSettings.save(); + fragment._filterSettings.save(); }; if(Settings.instance.subscriptions.fetchOnTabOpen) { //TODO: Do this different, temporary workaround loadResults(false); @@ -320,9 +327,9 @@ class SubscriptionsFeedFragment : MainFragment() { val nowSoon = OffsetDateTime.now().plusMinutes(5); val filterGroup = subGroup; return results.filter { - val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); + val allowedContentType = fragment._filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType); - if(it is IPlatformVideo && it.duration > 0 && !_filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) + if(it is IPlatformVideo && it.duration > 0 && !fragment._filterSettings.allowWatched && StateHistory.instance.isHistoryWatched(it.url, it.duration)) return@filter false; //TODO: Check against a sub cache @@ -331,11 +338,11 @@ class SubscriptionsFeedFragment : MainFragment() { if(it.datetime?.isAfter(nowSoon) == true) { - if(!_filterSettings.allowPlanned) + if(!fragment._filterSettings.allowPlanned) return@filter false; } - if(_filterSettings.allowLive) { //If allowLive, always show live + if(fragment._filterSettings.allowLive) { //If allowLive, always show live if(it is IPlatformVideo && it.isLive) return@filter true; } 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 1a1d503b..b72e840c 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -15,12 +15,14 @@ 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.api.media.structures.PlatformContentPager +import com.futo.platformplayer.debug.Stopwatch import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCriticalException import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.findNonRuntimeException import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp @@ -32,6 +34,8 @@ import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ExchangeContract import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.util.concurrent.ExecutionException import java.util.concurrent.ForkJoinPool @@ -149,42 +153,56 @@ abstract class SubscriptionsTaskFetchAlgorithm( //Resolve Subscription Exchange if(contract != null) { - try { - resolveTime = measureTimeMillis { - val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { - ChannelResolve( - it.task.url, - it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } - ) - }.toTypedArray() - val resolve = subsExchangeClient?.resolveContract( - contract!!, - *resolves - ); - if (resolve != null) { - resolveCount = resolves.size; - UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") - for(result in resolve){ - val task = providedTasks?.find { it.url == result.channelUrl }; - if(task != null) { - taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); - providedTasks?.remove(task); + fun resolve() { + try { + resolveTime = measureTimeMillis { + val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract!!.required.contains(it.task.url) }.map { + ChannelResolve( + it.task.url, + it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) } + ) + }.toTypedArray() + + val resolveRequestStart = OffsetDateTime.now(); + + val resolve = subsExchangeClient?.resolveContract( + contract!!, + *resolves + ); + + Logger.i(TAG, "Subscription Exchange contract resolved request in ${resolveRequestStart.getNowDiffMiliseconds()}ms"); + + if (resolve != null) { + resolveCount = resolves.size; + UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size}") + for(result in resolve){ + val task = providedTasks?.find { it.url == result.channelUrl }; + if(task != null) { + taskResults.add(SubscriptionTaskResult(task, PlatformContentPager(result.content, result.content.size), null)); + providedTasks?.remove(task); + } + } + } + if (providedTasks != null) { + for(task in providedTasks!!) { + taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); } } } - if (providedTasks != null) { - for(task in providedTasks!!) { - taskResults.add(SubscriptionTaskResult(task, null, IllegalStateException("No data received from exchange"))); - } - } - } - Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + Logger.i(TAG, "Subscription Exchange contract resolved in ${resolveTime}ms"); + } + catch(ex: Throwable) { + //TODO: fetch remainder after all? + Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); + } } - catch(ex: Throwable) { - //TODO: fetch remainder after all? - Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex); - } + if(providedTasks?.size ?: 0 == 0) + scope.launch(Dispatchers.IO) { + resolve(); + } + else + resolve(); } } diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 6f38014d..b0357f56 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -1,10 +1,14 @@ import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm.Companion.TAG import com.futo.platformplayer.subsexchange.ChannelRequest import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResult import com.futo.platformplayer.subsexchange.ExchangeContract import com.futo.platformplayer.subsexchange.ExchangeContractResolve +import com.futo.platformplayer.toGzip +import com.futo.platformplayer.toHumanBytesSize import kotlinx.serialization.* import kotlinx.serialization.json.* import kotlinx.coroutines.Dispatchers @@ -26,6 +30,7 @@ import java.nio.charset.StandardCharsets import java.security.KeyPairGenerator import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.RSAPublicKeySpec +import java.time.OffsetDateTime class SubsExchangeClient(private val server: String, private val privateKey: String, private val contractTimeout: Int = 1000) { @@ -40,24 +45,27 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str // Endpoint: Contract fun requestContract(vararg channels: ChannelRequest): ExchangeContract { - val data = post("/api/Channel/Contract", Json.encodeToString(channels), "application/json", contractTimeout) + val data = post("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json", contractTimeout) return Json.decodeFromString(data) } suspend fun requestContractAsync(vararg channels: ChannelRequest): ExchangeContract { - val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels), "application/json") + val data = postAsync("/api/Channel/Contract", Json.encodeToString(channels).toByteArray(Charsets.UTF_8), "application/json") return Json.decodeFromString(data) } // Endpoint: Resolve fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") - Logger.v("SubsExchangeClient", "Resolve:" + result); + val contractResolveJson = Serializer.json.encodeToString(contractResolve); + val contractResolveTimeStart = OffsetDateTime.now(); + val result = post("/api/Channel/Resolve?contractId=${contract.id}", contractResolveJson.toByteArray(Charsets.UTF_8), "application/json", 0, true) + val contractResolveTime = contractResolveTimeStart.getNowDiffMiliseconds(); + Logger.v("SubsExchangeClient", "Subscription Exchange Resolve Request [${contractResolveTime}ms]:" + result); return Serializer.json.decodeFromString(result) } suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array { val contractResolve = convertResolves(*resolves) - val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json") + val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve).toByteArray(Charsets.UTF_8), "application/json", true) return Serializer.json.decodeFromString(result) } @@ -74,7 +82,7 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } // IO methods - private fun post(query: String, body: String, contentType: String, timeout: Int = 0): String { + private fun post(query: String, body: ByteArray, contentType: String, timeout: Int = 0, gzip: Boolean = false): String { val url = URL("${server.trim('/')}$query") with(url.openConnection() as HttpURLConnection) { if(timeout > 0) @@ -82,7 +90,16 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str requestMethod = "POST" setRequestProperty("Content-Type", contentType) doOutput = true - OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() } + + + if(gzip) { + val gzipData = body.toGzip(); + setRequestProperty("Content-Encoding", "gzip"); + outputStream.write(gzipData); + Logger.i("SubsExchangeClient", "SubsExchange using gzip (${body.size.toHumanBytesSize()} => ${gzipData.size.toHumanBytesSize()}"); + } + else + outputStream.write(body); val status = responseCode; Logger.i("SubsExchangeClient", "POST [${url}]: ${status}"); @@ -105,9 +122,9 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str } } } - private suspend fun postAsync(query: String, body: String, contentType: String): String { + private suspend fun postAsync(query: String, body: ByteArray, contentType: String, gzip: Boolean = false): String { return withContext(Dispatchers.IO) { - post(query, body, contentType) + post(query, body, contentType, 0, gzip) } } diff --git a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt index ef2eadbc..4a545a26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/ToggleBar.kt @@ -53,7 +53,7 @@ class ToggleBar : LinearLayout { this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton); else this.setInfo(button.name, button.isActive, button.isButton); - this.onClick.subscribe { button.action(it); }; + this.onClick.subscribe({ view, enabled -> button.action(view, enabled); }); }); } } @@ -62,27 +62,27 @@ class ToggleBar : LinearLayout { val name: String; val icon: Int; val iconVariable: ImageVariable?; - val action: (Boolean)->Unit; + val action: (ToggleTagView, Boolean)->Unit; val isActive: Boolean; var isButton: Boolean = false private set; var tag: String? = null; - constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.iconVariable = icon; this.action = action; this.isActive = isActive; } - constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = icon; this.iconVariable = null; this.action = action; this.isActive = isActive; } - constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.iconVariable = null; diff --git a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt index 059405ad..3ba65413 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/ToggleTagView.kt @@ -12,8 +12,10 @@ import android.widget.TextView import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.images.GlideHelper import com.futo.platformplayer.models.ImageVariable +import com.futo.platformplayer.views.ToggleBar class ToggleTagView : LinearLayout { private val _root: FrameLayout; @@ -26,7 +28,7 @@ class ToggleTagView : LinearLayout { var isButton: Boolean = false private set; - var onClick = Event1(); + var onClick = Event2(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true); @@ -36,7 +38,7 @@ class ToggleTagView : LinearLayout { _root.setOnClickListener { if(!isButton) setToggle(!isActive); - onClick.emit(isActive); + onClick.emit(this, isActive); } } @@ -52,6 +54,24 @@ class ToggleTagView : LinearLayout { } } + fun setInfo(toggle: ToggleBar.Toggle){ + _text = toggle.name; + _textTag.text = toggle.name; + setToggle(toggle.isActive); + if(toggle.iconVariable != null) { + toggle.iconVariable.setImageView(_image, R.drawable.ic_error_pred); + _image.visibility = View.GONE; + } + else if(toggle.icon > 0) { + _image.setImageResource(toggle.icon); + _image.visibility = View.GONE; + } + else + _image.visibility = View.VISIBLE; + _textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE; + this.isButton = isButton; + } + fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) { _text = text; _textTag.text = text; diff --git a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt index 97eecac4..5cd2ad85 100644 --- a/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt +++ b/app/src/main/java/com/futo/platformplayer/views/subscriptions/SubscriptionBar.kt @@ -158,7 +158,7 @@ class SubscriptionBar : LinearLayout { for(button in buttons) { _tagsContainer.addView(ToggleTagView(context).apply { this.setInfo(button.name, button.isActive); - this.onClick.subscribe { button.action(it); }; + this.onClick.subscribe({ view, value -> button.action(view, value); }); }); } } @@ -166,16 +166,16 @@ class SubscriptionBar : LinearLayout { class Toggle { val name: String; val icon: Int; - val action: (Boolean)->Unit; + val action: (ToggleTagView, Boolean)->Unit; val isActive: Boolean; - constructor(name: String, icon: Int, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = icon; this.action = action; this.isActive = isActive; } - constructor(name: String, isActive: Boolean = false, action: (Boolean)->Unit) { + constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) { this.name = name; this.icon = 0; this.action = action; From ce2a2f85823ca90ca5e07780b8f74b2fb5c23bf1 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Mon, 7 Apr 2025 23:32:57 +0200 Subject: [PATCH 044/128] submods --- app/src/stable/assets/sources/odysee | 2 +- app/src/stable/assets/sources/soundcloud | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/odysee | 2 +- app/src/unstable/assets/sources/soundcloud | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/stable/assets/sources/odysee b/app/src/stable/assets/sources/odysee index f2f83344..215cd9bd 160000 --- a/app/src/stable/assets/sources/odysee +++ b/app/src/stable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 +Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 diff --git a/app/src/stable/assets/sources/soundcloud b/app/src/stable/assets/sources/soundcloud index bff981c3..f8234d6a 160000 --- a/app/src/stable/assets/sources/soundcloud +++ b/app/src/stable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d +Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index 331dd929..b61095ec 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 +Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index ae7b62f4..6f1266a0 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 +Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 diff --git a/app/src/unstable/assets/sources/odysee b/app/src/unstable/assets/sources/odysee index f2f83344..215cd9bd 160000 --- a/app/src/unstable/assets/sources/odysee +++ b/app/src/unstable/assets/sources/odysee @@ -1 +1 @@ -Subproject commit f2f83344ebc905b36c0689bfef407bb95e6d9af0 +Subproject commit 215cd9bd70d3cc68e25441f7696dcbe5beee2709 diff --git a/app/src/unstable/assets/sources/soundcloud b/app/src/unstable/assets/sources/soundcloud index bff981c3..f8234d6a 160000 --- a/app/src/unstable/assets/sources/soundcloud +++ b/app/src/unstable/assets/sources/soundcloud @@ -1 +1 @@ -Subproject commit bff981c3ce7abad363e79705214a4710fb347f7d +Subproject commit f8234d6af8573414d07fd364bc136aa67ad0e379 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index 331dd929..b61095ec 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit 331dd929293614875af80e3ab4cb162dc6183410 +Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index ae7b62f4..6f1266a0 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit ae7b62f4d85cf398d9566a6ed07a6afc193f6b48 +Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 From 869b1fc15e4ab3665b9951f6460194e74ab2f372 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Tue, 8 Apr 2025 00:34:52 +0200 Subject: [PATCH 045/128] Fix pager for landscape --- .../platformplayer/fragment/mainactivity/main/FeedView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 17ca5510..3c915ebe 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 @@ -197,10 +197,12 @@ abstract class FeedView : L val firstVisibleItemView = if(firstVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(firstVisibleItemPosition) else null; val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition(); val lastVisibleItemView = if(lastVisibleItemPosition != RecyclerView.NO_POSITION) layoutManager.findViewByPosition(lastVisibleItemPosition) else null; + val rows = if(recyclerData.layoutManager is GridLayoutManager) Math.max(1, recyclerData.results.size / recyclerData.layoutManager.spanCount) else 1; + val rowsHeight = (firstVisibleItemView?.height ?: 0) * rows; if(lastVisibleItemView != null && lastVisibleItemPosition == (recyclerData.results.size - 1)) { false; } - else if (firstVisibleItemView != null && height != null && firstVisibleItemView.height * recyclerData.results.size < height) { + else if (firstVisibleItemView != null && height != null && rowsHeight < height) { false; } else { true; From 1755d03a6bd402ba9980e4683ee0899e60be0b4a Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 9 Apr 2025 00:56:49 +0200 Subject: [PATCH 046/128] Fcast clearer connection/reconnection overlay, disable ipv6 by default --- .../futo/platformplayer/Extensions_Network.kt | 7 +++- .../java/com/futo/platformplayer/Settings.kt | 7 +++- .../java/com/futo/platformplayer/UIDialogs.kt | 11 ++++- .../casting/FCastCastingDevice.kt | 2 + .../platformplayer/casting/StateCasting.kt | 40 +++++++++++++++++-- .../dialogs/ConnectCastingDialog.kt | 4 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index 52d8a663..00f47885 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -216,9 +216,14 @@ private fun ByteArray.toInetAddress(): InetAddress { return InetAddress.getByAddress(this); } -fun getConnectedSocket(addresses: List, port: Int): Socket? { +fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? { val timeout = 2000 + + val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance() else attemptAddresses; + if(addresses.isEmpty()) + throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})"); + if (addresses.isEmpty()) { return null; } diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index cedc4316..2bd95905 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -583,10 +583,15 @@ class Settings : FragmentedStorageFileJson() { @Serializable(with = FlexibleBooleanSerializer::class) var keepScreenOn: Boolean = true; - @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1) + @FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3) @Serializable(with = FlexibleBooleanSerializer::class) var alwaysProxyRequests: Boolean = false; + + @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) + @Serializable(with = FlexibleBooleanSerializer::class) + var allowIpv6: Boolean = false; + /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @DropdownFieldOptionsId(R.array.preferred_quality_array) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index e84002da..8034854d 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -5,6 +5,7 @@ import android.app.AlertDialog import android.content.Context import android.content.Intent import android.graphics.Color +import android.graphics.drawable.Animatable import android.net.Uri import android.text.Layout import android.text.method.ScrollingMovementMethod @@ -199,16 +200,21 @@ class UIDialogs { dialog.show(); } - fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) { + fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { + return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions); + } + fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog { val builder = AlertDialog.Builder(context); val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null); builder.setView(view); - + builder.setCancelable(defaultCloseAction > -2); val dialog = builder.create(); registerDialogOpened(dialog); view.findViewById(R.id.dialog_icon).apply { this.setImageResource(icon); + if(animated) + this.drawable.assume { it.start() }; } view.findViewById(R.id.dialog_text).apply { this.text = text; @@ -275,6 +281,7 @@ class UIDialogs { registerDialogClosed(dialog); } dialog.show(); + return dialog; } fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 9e12f78c..85b928c2 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Base64 import android.util.Log +import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.casting.models.FCastDecryptedMessage import com.futo.platformplayer.casting.models.FCastEncryptedMessage @@ -32,6 +33,7 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.math.BigInteger +import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index e329a495..90177050 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.casting +import android.app.AlertDialog import android.content.ContentResolver import android.content.Context import android.net.Uri @@ -9,6 +10,7 @@ import android.util.Log import android.util.Xml import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi +import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient @@ -239,6 +241,9 @@ class StateCasting { Logger.i(TAG, "CastingService stopped.") } + private val _castingDialogLock = Any(); + private var _currentDialog: AlertDialog? = null; + @Synchronized fun connectDevice(device: CastingDevice) { if (activeDevice == device) @@ -272,10 +277,39 @@ class StateCasting { invokeInMainScopeIfRequired { StateApp.withContext(false) { context -> context.let { + Logger.i(TAG, "Casting state changed to ${castConnectionState}"); when (castConnectionState) { - CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device") - CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...") - CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device") + CastConnectionState.CONNECTED -> { + Logger.i(TAG, "Casting connected to [${device.name}]"); + UIDialogs.appToast("Connected to device") + synchronized(_castingDialogLock) { + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + } + CastConnectionState.CONNECTING -> { + Logger.i(TAG, "Casting connecting to [${device.name}]"); + UIDialogs.toast(it, "Connecting to device...") + synchronized(_castingDialogLock) { + if(_currentDialog == null) { + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, + UIDialogs.Action("Disconnect", { + device.stop(); + })); + } + } + } + CastConnectionState.DISCONNECTED -> { + UIDialogs.toast(it, "Disconnected from device") + synchronized(_castingDialogLock) { + if(_currentDialog != null) { + _currentDialog?.hide(); + _currentDialog = null; + } + } + } } } }; diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index bd5da2ea..8f3b836c 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -73,11 +73,11 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { }; _rememberedAdapter.onConnect.subscribe { _ -> dismiss() - UIDialogs.showCastingDialog(context) + //UIDialogs.showCastingDialog(context) } _adapter.onConnect.subscribe { _ -> dismiss() - UIDialogs.showCastingDialog(context) + //UIDialogs.showCastingDialog(context) } _recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af450660..e7c101c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,8 @@ Keep screen on while casting Always proxy requests Always proxy requests when casting data through the device. + Allow IPV6 + If casting over IPV6 is allowed, can cause issues on some networks Discover Find new video sources to add These sources have been disabled From 4b6a2c982944484193be05eb5d7a150b1a557be3 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 9 Apr 2025 21:02:19 +0200 Subject: [PATCH 047/128] Keyboard hide on search end --- .../mainactivity/main/VideoListEditorView.kt | 25 +++++++++++++------ .../futo/platformplayer/views/SearchView.kt | 5 ++++ .../main/res/layout/fragment_playlists.xml | 3 +++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt index c0383b89..26f834f4 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoListEditorView.kt @@ -1,9 +1,11 @@ package com.futo.platformplayer.fragment.mainactivity.main +import android.content.Context import android.graphics.drawable.Animatable import android.util.TypedValue import android.view.LayoutInflater import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -48,6 +50,11 @@ abstract class VideoListEditorView : LinearLayout { private var _loadedVideos: List? = null; private var _loadedVideosCanEdit: Boolean = false; + fun hideSearchKeyboard() { + (context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.hideSoftInputFromWindow(_search.textSearch.windowToken, 0) + _search.textSearch.clearFocus(); + } + constructor(inflater: LayoutInflater) : super(inflater.context) { inflater.inflate(R.layout.fragment_video_list_editor, this); @@ -79,6 +86,7 @@ abstract class VideoListEditorView : LinearLayout { _search.textSearch.text = ""; updateVideoFilters(); _buttonSearch.setImageResource(R.drawable.ic_search); + hideSearchKeyboard(); } else { _search.visibility = View.VISIBLE; @@ -89,23 +97,23 @@ abstract class VideoListEditorView : LinearLayout { _buttonShare = findViewById(R.id.button_share); val onShare = _onShare; if(onShare != null) { - _buttonShare.setOnClickListener { onShare.invoke() }; + _buttonShare.setOnClickListener { hideSearchKeyboard(); onShare.invoke() }; _buttonShare.visibility = View.VISIBLE; } else _buttonShare.visibility = View.GONE; - buttonPlayAll.setOnClickListener { onPlayAllClick(); }; - buttonShuffle.setOnClickListener { onShuffleClick(); }; + buttonPlayAll.setOnClickListener { hideSearchKeyboard();onPlayAllClick(); hideSearchKeyboard(); }; + buttonShuffle.setOnClickListener { hideSearchKeyboard();onShuffleClick(); hideSearchKeyboard(); }; - _buttonEdit.setOnClickListener { onEditClick(); }; + _buttonEdit.setOnClickListener { hideSearchKeyboard(); onEditClick(); }; setButtonExportVisible(false); setButtonDownloadVisible(canEdit()); videoListEditorView.onVideoOrderChanged.subscribe(::onVideoOrderChanged); videoListEditorView.onVideoRemoved.subscribe(::onVideoRemoved); videoListEditorView.onVideoOptions.subscribe(::onVideoOptions); - videoListEditorView.onVideoClicked.subscribe(::onVideoClicked); + videoListEditorView.onVideoClicked.subscribe { hideSearchKeyboard(); onVideoClicked(it)}; _videoListEditorView = videoListEditorView; } @@ -113,6 +121,7 @@ abstract class VideoListEditorView : LinearLayout { fun setOnShare(onShare: (()-> Unit)? = null) { _onShare = onShare; _buttonShare.setOnClickListener { + hideSearchKeyboard(); onShare?.invoke(); }; _buttonShare.visibility = View.VISIBLE; @@ -145,7 +154,7 @@ abstract class VideoListEditorView : LinearLayout { setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_loader_animated); _buttonDownload.drawable.assume { it.start() }; - _buttonDownload.setOnClickListener { + _buttonDownload.setOnClickListener { hideSearchKeyboard(); UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { StateDownloads.instance.deleteCachedPlaylist(playlistId); }); @@ -154,7 +163,7 @@ abstract class VideoListEditorView : LinearLayout { else if(isDownloaded) { setButtonExportVisible(true) _buttonDownload.setImageResource(R.drawable.ic_download_off); - _buttonDownload.setOnClickListener { + _buttonDownload.setOnClickListener { hideSearchKeyboard(); UIDialogs.showConfirmationDialog(context, context.getString(R.string.are_you_sure_you_want_to_delete_the_downloaded_videos), { StateDownloads.instance.deleteCachedPlaylist(playlistId); }); @@ -163,7 +172,7 @@ abstract class VideoListEditorView : LinearLayout { else { setButtonExportVisible(false); _buttonDownload.setImageResource(R.drawable.ic_download); - _buttonDownload.setOnClickListener { + _buttonDownload.setOnClickListener { hideSearchKeyboard(); onDownload(); //UISlideOverlays.showDownloadPlaylistOverlay(playlist, overlayContainer); } diff --git a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt index 77cf431e..c36a04a5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/SearchView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/SearchView.kt @@ -1,7 +1,9 @@ package com.futo.platformplayer.views import android.content.Context +import android.text.TextWatcher import android.util.AttributeSet +import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView @@ -18,6 +20,9 @@ class SearchView : FrameLayout { val buttonClear: ImageButton; var onSearchChanged = Event1(); + var onEnter = Event1(); + + val text: String get() = textSearch.text.toString(); constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.view_search_bar, this); diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml index b8ca23df..a03f36ac 100644 --- a/app/src/main/res/layout/fragment_playlists.xml +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -144,6 +144,9 @@ android:layout_marginTop="10dp" android:layout_marginLeft="15dp" android:layout_marginRight="15dp" + android:inputType="text" + android:imeOptions="actionDone" + android:singleLine="true" android:background="@drawable/background_button_round" android:hint="Search.." /> From ca15983a72e3eb4ca868e4277c6cccbf99160eab Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 9 Apr 2025 23:26:35 +0200 Subject: [PATCH 048/128] Casting message, caching creator images --- .../main/java/com/futo/platformplayer/casting/StateCasting.kt | 4 +++- .../com/futo/platformplayer/views/others/CreatorThumbnail.kt | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 90177050..70b3069d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -294,7 +294,9 @@ class StateCasting { UIDialogs.toast(it, "Connecting to device...") synchronized(_castingDialogLock) { if(_currentDialog == null) { - _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", "Make sure you are on the same network", null, -2, + _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, + "Connecting to [${device.name}]", + "Make sure you are on the same network\n\nVPNs and guest networks can prevent connections", null, -2, UIDialogs.Action("Disconnect", { device.stop(); })); diff --git a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt index 472a516f..d655d7dd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/CreatorThumbnail.kt @@ -9,6 +9,7 @@ import android.view.View import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.getDataLinkFromUrl @@ -81,12 +82,14 @@ class CreatorThumbnail : ConstraintLayout { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) + .diskCacheStrategy(DiskCacheStrategy.DATA) .crossfade() .into(_imageChannelThumbnail); } else { Glide.with(_imageChannelThumbnail) .load(url) .placeholder(R.drawable.placeholder_channel_thumbnail) + .diskCacheStrategy(DiskCacheStrategy.DATA) .into(_imageChannelThumbnail); } } From dccdf72c7354c4d14a3851df776cf5000ced1515 Mon Sep 17 00:00:00 2001 From: Kelvin K Date: Wed, 9 Apr 2025 23:35:44 +0200 Subject: [PATCH 049/128] Message change --- .../main/java/com/futo/platformplayer/casting/StateCasting.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 70b3069d..25d2bcd1 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -296,7 +296,7 @@ class StateCasting { if(_currentDialog == null) { _currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true, "Connecting to [${device.name}]", - "Make sure you are on the same network\n\nVPNs and guest networks can prevent connections", null, -2, + "Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2, UIDialogs.Action("Disconnect", { device.stop(); })); From 05230971b3b48cbf161899948d637fa9e77b90da Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 11 Apr 2025 14:31:47 +0000 Subject: [PATCH 050/128] Implemented remote sync. --- .../futo/platformplayer/SyncServerTests.kt | 266 ++++ .../java/com/futo/platformplayer/Utility.kt | 15 +- .../activities/SyncHomeActivity.kt | 3 +- .../activities/SyncPairActivity.kt | 4 +- .../activities/SyncShowPairingCodeActivity.kt | 2 +- .../futo/platformplayer/states/StateSync.kt | 714 +++++++++-- .../platformplayer/sync/internal/Channel.kt | 332 +++++ .../platformplayer/sync/internal/Opcode.kt | 60 + .../sync/internal/SyncDeviceInfo.kt | 4 +- .../sync/internal/SyncErrorCode.kt | 6 + .../sync/internal/SyncSession.kt | 470 ++----- .../sync/internal/SyncSocketSession.kt | 1141 ++++++++++++++--- 12 files changed, 2353 insertions(+), 664 deletions(-) create mode 100644 app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt create mode 100644 app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt create mode 100644 app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt create mode 100644 app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt new file mode 100644 index 00000000..7348c3c7 --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt @@ -0,0 +1,266 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.sync.internal.* +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test +import java.net.Socket +import java.nio.ByteBuffer +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds + +class SyncServerTests { + + //private val relayHost = "relay.grayjay.app" + //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" + private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw=" + private val relayHost = "192.168.1.175" + private val relayPort = 9000 + + /** Creates a client connected to the live relay server. */ + private suspend fun createClient( + onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null, + onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null, + onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null, + isHandshakeAllowed: ((SyncSocketSession, String, String?) -> Boolean)? = null + ): SyncSocketSession = withContext(Dispatchers.IO) { + val p = Noise.createDH("25519") + p.generateKeyPair() + val socket = Socket(relayHost, relayPort) + val inputStream = LittleEndianDataInputStream(socket.getInputStream()) + val outputStream = LittleEndianDataOutputStream(socket.getOutputStream()) + val tcs = CompletableDeferred() + val socketSession = SyncSocketSession( + relayHost, + p, + inputStream, + outputStream, + onClose = { socket.close() }, + onHandshakeComplete = { s -> + onHandshakeComplete?.invoke(s) + tcs.complete(true) + }, + onData = onData ?: { _, _, _, _ -> }, + onNewChannel = onNewChannel ?: { _, _ -> }, + isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _ -> true } + ) + socketSession.authorizable = AlwaysAuthorized() + socketSession.startAsInitiator(relayKey) + withTimeout(5000.milliseconds) { tcs.await() } + return@withContext socketSession + } + + @Test + fun multipleClientsHandshake_Success() = runBlocking { + val client1 = createClient() + val client2 = createClient() + assertNotNull(client1.remotePublicKey, "Client 1 handshake failed") + assertNotNull(client2.remotePublicKey, "Client 2 handshake failed") + client1.stop() + client2.stop() + } + + @Test + fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val clientC = createClient() + clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true) + delay(100.milliseconds) + val infoB = clientB.requestConnectionInfo(clientA.localPublicKey) + val infoC = clientC.requestConnectionInfo(clientA.localPublicKey) + assertNotNull("Client B should receive connection info", infoB) + assertEquals(12345.toUShort(), infoB!!.port) + assertNull("Client C should not receive connection info (unauthorized)", infoC) + clientA.stop() + clientB.stop() + clientC.stop() + } + + @Test + fun relayedTransport_Bidirectional_Success() = runBlocking { + val tcsA = CompletableDeferred() + val tcsB = CompletableDeferred() + val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) + val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } + val channelA = withTimeout(5000.milliseconds) { tcsA.await() } + channelA.authorizable = AlwaysAuthorized() + val channelB = withTimeout(5000.milliseconds) { tcsB.await() } + channelB.authorizable = AlwaysAuthorized() + channelTask.await() + + val tcsDataB = CompletableDeferred() + channelB.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) + } + channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3))) + + val tcsDataA = CompletableDeferred() + channelA.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b) + } + channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6))) + + val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() } + val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() } + assertArrayEquals(byteArrayOf(1, 2, 3), receivedB) + assertArrayEquals(byteArrayOf(4, 5, 6), receivedA) + clientA.stop() + clientB.stop() + } + + @Test + fun relayedTransport_MaximumMessageSize_Success() = runBlocking { + val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16 + val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) } + val tcsA = CompletableDeferred() + val tcsB = CompletableDeferred() + val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) + val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } + val channelA = withTimeout(5000.milliseconds) { tcsA.await() } + channelA.authorizable = AlwaysAuthorized() + val channelB = withTimeout(5000.milliseconds) { tcsB.await() } + channelB.authorizable = AlwaysAuthorized() + channelTask.await() + + val tcsDataB = CompletableDeferred() + channelB.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) + } + channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData)) + val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() } + assertArrayEquals(maxSizeData, receivedData) + clientA.stop() + clientB.stop() + } + + @Test + fun publishAndGetRecord_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val clientC = createClient() + val data = byteArrayOf(1, 2, 3) + val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data) + val recordB = clientB.getRecord(clientA.localPublicKey, "testKey") + val recordC = clientC.getRecord(clientA.localPublicKey, "testKey") + assertTrue(success) + assertNotNull(recordB) + assertArrayEquals(data, recordB!!.first) + assertNull("Unauthorized client should not access record", recordC) + clientA.stop() + clientB.stop() + clientC.stop() + } + + @Test + fun getNonExistentRecord_ReturnsNull() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey") + assertNull("Getting non-existent record should return null", record) + clientA.stop() + clientB.stop() + } + + @Test + fun updateRecord_TimestampUpdated() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val key = "updateKey" + val data1 = byteArrayOf(1) + val data2 = byteArrayOf(2) + clientA.publishRecords(listOf(clientB.localPublicKey), key, data1) + val record1 = clientB.getRecord(clientA.localPublicKey, key) + delay(1000.milliseconds) + clientA.publishRecords(listOf(clientB.localPublicKey), key, data2) + val record2 = clientB.getRecord(clientA.localPublicKey, key) + assertNotNull(record1) + assertNotNull(record2) + assertTrue(record2!!.second > record1!!.second) + assertArrayEquals(data2, record2.first) + clientA.stop() + clientB.stop() + } + + @Test + fun deleteRecord_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val data = byteArrayOf(1, 2, 3) + clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data) + val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete")) + val record = clientB.getRecord(clientA.localPublicKey, "toDelete") + assertTrue(success) + assertNull(record) + clientA.stop() + clientB.stop() + } + + @Test + fun listRecordKeys_Success() = runBlocking { + val clientA = createClient() + val clientB = createClient() + val keys = arrayOf("key1", "key2", "key3") + keys.forEach { key -> + clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1)) + } + val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey) + assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray()) + clientA.stop() + clientB.stop() + } + + @Test + fun singleLargeMessageViaRelayedChannel_Success() = runBlocking { + val largeData = ByteArray(100000).apply { Random.nextBytes(this) } + val tcsA = CompletableDeferred() + val tcsB = CompletableDeferred() + val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) }) + val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) }) + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) } + val channelA = withTimeout(5000.milliseconds) { tcsA.await() } + channelA.authorizable = AlwaysAuthorized() + val channelB = withTimeout(5000.milliseconds) { tcsB.await() } + channelB.authorizable = AlwaysAuthorized() + channelTask.await() + + val tcsDataB = CompletableDeferred() + channelB.setDataHandler { _, _, o, so, d -> + val b = ByteArray(d.remaining()) + d.get(b) + if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b) + } + channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData)) + val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() } + assertArrayEquals(largeData, receivedData) + clientA.stop() + clientB.stop() + } + + @Test + fun publishAndGetLargeRecord_Success() = runBlocking { + val largeData = ByteArray(1000000).apply { Random.nextBytes(this) } + val clientA = createClient() + val clientB = createClient() + val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData) + val record = clientB.getRecord(clientA.localPublicKey, "largeRecord") + assertTrue(success) + assertNotNull(record) + assertArrayEquals(largeData, record!!.first) + clientA.stop() + clientB.stop() + } +} + +class AlwaysAuthorized : IAuthorizable { + override val isAuthorized: Boolean get() = true +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 5cd5d26f..7ddefc79 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -28,12 +28,11 @@ import com.futo.platformplayer.models.PlatformVideoWithTime import com.futo.platformplayer.others.PlatformLinkMovementMethod import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.nio.ByteBuffer -import java.nio.ByteOrder +import java.security.SecureRandom import java.time.OffsetDateTime import java.util.* import java.util.concurrent.ThreadLocalRandom @@ -284,6 +283,18 @@ fun ByteBuffer.toUtf8String(): String { return String(remainingBytes, Charsets.UTF_8) } +fun generateReadablePassword(length: Int): String { + val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" + val secureRandom = SecureRandom() + val randomBytes = ByteArray(length) + secureRandom.nextBytes(randomBytes) + val sb = StringBuilder(length) + for (byte in randomBytes) { + val index = (byte.toInt() and 0xFF) % validChars.length + sb.append(validChars[index]) + } + return sb.toString() +} fun ByteArray.toGzip(): ByteArray { if (this == null || this.isEmpty()) return ByteArray(0) diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index d1cd7706..2b7e3a72 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -100,7 +100,8 @@ class SyncHomeActivity : AppCompatActivity() { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { val connected = session?.connected ?: false - syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None) + + syncDeviceView.setLinkType(session?.linkType ?: LinkType.None) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) //TODO: also display public key? .setStatus(if (connected) "Connected" else "Disconnected") diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt index a7030b97..5e808977 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt @@ -109,9 +109,9 @@ class SyncPairActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - StateSync.instance.connect(deviceInfo) { session, complete, message -> + StateSync.instance.connect(deviceInfo) { complete, message -> lifecycleScope.launch(Dispatchers.Main) { - if (complete) { + if (complete != null && complete) { _layoutPairingSuccess.visibility = View.VISIBLE _layoutPairing.visibility = View.GONE } else { diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt index 2fbb4b97..b9f3d437 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt @@ -67,7 +67,7 @@ class SyncShowPairingCodeActivity : AppCompatActivity() { } val ips = getIPs() - val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT) + val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode) val json = Json.encodeToString(selfDeviceInfo) val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) val url = "grayjay://sync/${base64}" diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 96c25f9d..57bcde5f 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -6,38 +6,62 @@ import com.futo.platformplayer.LittleEndianDataInputStream import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity +import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.encryption.GEncryptionProvider +import com.futo.platformplayer.generateReadablePassword import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.mdns.DnsService import com.futo.platformplayer.mdns.ServiceDiscoverer +import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.smartMerge import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringStorage import com.futo.platformplayer.stores.StringTMapStorage import com.futo.platformplayer.sync.SyncSessionData +import com.futo.platformplayer.sync.internal.ChannelSocket import com.futo.platformplayer.sync.internal.GJSyncOpcodes +import com.futo.platformplayer.sync.internal.IAuthorizable +import com.futo.platformplayer.sync.internal.IChannel +import com.futo.platformplayer.sync.internal.Opcode import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.SyncKeyPair import com.futo.platformplayer.sync.internal.SyncSession +import com.futo.platformplayer.sync.internal.SyncSession.Companion import com.futo.platformplayer.sync.internal.SyncSocketSession +import com.futo.platformplayer.sync.models.SendToDevicePackage +import com.futo.platformplayer.sync.models.SyncPlaylistsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage +import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage +import com.futo.platformplayer.sync.models.SyncWatchLaterPackage import com.futo.polycentric.core.base64ToByteArray import com.futo.polycentric.core.toBase64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.io.ByteArrayInputStream +import java.lang.Thread.sleep import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket +import java.nio.ByteBuffer +import java.nio.channels.Channel +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.Base64 import java.util.Locale import kotlin.system.measureTimeMillis @@ -59,13 +83,19 @@ class StateSync { //TODO: Should sync mdns and casting mdns be merged? //TODO: Decrease interval that devices are updated //TODO: Send less data - val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) } + private val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) } + private val _pairingCode: String? = generateReadablePassword(8) + val pairingCode: String? get() = _pairingCode + private var _relaySession: SyncSocketSession? = null + private var _threadRelay: Thread? = null var keyPair: DHState? = null var publicKey: String? = null val deviceRemoved: Event1 = Event1() val deviceUpdatedOrAdded: Event2 = Event2() + //TODO: Should authorize acknowledge be implemented? + fun hasAuthorizedDevice(): Boolean { synchronized(_sessions) { return _sessions.any{ it.value.connected && it.value.isAuthorized }; @@ -127,7 +157,7 @@ class StateSync { while (_started) { val socket = serverSocket.accept() - val session = createSocketSession(socket, true) { session, socketSession -> + val session = createSocketSession(socket, true) { session -> } @@ -164,7 +194,7 @@ class StateSync { for (connectPair in addressesToConnect) { try { - val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT) + val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT, null) val now = System.currentTimeMillis() val lastConnectTime = synchronized(_lastConnectTimesIp) { @@ -188,6 +218,138 @@ class StateSync { } }.apply { start() } } + + _threadRelay = Thread { + while (_started) { + try { + Log.i(TAG, "Starting relay session...") + + var socketClosed = false; + val socket = Socket(RELAY_SERVER, 9000) + _relaySession = SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + LittleEndianDataInputStream(socket.getInputStream()), + LittleEndianDataOutputStream(socket.getOutputStream()), + isHandshakeAllowed = { _, pk, pairingCode -> + Log.v(TAG, "Check if handshake allowed from '$pk'.") + if (pk == RELAY_PUBLIC_KEY) + return@SyncSocketSession true + + synchronized(_authorizedDevices) { + if (_authorizedDevices.values.contains(pk)) + return@SyncSocketSession true + } + + Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode'.") + if (_pairingCode == null || pairingCode.isNullOrEmpty()) + return@SyncSocketSession false + + _pairingCode == pairingCode + }, + onNewChannel = { _, c -> + val remotePublicKey = c.remotePublicKey + if (remotePublicKey == null) { + Log.e(TAG, "Remote public key should never be null in onNewChannel.") + return@SyncSocketSession + } + + Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") + + var session: SyncSession? + synchronized(_sessions) { + session = _sessions[remotePublicKey] + if (session == null) { + val remoteDeviceName = synchronized(_nameStorage) { + _nameStorage.get(remotePublicKey) + } + session = createNewSyncSession(remotePublicKey, remoteDeviceName) { } + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(c) + } + + c.setDataHandler { _, channel, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + c.setCloseHandler { channel -> + session?.removeChannel(channel) + } + }, + onChannelEstablished = { _, channel, isResponder -> + handleAuthorization(channel, isResponder) + }, + onClose = { socketClosed = true }, + onHandshakeComplete = { relaySession -> + Thread { + try { + while (_started && !socketClosed) { + val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) { + _authorizedDevices.values.filter { !isConnected(it) }.toTypedArray() + } + + relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, true, false, false, true) + + val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } + + for ((targetKey, connectionInfo) in connectionInfos) { + val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses) + .filter { it != connectionInfo.remoteIp } + if (connectionInfo.allowLocalDirect) { + Thread { + try { + Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") + connect(potentialLocalAddresses.map { it }.toTypedArray(), PORT, targetKey, null) + } catch (e: Throwable) { + Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) + } + }.start() + } + + if (connectionInfo.allowRemoteDirect) { + // TODO: Implement direct remote connection if needed + } + + if (connectionInfo.allowRemoteHolePunched) { + // TODO: Implement hole punching if needed + } + + if (connectionInfo.allowRemoteProxied) { + try { + Log.v(TAG, "Attempting relayed connection with '$targetKey'.") + runBlocking { relaySession.startRelayedChannel(targetKey, null) } + } catch (e: Throwable) { + Log.e(TAG, "Failed to start relayed channel with $targetKey.", e) + } + } + } + + Thread.sleep(15000) + } + } catch (e: Throwable) { + Log.e(TAG, "Unhandled exception in relay session.", e) + relaySession.stop() + } + }.start() + } + ) + + _relaySession!!.authorizable = object : IAuthorizable { + override val isAuthorized: Boolean get() = true + } + + _relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, null) + + Log.i(TAG, "Started relay session.") + } catch (e: Throwable) { + Log.e(TAG, "Relay session failed.", e) + Thread.sleep(5000) + } finally { + _relaySession?.stop() + _relaySession = null + } + } + }.apply { start() } } private fun getDeviceName(): String { @@ -219,14 +381,14 @@ class StateSync { } } fun getSessions(): List { - return synchronized(_sessions) { + synchronized(_sessions) { return _sessions.values.toList() - }; + } } fun getAuthorizedSessions(): List { - return synchronized(_sessions) { + synchronized(_sessions) { return _sessions.values.filter { it.isAuthorized }.toList() - }; + } } fun getSyncSessionData(key: String): SyncSessionData { @@ -253,7 +415,7 @@ class StateSync { val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) - val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port) + val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port, null) val authorized = isAuthorized(pkey) if (authorized && !isConnected(pkey)) { @@ -288,11 +450,313 @@ class StateSync { deviceRemoved.emit(remotePublicKey) } - private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession, socketSession: SyncSocketSession) -> Unit): SyncSocketSession { + + private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { + val added = mutableListOf() + for(sub in pack.subscriptions) { + if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { + val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); + if(sub.creationTime > removalTime) { + val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); + added.add(newSub); + } + } + } + if(added.size > 3) + UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); + else if(added.size > 0) + UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + + added.map { it.channel.name }.joinToString("\n")); + + + if(pack.subscriptions.isNotEmpty()) { + for (subRemoved in pack.subscriptionRemovals) { + val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); + if(removed.size > 3) { + UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}"); + } else if(removed.isNotEmpty()) { + UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, 8.coerceAtMost(origin.remotePublicKey.length))}:\n" + removed.map { it.channel.name }.joinToString("\n")); + } + } + } + } + + private fun handleData(session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + val remotePublicKey = session.remotePublicKey + when (subOpcode) { + GJSyncOpcodes.sendToDevices -> { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + val context = StateApp.instance.contextOrNull; + if (context != null && context is MainActivity) { + val dataBody = ByteArray(data.remaining()); + val remainder = data.remaining(); + data.get(dataBody, 0, remainder); + val json = String(dataBody, Charsets.UTF_8); + val obj = Json.decodeFromString(json); + UIDialogs.appToast("Received url from device [${session.remotePublicKey}]:\n{${obj.url}"); + context.handleUrl(obj.url, obj.position); + } + }; + } + + GJSyncOpcodes.syncStateExchange -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val syncSessionData = Serializer.json.decodeFromString(json); + + Logger.i(TAG, "Received SyncSessionData from $remotePublicKey"); + + + session.sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); + session.sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); + session.sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) + + session.sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); + + val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); + if(recentHistory.isNotEmpty()) + session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); + } + + GJSyncOpcodes.syncExport -> { + val dataBody = ByteArray(data.remaining()); + val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); + bytesStr.use { bytesStrBytes -> + val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStrBytes); + for (store in exportStruct.stores) { + if (store.key.equals("subscriptions", true)) { + val subStore = + StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + val pack = SyncSubscriptionsPackage( + store.value.map { + subStore.fromReconstruction(it, exportStruct.cache) + }, + StateSubscriptions.instance.getSubscriptionRemovals() + ); + handleSyncSubscriptionPackage(session, pack); + } + } + } + } + } + + GJSyncOpcodes.syncSubscriptions -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val subPackage = Serializer.json.decodeFromString(json); + handleSyncSubscriptionPackage(session, subPackage); + + val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; + + val sesData = getSyncSessionData(remotePublicKey); + if(newestSub > sesData.lastSubscription) { + sesData.lastSubscription = newestSub; + saveSyncSessionData(sesData); + } + } + + GJSyncOpcodes.syncSubscriptionGroups -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + var lastSubgroupChange = OffsetDateTime.MIN; + for(group in pack.groups){ + if(group.lastChange > lastSubgroupChange) + lastSubgroupChange = group.lastChange; + + val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); + + if(existing == null) + StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); + else if(existing.lastChange < group.lastChange) { + existing.name = group.name; + existing.urls = group.urls; + existing.image = group.image; + existing.priority = group.priority; + existing.lastChange = group.lastChange; + StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); + } + } + for(removal in pack.groupRemovals) { + val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.creationTime < removalTime) + StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); + } + } + + GJSyncOpcodes.syncPlaylists -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + for(playlist in pack.playlists) { + val existing = StatePlaylists.instance.getPlaylist(playlist.id); + + if(existing == null) + StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); + else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { + existing.dateUpdate = playlist.dateUpdate; + existing.name = playlist.name; + existing.videos = playlist.videos; + existing.dateCreation = playlist.dateCreation; + existing.datePlayed = playlist.datePlayed; + StatePlaylists.instance.createOrUpdatePlaylist(existing, false); + } + } + for(removal in pack.playlistRemovals) { + val creation = StatePlaylists.instance.getPlaylist(removal.key); + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + if(creation != null && creation.dateCreation < removalTime) + StatePlaylists.instance.removePlaylist(creation, false); + + } + } + + GJSyncOpcodes.syncWatchLater -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val pack = Serializer.json.decodeFromString(json); + + Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})"); + + val allExisting = StatePlaylists.instance.getWatchLater(); + for(video in pack.videos) { + val existing = allExisting.firstOrNull { it.url == video.url }; + val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN; + + if(existing == null) { + StatePlaylists.instance.addToWatchLater(video, false); + if(time > OffsetDateTime.MIN) + StatePlaylists.instance.setWatchLaterAddTime(video.url, time); + } + } + for(removal in pack.videoRemovals) { + val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; + val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; + val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC); + if(creation < removalTime) + StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); + } + + val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC); + val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); + if(localReorderTime < packReorderTime && pack.ordering != null) { + StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); + } + } + + GJSyncOpcodes.syncHistory -> { + val dataBody = ByteArray(data.remaining()); + data.get(dataBody); + val json = String(dataBody, Charsets.UTF_8); + val history = Serializer.json.decodeFromString>(json); + Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); + + var lastHistory = OffsetDateTime.MIN; + for(video in history){ + val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date); + if(hist != null) + StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date) + if(lastHistory < video.date) + lastHistory = video.date; + } + + if(lastHistory != OffsetDateTime.MIN && history.size > 1) { + val sesData = getSyncSessionData(remotePublicKey); + if (lastHistory > sesData.lastHistory) { + sesData.lastHistory = lastHistory; + saveSyncSessionData(sesData); + } + } + } + } + } + + private fun createNewSyncSession(remotePublicKey: String, remoteDeviceName: String?, onAuthorized: ((SyncSession) -> Unit)?): SyncSession { + return SyncSession( + remotePublicKey, + onAuthorized = { it, isNewlyAuthorized, isNewSession -> + if (!isNewSession) { + return@SyncSession + } + + it.remoteDeviceName?.let { remoteDeviceName -> + synchronized(_nameStorage) { + _nameStorage.setAndSave(remotePublicKey, remoteDeviceName) + } + } + + Logger.i(TAG, "${remotePublicKey} authorized (name: ${it.displayName})") + onAuthorized?.invoke(it) + _authorizedDevices.addDistinct(remotePublicKey) + _authorizedDevices.save() + deviceUpdatedOrAdded.emit(it.remotePublicKey, it) + + checkForSync(it); + }, + onUnauthorized = { + unauthorize(remotePublicKey) + + synchronized(_sessions) { + it.close() + _sessions.remove(remotePublicKey) + } + }, + onConnectedChanged = { it, connected -> + Logger.i(TAG, "$remotePublicKey connected: " + connected) + deviceUpdatedOrAdded.emit(it.remotePublicKey, it) + }, + onClose = { + Logger.i(TAG, "$remotePublicKey closed") + + synchronized(_sessions) + { + _sessions.remove(it.remotePublicKey) + } + + deviceRemoved.emit(it.remotePublicKey) + }, + dataHandler = { it, opcode, subOpcode, data -> + handleData(it, opcode, subOpcode, data) + }, + remoteDeviceName + ) + } + + private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession) -> Unit): SyncSocketSession { var session: SyncSession? = null - return SyncSocketSession((socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, LittleEndianDataInputStream(socket.getInputStream()), LittleEndianDataOutputStream(socket.getOutputStream()), + var channelSocket: ChannelSocket? = null + return SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + LittleEndianDataInputStream(socket.getInputStream()), + LittleEndianDataOutputStream(socket.getOutputStream()), onClose = { s -> - session?.removeSocketSession(s) + if (channelSocket != null) + session?.removeChannel(channelSocket!!) + }, + isHandshakeAllowed = { _, pk, pairingCode -> + Logger.v(TAG, "Check if handshake allowed from '${pk}'.") + + synchronized (_authorizedDevices) + { + if (_authorizedDevices.values.contains(pk)) + return@SyncSocketSession true + } + + Logger.v(TAG, "Check if handshake allowed with pairing code '${pairingCode}' with active pairing code '${_pairingCode}'."); + if (_pairingCode == null || pairingCode.isNullOrEmpty()) + return@SyncSocketSession false + + return@SyncSocketSession _pairingCode == pairingCode }, onHandshakeComplete = { s -> val remotePublicKey = s.remotePublicKey @@ -303,6 +767,8 @@ class StateSync { Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") + channelSocket = ChannelSocket(s) + synchronized(_sessions) { session = _sessions[s.remotePublicKey] if (session == null) { @@ -310,126 +776,99 @@ class StateSync { _nameStorage.get(remotePublicKey) } - session = SyncSession(remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> - if (!isNewSession) { - return@SyncSession - } + synchronized(_lastAddressStorage) { + _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) + } - it.remoteDeviceName?.let { remoteDeviceName -> - synchronized(_nameStorage) { - _nameStorage.setAndSave(remotePublicKey, remoteDeviceName) - } - } - - Logger.i(TAG, "${s.remotePublicKey} authorized (name: ${it.displayName})") - synchronized(_lastAddressStorage) { - _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) - } - - onAuthorized(it, s) - _authorizedDevices.addDistinct(remotePublicKey) - _authorizedDevices.save() - deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) - - checkForSync(it); - }, onUnauthorized = { - unauthorize(remotePublicKey) - - synchronized(_sessions) { - session?.close() - _sessions.remove(remotePublicKey) - } - }, onConnectedChanged = { it, connected -> - Logger.i(TAG, "${s.remotePublicKey} connected: " + connected) - deviceUpdatedOrAdded.emit(it.remotePublicKey, session!!) - }, onClose = { - Logger.i(TAG, "${s.remotePublicKey} closed") - - synchronized(_sessions) - { - _sessions.remove(it.remotePublicKey) - } - - deviceRemoved.emit(it.remotePublicKey) - - }, remoteDeviceName) + session = createNewSyncSession(remotePublicKey, remoteDeviceName, onAuthorized) _sessions[remotePublicKey] = session!! } - session!!.addSocketSession(s) + session!!.addChannel(channelSocket!!) } - if (isResponder) { - val isAuthorized = synchronized(_authorizedDevices) { - _authorizedDevices.values.contains(remotePublicKey) - } - - if (!isAuthorized) { - val scope = StateApp.instance.scopeOrNull - val activity = SyncShowPairingCodeActivity.activity - - if (scope != null && activity != null) { - scope.launch(Dispatchers.Main) { - UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", action = { - scope.launch(Dispatchers.IO) { - try { - session!!.authorize(s) - Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize", e) - } - } - }, cancelAction = { - scope.launch(Dispatchers.IO) { - try { - unauthorize(remotePublicKey) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to send unauthorize", e) - } - - synchronized(_sessions) { - session?.close() - _sessions.remove(remotePublicKey) - } - } - }) - } - } else { - val publicKey = session!!.remotePublicKey - session!!.unauthorize(s) - session!!.close() - - synchronized(_sessions) { - _sessions.remove(publicKey) - } - - Logger.i(TAG, "Connection unauthorized for ${remotePublicKey} because not authorized and not on pairing activity to ask") - } - } else { - //Responder does not need to check because already approved - session!!.authorize(s) - Logger.i(TAG, "Connection authorized for ${remotePublicKey} because already authorized") - } - } else { - //Initiator does not need to check because the manual action of scanning the QR counts as approval - session!!.authorize(s) - Logger.i(TAG, "Connection authorized for ${remotePublicKey} because initiator") - } + handleAuthorization(channelSocket!!, isResponder) }, onData = { s, opcode, subOpcode, data -> - session?.handlePacket(s, opcode, subOpcode, data) - }) + session?.handlePacket(opcode, subOpcode, data) + } + ) + } + + private fun handleAuthorization(channel: IChannel, isResponder: Boolean) { + val syncSession = channel.syncSession!! + val remotePublicKey = channel.remotePublicKey!! + + if (isResponder) { + val isAuthorized = synchronized(_authorizedDevices) { + _authorizedDevices.values.contains(remotePublicKey) + } + + if (!isAuthorized) { + val scope = StateApp.instance.scopeOrNull + val activity = SyncShowPairingCodeActivity.activity + + if (scope != null && activity != null) { + scope.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", + action = { + scope.launch(Dispatchers.IO) { + try { + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize", e) + } + } + }, + cancelAction = { + scope.launch(Dispatchers.IO) { + try { + unauthorize(remotePublicKey) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to send unauthorize", e) + } + + syncSession.close() + synchronized(_sessions) { + _sessions.remove(remotePublicKey) + } + } + } + ) + } + } else { + val publicKey = syncSession.remotePublicKey + syncSession.unauthorize() + syncSession.close() + + synchronized(_sessions) { + _sessions.remove(publicKey) + } + + Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask") + } + } else { + //Responder does not need to check because already approved + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized") + } + } else { + //Initiator does not need to check because the manual action of scanning the QR counts as approval + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator") + } } inline fun broadcastJsonData(subOpcode: UByte, data: T) { - broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, Json.encodeToString(data)); + broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); } fun broadcastData(subOpcode: UByte, data: String) { - broadcast(SyncSocketSession.Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); + broadcast(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))); } fun broadcast(opcode: UByte, subOpcode: UByte, data: String) { - broadcast(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); + broadcast(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))); } - fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteArray) { + fun broadcast(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { for(session in getAuthorizedSessions()) { try { session.send(opcode, subOpcode, data); @@ -456,21 +895,46 @@ class StateSync { _serverSocket?.close() _serverSocket = null - //_thread?.join() + _thread?.interrupt() _thread = null + _connectThread?.interrupt() _connectThread = null + _threadRelay?.interrupt() + _threadRelay = null + + _relaySession?.stop() + _relaySession = null } - fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((session: SyncSocketSession?, complete: Boolean, message: String) -> Unit)? = null): SyncSocketSession { - onStatusUpdate?.invoke(null, false, "Connecting...") - val socket = getConnectedSocket(deviceInfo.addresses.map { InetAddress.getByName(it) }, deviceInfo.port) ?: throw Exception("Failed to connect") - onStatusUpdate?.invoke(null, false, "Handshaking...") + fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) { + try { + connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to connect directly", e) + val relaySession = _relaySession + if (relaySession != null) { + onStatusUpdate?.invoke(null, "Connecting via relay...") - val session = createSocketSession(socket, false) { _, ss -> - onStatusUpdate?.invoke(ss, true, "Handshake complete") + runBlocking { + relaySession.startRelayedChannel(deviceInfo.publicKey, deviceInfo.pairingCode) + onStatusUpdate?.invoke(true, "Connected") + } + } else { + throw Exception("Failed to connect.") + } + } + } + + fun connect(addresses: Array, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession { + onStatusUpdate?.invoke(null, "Connecting directly...") + val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") + onStatusUpdate?.invoke(null, "Handshaking...") + + val session = createSocketSession(socket, false) { s -> + onStatusUpdate?.invoke(true, "Authorized") } - session.startAsInitiator(deviceInfo.publicKey) + session.startAsInitiator(publicKey, pairingCode) return session } @@ -526,6 +990,8 @@ class StateSync { val hash = "BLAKE2b" var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" val version = 1 + val RELAY_SERVER = "relay.grayjay.app" + val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" private const val TAG = "StateSync" const val PORT = 12315 diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt new file mode 100644 index 00000000..2d3f1580 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -0,0 +1,332 @@ +package com.futo.platformplayer.sync.internal + +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.noise.protocol.CipherStatePair +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.HandshakeState +import com.futo.platformplayer.states.StateSync +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Base64 + +interface IChannel : AutoCloseable { + val remotePublicKey: String? + val remoteVersion: Int? + var authorizable: IAuthorizable? + var syncSession: SyncSession? + fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) + fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null) + fun setCloseHandler(onClose: ((IChannel) -> Unit)?) +} + +class ChannelSocket(private val session: SyncSocketSession) : IChannel { + override val remotePublicKey: String? get() = session.remotePublicKey + override val remoteVersion: Int? get() = session.remoteVersion + private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null + private var onClose: ((IChannel) -> Unit)? = null + + override var authorizable: IAuthorizable? + get() = session.authorizable + set(value) { session.authorizable = value } + override var syncSession: SyncSession? = null + + override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { + this.onData = onData + } + + override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) { + this.onClose = onClose + } + + override fun close() { + session.stop() + onClose?.invoke(this) + } + + fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + onData?.invoke(session, this, opcode, subOpcode, data) + } + + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { + if (data != null) { + session.send(opcode, subOpcode, data) + } else { + session.send(opcode, subOpcode) + } + } +} + +class ChannelRelayed( + private val session: SyncSocketSession, + private val localKeyPair: DHState, + private val publicKey: String, + private val initiator: Boolean +) : IChannel { + private val sendLock = Object() + private val decryptLock = Object() + private var handshakeState: HandshakeState? = if (initiator) { + HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply { + localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) + remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0) + } + } else { + HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) + } + } + private var transport: CipherStatePair? = null + override var authorizable: IAuthorizable? = null + val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false + var connectionId: Long = 0L + override var remotePublicKey: String? = publicKey + private set + override var remoteVersion: Int? = null + private set + override var syncSession: SyncSession? = null + + private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null + private var onClose: ((IChannel) -> Unit)? = null + private var disposed = false + + init { + handshakeState?.start() + } + + override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { + this.onData = onData + } + + override fun setCloseHandler(onClose: ((IChannel) -> Unit)?) { + this.onClose = onClose + } + + override fun close() { + disposed = true + + if (connectionId != 0L) { + Thread { + try { + session.sendRelayError(connectionId, SyncErrorCode.ConnectionClosed) + } catch (e: Exception) { + Logger.e("ChannelRelayed", "Exception while sending relay error", e) + } + }.start() + } + + transport?.sender?.destroy() + transport?.receiver?.destroy() + transport = null + handshakeState?.destroy() + handshakeState = null + + onClose?.invoke(this) + } + + private fun throwIfDisposed() { + if (disposed) throw IllegalStateException("ChannelRelayed is disposed") + } + + fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + onData?.invoke(session, this, opcode, subOpcode, data) + } + + private fun completeHandshake(remoteVersion: Int, transport: CipherStatePair) { + throwIfDisposed() + + this.remoteVersion = remoteVersion + val remoteKeyBytes = ByteArray(handshakeState!!.remotePublicKey.publicKeyLength) + handshakeState!!.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + this.remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + handshakeState?.destroy() + handshakeState = null + this.transport = transport + Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId") + } + + private fun sendPacket(packet: ByteArray) { + throwIfDisposed() + + synchronized(sendLock) { + val encryptedPayload = ByteArray(packet.size + 16) + val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size) + + val relayedPacket = ByteArray(8 + encryptedLength) + ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putLong(connectionId) + put(encryptedPayload, 0, encryptedLength) + } + + session.send(Opcode.RELAY.value, RelayOpcode.DATA.value, ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN)) + } + } + + fun sendError(errorCode: SyncErrorCode) { + throwIfDisposed() + + synchronized(sendLock) { + val packet = ByteArray(4) + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(errorCode.value) + + val encryptedPayload = ByteArray(4 + 16) + val encryptedLength = transport!!.sender.encryptWithAd(null, packet, 0, encryptedPayload, 0, packet.size) + + val relayedPacket = ByteArray(8 + encryptedLength) + ByteBuffer.wrap(relayedPacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putLong(connectionId) + put(encryptedPayload, 0, encryptedLength) + } + + session.send(Opcode.RELAY.value, RelayOpcode.ERROR.value, ByteBuffer.wrap(relayedPacket)) + } + } + + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { + throwIfDisposed() + + val actualCount = data?.remaining() ?: 0 + val ENCRYPTION_OVERHEAD = 16 + val CONNECTION_ID_SIZE = 8 + val HEADER_SIZE = 6 + val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 + + if (actualCount > MAX_DATA_PER_PACKET && data != null) { + val streamId = session.generateStreamId() + val totalSize = actualCount + var sendOffset = 0 + + while (sendOffset < totalSize) { + val bytesRemaining = totalSize - sendOffset + val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining) + + val streamData: ByteArray + val streamOpcode: StreamOpcode + if (sendOffset == 0) { + streamOpcode = StreamOpcode.START + streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend) + ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(streamId) + putInt(totalSize) + put(opcode.toByte()) + put(subOpcode.toByte()) + put(data.array(), data.position() + sendOffset, bytesToSend) + } + } else { + streamData = ByteArray(4 + 4 + bytesToSend) + ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(streamId) + putInt(sendOffset) + put(data.array(), data.position() + sendOffset, bytesToSend) + } + streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END + } + + val fullPacket = ByteArray(HEADER_SIZE + streamData.size) + ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(streamData.size + 2) + put(Opcode.STREAM.value.toByte()) + put(streamOpcode.value.toByte()) + put(streamData) + } + + sendPacket(fullPacket) + sendOffset += bytesToSend + } + } else { + val packet = ByteArray(HEADER_SIZE + actualCount) + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(actualCount + 2) + put(opcode.toByte()) + put(subOpcode.toByte()) + if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount) + } + sendPacket(packet) + } + } + + fun sendRequestTransport(requestId: Int, publicKey: String, pairingCode: String? = null) { + throwIfDisposed() + + synchronized(sendLock) { + val channelMessage = ByteArray(1024) + val channelBytesWritten = handshakeState!!.writeMessage(channelMessage, 0, null, 0, 0) + + val publicKeyBytes = Base64.getDecoder().decode(publicKey) + if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") + + val (pairingMessageLength, pairingMessage) = if (pairingCode != null) { + val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply { + remotePublicKey.setPublicKey(publicKeyBytes, 0) + start() + } + val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) + if (pairingCodeBytes.size > 32) throw IllegalArgumentException("Pairing code must not exceed 32 bytes") + val pairingMessageBuffer = ByteArray(1024) + val bytesWritten = pairingHandshake.writeMessage(pairingMessageBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size) + bytesWritten to pairingMessageBuffer.copyOf(bytesWritten) + } else { + 0 to ByteArray(0) + } + + val packetSize = 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten + val packet = ByteArray(packetSize) + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(requestId) + put(publicKeyBytes) + putInt(pairingMessageLength) + if (pairingMessageLength > 0) put(pairingMessage) + putInt(channelBytesWritten) + put(channelMessage, 0, channelBytesWritten) + } + + session.send(Opcode.REQUEST.value, RequestOpcode.TRANSPORT.value, ByteBuffer.wrap(packet)) + } + } + + fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) { + throwIfDisposed() + + synchronized(sendLock) { + val message = ByteArray(1024) + val plaintext = ByteArray(1024) + handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0) + val bytesWritten = handshakeState!!.writeMessage(message, 0, null, 0, 0) + val transport = handshakeState!!.split() + + val responsePacket = ByteArray(20 + bytesWritten) + ByteBuffer.wrap(responsePacket).order(ByteOrder.LITTLE_ENDIAN).apply { + putInt(0) // Status code + putLong(connectionId) + putInt(requestId) + putInt(bytesWritten) + put(message, 0, bytesWritten) + } + + completeHandshake(remoteVersion, transport) + session.send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, ByteBuffer.wrap(responsePacket)) + } + } + + fun decrypt(encryptedPayload: ByteBuffer): ByteBuffer { + throwIfDisposed() + + synchronized(decryptLock) { + val encryptedBytes = ByteArray(encryptedPayload.remaining()).also { encryptedPayload.get(it) } + val decryptedPayload = ByteArray(encryptedBytes.size - 16) + val plen = transport!!.receiver.decryptWithAd(null, encryptedBytes, 0, decryptedPayload, 0, encryptedBytes.size) + if (plen != decryptedPayload.size) throw IllegalStateException("Expected decrypted payload length to be $plen") + return ByteBuffer.wrap(decryptedPayload).order(ByteOrder.LITTLE_ENDIAN) + } + } + + fun handleTransportRelayed(remoteVersion: Int, connectionId: Long, handshakeMessage: ByteArray) { + throwIfDisposed() + + synchronized(decryptLock) { + this.connectionId = connectionId + val plaintext = ByteArray(1024) + val plen = handshakeState!!.readMessage(handshakeMessage, 0, handshakeMessage.size, plaintext, 0) + val transport = handshakeState!!.split() + completeHandshake(remoteVersion, transport) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt new file mode 100644 index 00000000..8a12b579 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Opcode.kt @@ -0,0 +1,60 @@ +package com.futo.platformplayer.sync.internal + +enum class Opcode(val value: UByte) { + PING(0u), + PONG(1u), + NOTIFY(2u), + STREAM(3u), + DATA(4u), + REQUEST(5u), + RESPONSE(6u), + RELAY(7u) +} + +enum class NotifyOpcode(val value: UByte) { + AUTHORIZED(0u), + UNAUTHORIZED(1u), + CONNECTION_INFO(2u) +} + +enum class StreamOpcode(val value: UByte) { + START(0u), + DATA(1u), + END(2u) +} + +enum class RequestOpcode(val value: UByte) { + CONNECTION_INFO(0u), + TRANSPORT(1u), + TRANSPORT_RELAYED(2u), + PUBLISH_RECORD(3u), + DELETE_RECORD(4u), + LIST_RECORD_KEYS(5u), + GET_RECORD(6u), + BULK_PUBLISH_RECORD(7u), + BULK_GET_RECORD(8u), + BULK_CONNECTION_INFO(9u), + BULK_DELETE_RECORD(10u) +} + +enum class ResponseOpcode(val value: UByte) { + CONNECTION_INFO(0u), + TRANSPORT(1u), + TRANSPORT_RELAYED(2u), //TODO: Server errors also included in this one, disentangle? + PUBLISH_RECORD(3u), + DELETE_RECORD(4u), + LIST_RECORD_KEYS(5u), + GET_RECORD(6u), + BULK_PUBLISH_RECORD(7u), + BULK_GET_RECORD(8u), + BULK_CONNECTION_INFO(9u), + BULK_DELETE_RECORD(10u) +} + +enum class RelayOpcode(val value: UByte) { + DATA(0u), + RELAYED_DATA(1u), + ERROR(2u), + RELAYED_ERROR(3u), + RELAY_ERROR(4u) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt index 17a70860..a3bb6e00 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncDeviceInfo.kt @@ -5,10 +5,12 @@ class SyncDeviceInfo { var publicKey: String var addresses: Array var port: Int + var pairingCode: String? - constructor(publicKey: String, addresses: Array, port: Int) { + constructor(publicKey: String, addresses: Array, port: Int, pairingCode: String?) { this.publicKey = publicKey this.addresses = addresses this.port = port + this.pairingCode = pairingCode } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt new file mode 100644 index 00000000..0b4be0ce --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncErrorCode.kt @@ -0,0 +1,6 @@ +package com.futo.platformplayer.sync.internal + +enum class SyncErrorCode(val value: Int) { + ConnectionClosed(1), + NotFound(2) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index e4273d63..76af1edb 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -1,37 +1,13 @@ package com.futo.platformplayer.sync.internal import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.api.media.Serializer import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.smartMerge -import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateBackup -import com.futo.platformplayer.states.StateHistory -import com.futo.platformplayer.states.StatePlaylists -import com.futo.platformplayer.states.StateSubscriptionGroups import com.futo.platformplayer.states.StateSubscriptions -import com.futo.platformplayer.states.StateSync -import com.futo.platformplayer.sync.SyncSessionData -import com.futo.platformplayer.sync.internal.SyncSocketSession.Opcode -import com.futo.platformplayer.sync.models.SendToDevicePackage -import com.futo.platformplayer.sync.models.SyncPlaylistsPackage -import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage -import com.futo.platformplayer.sync.models.SyncWatchLaterPackage -import com.futo.platformplayer.toUtf8String -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.io.ByteArrayInputStream import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID interface IAuthorizable { @@ -39,13 +15,14 @@ interface IAuthorizable { } class SyncSession : IAuthorizable { - private val _socketSessions: MutableList = mutableListOf() + private val _channels: MutableList = mutableListOf() private var _authorized: Boolean = false private var _remoteAuthorized: Boolean = false private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit private val _onUnauthorized: (session: SyncSession) -> Unit private val _onClose: (session: SyncSession) -> Unit private val _onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit + private val _dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit val remotePublicKey: String override val isAuthorized get() = _authorized && _remoteAuthorized private var _wasAuthorized = false @@ -56,140 +33,151 @@ class SyncSession : IAuthorizable { private set val displayName: String get() = remoteDeviceName ?: remotePublicKey - var connected: Boolean = false - private set(v) { - if (field != v) { - field = v - this._onConnectedChanged(this, v) + val linkType: LinkType get() + { + var hasProxied = false + var hasDirect = false + synchronized(_channels) + { + for (channel in _channels) + { + if (channel is ChannelRelayed) + hasProxied = true + if (channel is ChannelSocket) + hasDirect = true + if (hasProxied && hasDirect) + return LinkType.Local + } } + + if (hasProxied) + return LinkType.Proxied + if (hasDirect) + return LinkType.Local + return LinkType.None } - constructor(remotePublicKey: String, onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, onUnauthorized: (session: SyncSession) -> Unit, onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, onClose: (session: SyncSession) -> Unit, remoteDeviceName: String?) { + var connected: Boolean = false + private set(v) { + if (field != v) { + field = v + this._onConnectedChanged(this, v) + } + } + + constructor( + remotePublicKey: String, + onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit, + onUnauthorized: (session: SyncSession) -> Unit, + onConnectedChanged: (session: SyncSession, connected: Boolean) -> Unit, + onClose: (session: SyncSession) -> Unit, + dataHandler: (session: SyncSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit, + remoteDeviceName: String? = null + ) { this.remotePublicKey = remotePublicKey + this.remoteDeviceName = remoteDeviceName _onAuthorized = onAuthorized _onUnauthorized = onUnauthorized _onConnectedChanged = onConnectedChanged _onClose = onClose + _dataHandler = dataHandler } - fun addSocketSession(socketSession: SyncSocketSession) { - if (socketSession.remotePublicKey != remotePublicKey) { - throw Exception("Public key of session must match public key of socket session") + fun addChannel(channel: IChannel) { + if (channel.remotePublicKey != remotePublicKey) { + throw Exception("Public key of session must match public key of channel") } - synchronized(_socketSessions) { - _socketSessions.add(socketSession) - connected = _socketSessions.isNotEmpty() + synchronized(_channels) { + _channels.add(channel) + connected = _channels.isNotEmpty() } - socketSession.authorizable = this + channel.authorizable = this + channel.syncSession = this } - fun authorize(socketSession: SyncSocketSession) { + fun authorize() { Logger.i(TAG, "Sent AUTHORIZED with session id $_id") - - if (socketSession.remoteVersion >= 3) { - val idStringBytes = _id.toString().toByteArray() - val nameBytes = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}".toByteArray() - val buffer = ByteArray(1 + idStringBytes.size + 1 + nameBytes.size) - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN).apply { - put(idStringBytes.size.toByte()) - put(idStringBytes) - put(nameBytes.size.toByte()) - put(nameBytes) - }.apply { flip() }) - } else { - socketSession.send(Opcode.NOTIFY_AUTHORIZED.value, 0u, ByteBuffer.wrap(_id.toString().toByteArray())) - } + val idString = _id.toString() + val idBytes = idString.toByteArray(Charsets.UTF_8) + val name = "${android.os.Build.MANUFACTURER}-${android.os.Build.MODEL}" + val nameBytes = name.toByteArray(Charsets.UTF_8) + val buffer = ByteArray(1 + idBytes.size + 1 + nameBytes.size) + buffer[0] = idBytes.size.toByte() + System.arraycopy(idBytes, 0, buffer, 1, idBytes.size) + buffer[1 + idBytes.size] = nameBytes.size.toByte() + System.arraycopy(nameBytes, 0, buffer, 2 + idBytes.size, nameBytes.size) + send(Opcode.NOTIFY.value, NotifyOpcode.AUTHORIZED.value, ByteBuffer.wrap(buffer)) _authorized = true checkAuthorized() } - fun unauthorize(socketSession: SyncSocketSession? = null) { - if (socketSession != null) - socketSession.send(Opcode.NOTIFY_UNAUTHORIZED.value) - else { - val ss = synchronized(_socketSessions) { - _socketSessions.first() - } - - ss.send(Opcode.NOTIFY_UNAUTHORIZED.value) - } + fun unauthorize() { + send(Opcode.NOTIFY.value, NotifyOpcode.UNAUTHORIZED.value) } private fun checkAuthorized() { if (isAuthorized) { - val isNewlyAuthorized = !_wasAuthorized; - val isNewSession = _lastAuthorizedRemoteId != _remoteId; - Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)"); - _onAuthorized.invoke(this, !_wasAuthorized, _lastAuthorizedRemoteId != _remoteId) + val isNewlyAuthorized = !_wasAuthorized + val isNewSession = _lastAuthorizedRemoteId != _remoteId + Logger.i(TAG, "onAuthorized (isNewlyAuthorized = $isNewlyAuthorized, isNewSession = $isNewSession)") + _onAuthorized(this, isNewlyAuthorized, isNewSession) _wasAuthorized = true _lastAuthorizedRemoteId = _remoteId } } - fun removeSocketSession(socketSession: SyncSocketSession) { - synchronized(_socketSessions) { - _socketSessions.remove(socketSession) - connected = _socketSessions.isNotEmpty() + fun removeChannel(channel: IChannel) { + synchronized(_channels) { + _channels.remove(channel) + connected = _channels.isNotEmpty() } } fun close() { - synchronized(_socketSessions) { - for (socketSession in _socketSessions) { - socketSession.stop() - } - - _socketSessions.clear() + synchronized(_channels) { + _channels.forEach { it.close() } + _channels.clear() } - - _onClose.invoke(this) + _onClose(this) } - fun handlePacket(socketSession: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { try { - Logger.i(TAG, "Handle packet (opcode: ${opcode}, subOpcode: ${subOpcode}, data.length: ${data.remaining()})") + Logger.i(TAG, "Handle packet (opcode: $opcode, subOpcode: $subOpcode, data.length: ${data.remaining()})") when (opcode) { - Opcode.NOTIFY_AUTHORIZED.value -> { - if (socketSession.remoteVersion >= 3) { + Opcode.NOTIFY.value -> when (subOpcode) { + NotifyOpcode.AUTHORIZED.value -> { val idByteCount = data.get().toInt() if (idByteCount > 64) throw Exception("Id should always be smaller than 64 bytes") - val idBytes = ByteArray(idByteCount) data.get(idBytes) val nameByteCount = data.get().toInt() if (nameByteCount > 64) throw Exception("Name should always be smaller than 64 bytes") - val nameBytes = ByteArray(nameByteCount) data.get(nameBytes) _remoteId = UUID.fromString(idBytes.toString(Charsets.UTF_8)) remoteDeviceName = nameBytes.toString(Charsets.UTF_8) - } else { - val str = data.toUtf8String() - _remoteId = if (data.remaining() >= 0) UUID.fromString(str) else UUID.fromString("00000000-0000-0000-0000-000000000000") - remoteDeviceName = null + _remoteAuthorized = true + Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") + checkAuthorized() + return + } + NotifyOpcode.UNAUTHORIZED.value -> { + _remoteAuthorized = false + _remoteId = null + remoteDeviceName = null + _lastAuthorizedRemoteId = null + _onUnauthorized(this) + return } - - _remoteAuthorized = true - Logger.i(TAG, "Received AUTHORIZED with session id $_remoteId (device name: '${remoteDeviceName ?: "not set"}')") - checkAuthorized() - return } - Opcode.NOTIFY_UNAUTHORIZED.value -> { - _remoteId = null - remoteDeviceName = null - _lastAuthorizedRemoteId = null - _remoteAuthorized = false - _onUnauthorized(this) - return - } - //TODO: Handle any kind of packet (that is not necessarily authorized) } if (!isAuthorized) { @@ -197,282 +185,58 @@ class SyncSession : IAuthorizable { } if (opcode != Opcode.DATA.value) { - Logger.w(TAG, "Unknown opcode received: (opcode = ${opcode}, subOpcode = ${subOpcode})}") + Logger.w(TAG, "Unknown opcode received: (opcode = $opcode, subOpcode = $subOpcode)") return } - Logger.i(TAG, "Received (opcode = ${opcode}, subOpcode = ${subOpcode}) (${data.remaining()} bytes)") - //TODO: Abstract this out - when (subOpcode) { - GJSyncOpcodes.sendToDevices -> { - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - val context = StateApp.instance.contextOrNull; - if (context != null && context is MainActivity) { - val dataBody = ByteArray(data.remaining()); - val remainder = data.remaining(); - data.get(dataBody, 0, remainder); - val json = String(dataBody, Charsets.UTF_8); - val obj = Json.decodeFromString(json); - UIDialogs.appToast("Received url from device [${socketSession.remotePublicKey}]:\n{${obj.url}"); - context.handleUrl(obj.url, obj.position); - } - }; - } - - GJSyncOpcodes.syncStateExchange -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val syncSessionData = Serializer.json.decodeFromString(json); - - Logger.i(TAG, "Received SyncSessionData from " + remotePublicKey); - - - sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); - sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); - sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) - - sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); - - val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); - if(recentHistory.size > 0) - sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); - } - - GJSyncOpcodes.syncExport -> { - val dataBody = ByteArray(data.remaining()); - val bytesStr = ByteArrayInputStream(data.array(), data.position(), data.remaining()); - try { - val exportStruct = StateBackup.ExportStructure.fromZipBytes(bytesStr); - for (store in exportStruct.stores) { - if (store.key.equals("subscriptions", true)) { - val subStore = - StateSubscriptions.instance.getUnderlyingSubscriptionsStore(); - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - val pack = SyncSubscriptionsPackage( - store.value.map { - subStore.fromReconstruction(it, exportStruct.cache) - }, - StateSubscriptions.instance.getSubscriptionRemovals() - ); - handleSyncSubscriptionPackage(this@SyncSession, pack); - } - } - } - } finally { - bytesStr.close(); - } - } - - GJSyncOpcodes.syncSubscriptions -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val subPackage = Serializer.json.decodeFromString(json); - handleSyncSubscriptionPackage(this, subPackage); - - val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; - - val sesData = StateSync.instance.getSyncSessionData(remotePublicKey); - if(newestSub > sesData.lastSubscription) { - sesData.lastSubscription = newestSub; - StateSync.instance.saveSyncSessionData(sesData); - } - } - - GJSyncOpcodes.syncSubscriptionGroups -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - var lastSubgroupChange = OffsetDateTime.MIN; - for(group in pack.groups){ - if(group.lastChange > lastSubgroupChange) - lastSubgroupChange = group.lastChange; - - val existing = StateSubscriptionGroups.instance.getSubscriptionGroup(group.id); - - if(existing == null) - StateSubscriptionGroups.instance.updateSubscriptionGroup(group, false, true); - else if(existing.lastChange < group.lastChange) { - existing.name = group.name; - existing.urls = group.urls; - existing.image = group.image; - existing.priority = group.priority; - existing.lastChange = group.lastChange; - StateSubscriptionGroups.instance.updateSubscriptionGroup(existing, false, true); - } - } - for(removal in pack.groupRemovals) { - val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); - if(creation != null && creation.creationTime < removalTime) - StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); - } - } - - GJSyncOpcodes.syncPlaylists -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - for(playlist in pack.playlists) { - val existing = StatePlaylists.instance.getPlaylist(playlist.id); - - if(existing == null) - StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); - else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { - existing.dateUpdate = playlist.dateUpdate; - existing.name = playlist.name; - existing.videos = playlist.videos; - existing.dateCreation = playlist.dateCreation; - existing.datePlayed = playlist.datePlayed; - StatePlaylists.instance.createOrUpdatePlaylist(existing, false); - } - } - for(removal in pack.playlistRemovals) { - val creation = StatePlaylists.instance.getPlaylist(removal.key); - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); - if(creation != null && creation.dateCreation < removalTime) - StatePlaylists.instance.removePlaylist(creation, false); - - } - } - - GJSyncOpcodes.syncWatchLater -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val pack = Serializer.json.decodeFromString(json); - - Logger.i(TAG, "SyncWatchLater received ${pack.videos.size} (${pack.videoAdds?.size}, ${pack.videoRemovals?.size})"); - - val allExisting = StatePlaylists.instance.getWatchLater(); - for(video in pack.videos) { - val existing = allExisting.firstOrNull { it.url == video.url }; - val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN; - - if(existing == null) { - StatePlaylists.instance.addToWatchLater(video, false); - if(time > OffsetDateTime.MIN) - StatePlaylists.instance.setWatchLaterAddTime(video.url, time); - } - } - for(removal in pack.videoRemovals) { - val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; - val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC); - if(creation < removalTime) - StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); - } - - val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC); - val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); - if(localReorderTime < packReorderTime && pack.ordering != null) { - StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); - } - } - - GJSyncOpcodes.syncHistory -> { - val dataBody = ByteArray(data.remaining()); - data.get(dataBody); - val json = String(dataBody, Charsets.UTF_8); - val history = Serializer.json.decodeFromString>(json); - Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); - - var lastHistory = OffsetDateTime.MIN; - for(video in history){ - val hist = StateHistory.instance.getHistoryByVideo(video.video, true, video.date); - if(hist != null) - StateHistory.instance.updateHistoryPosition(video.video, hist, true, video.position, video.date) - if(lastHistory < video.date) - lastHistory = video.date; - } - - if(lastHistory != OffsetDateTime.MIN && history.size > 1) { - val sesData = StateSync.instance.getSyncSessionData(remotePublicKey); - if (lastHistory > sesData.lastHistory) { - sesData.lastHistory = lastHistory; - StateSync.instance.saveSyncSessionData(sesData); - } - } - } - } + Logger.i(TAG, "Received (opcode = $opcode, subOpcode = $subOpcode) (${data.remaining()} bytes)") + _dataHandler.invoke(this, opcode, subOpcode, data) + } catch (ex: Exception) { + Logger.w(TAG, "Failed to handle sync package $opcode: ${ex.message}", ex) } catch(ex: Exception) { Logger.w(TAG, "Failed to handle sync package ${opcode}: ${ex.message}", ex); } } - private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { - val added = mutableListOf() - for(sub in pack.subscriptions) { - if(!StateSubscriptions.instance.isSubscribed(sub.channel)) { - val removalTime = StateSubscriptions.instance.getSubscriptionRemovalTime(sub.channel.url); - if(sub.creationTime > removalTime) { - val newSub = StateSubscriptions.instance.addSubscription(sub.channel, sub.creationTime); - added.add(newSub); - } - } - } - if(added.size > 3) - UIDialogs.appToast("${added.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); - else if(added.size > 0) - UIDialogs.appToast("Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + - added.map { it.channel.name }.joinToString("\n")); - - - if(pack.subscriptions != null && pack.subscriptions.size > 0) { - for (subRemoved in pack.subscriptionRemovals) { - val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); - if(removed.size > 3) - UIDialogs.appToast("Removed ${removed.size} Subscriptions from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}"); - else if(removed.size > 0) - UIDialogs.appToast("Subscriptions removed from ${origin.remotePublicKey.substring(0, Math.min(8, origin.remotePublicKey.length))}:\n" + - removed.map { it.channel.name }.joinToString("\n")); - - } - } - } - inline fun sendJsonData(subOpcode: UByte, data: T) { - send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); + send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)) } - fun sendData(subOpcode: UByte, data: String) { - send(Opcode.DATA.value, subOpcode, data.toByteArray(Charsets.UTF_8)); - } - fun send(opcode: UByte, subOpcode: UByte, data: String) { - send(opcode, subOpcode, data.toByteArray(Charsets.UTF_8)); - } - fun send(opcode: UByte, subOpcode: UByte, data: ByteArray) { - val socketSessions = synchronized(_socketSessions) { - _socketSessions.toList() - } - if (socketSessions.isEmpty()) { - Logger.v(TAG, "Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to no connected sockets") + fun sendData(subOpcode: UByte, data: String) { + send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))) + } + + fun send(opcode: UByte, subOpcode: UByte, data: String) { + send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))) + } + + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) { + //TODO: Prioritize local connections + val channels = synchronized(_channels) { _channels.toList() } + if (channels.isEmpty()) { + //TODO: Should this throw? + Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets") return } var sent = false - for (socketSession in socketSessions) { + for (channel in channels) { try { - socketSession.send(opcode, subOpcode, ByteBuffer.wrap(data)) + channel.send(opcode, subOpcode, data) sent = true break } catch (e: Throwable) { - Logger.w(TAG, "Packet failed to send (opcode = ${opcode}, subOpcode = ${subOpcode})", e) + Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e) } } if (!sent) { - throw Exception("Packet was not sent (opcode = ${opcode}, subOpcode = ${subOpcode}) due to send errors and no remaining candidates") + throw Exception("Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to send errors and no remaining candidates") } } - private companion object { - const val TAG = "SyncSession" + companion object { + private const val TAG = "SyncSession" } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index c997cec4..c8f4f683 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.sync.internal +import android.os.Build import com.futo.platformplayer.LittleEndianDataInputStream import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.ensureNotMainThread @@ -8,22 +9,19 @@ import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync +import kotlinx.coroutines.CompletableDeferred +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface import java.nio.ByteBuffer import java.nio.ByteOrder -import java.util.UUID +import java.util.Base64 +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.min class SyncSocketSession { - enum class Opcode(val value: UByte) { - PING(0u), - PONG(1u), - NOTIFY_AUTHORIZED(2u), - NOTIFY_UNAUTHORIZED(3u), - STREAM_START(4u), - STREAM_DATA(5u), - STREAM_END(6u), - DATA(7u) - } - private val _inputStream: LittleEndianDataInputStream private val _outputStream: LittleEndianDataOutputStream private val _sendLockObject = Object() @@ -32,11 +30,15 @@ class SyncSocketSession { private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE) private val _sendBufferEncrypted = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _syncStreams = hashMapOf() - private val _streamIdGenerator = 0 + private var _streamIdGenerator = 0 private val _streamIdGeneratorLock = Object() - private val _onClose: (session: SyncSocketSession) -> Unit - private val _onHandshakeComplete: (session: SyncSocketSession) -> Unit - private var _thread: Thread? = null + private var _requestIdGenerator = 0 + private val _requestIdGeneratorLock = Object() + private val _onClose: ((session: SyncSocketSession) -> Unit)? + private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? + private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? + private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? + private val _isHandshakeAllowed: ((session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? private var _cipherStatePair: CipherStatePair? = null private var _remotePublicKey: String? = null val remotePublicKey: String? get() = _remotePublicKey @@ -44,55 +46,90 @@ class SyncSocketSession { private val _localKeyPair: DHState private var _localPublicKey: String val localPublicKey: String get() = _localPublicKey - private val _onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit + private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? + val isAuthorized: Boolean + get() = authorizable?.isAuthorized ?: false var authorizable: IAuthorizable? = null var remoteVersion: Int = -1 private set val remoteAddress: String - constructor(remoteAddress: String, localKeyPair: DHState, inputStream: LittleEndianDataInputStream, outputStream: LittleEndianDataOutputStream, onClose: (session: SyncSocketSession) -> Unit, onHandshakeComplete: (session: SyncSocketSession) -> Unit, onData: (session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit) { + private val _channels = ConcurrentHashMap() + private val _pendingChannels = ConcurrentHashMap>>() + private val _pendingConnectionInfoRequests = ConcurrentHashMap>() + private val _pendingPublishRequests = ConcurrentHashMap>() + private val _pendingDeleteRequests = ConcurrentHashMap>() + private val _pendingListKeysRequests = ConcurrentHashMap>>>() + private val _pendingGetRecordRequests = ConcurrentHashMap?>>() + private val _pendingBulkGetRecordRequests = ConcurrentHashMap>>>() + private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap>>() + + data class ConnectionInfo( + val port: UShort, + val name: String, + val remoteIp: String, + val ipv4Addresses: List, + val ipv6Addresses: List, + val allowLocalDirect: Boolean, + val allowRemoteDirect: Boolean, + val allowRemoteHolePunched: Boolean, + val allowRemoteProxied: Boolean + ) + + constructor( + remoteAddress: String, + localKeyPair: DHState, + inputStream: LittleEndianDataInputStream, + outputStream: LittleEndianDataOutputStream, + onClose: ((session: SyncSocketSession) -> Unit)? = null, + onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null, + onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, + onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, + onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, + isHandshakeAllowed: ((session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? = null + ) { _inputStream = inputStream _outputStream = outputStream _onClose = onClose _onHandshakeComplete = onHandshakeComplete _localKeyPair = localKeyPair _onData = onData + _onNewChannel = onNewChannel + _onChannelEstablished = onChannelEstablished + _isHandshakeAllowed = isHandshakeAllowed this.remoteAddress = remoteAddress val localPublicKey = ByteArray(localKeyPair.publicKeyLength) localKeyPair.getPublicKey(localPublicKey, 0) - _localPublicKey = java.util.Base64.getEncoder().encodeToString(localPublicKey) + _localPublicKey = Base64.getEncoder().encodeToString(localPublicKey) } - fun startAsInitiator(remotePublicKey: String) { + fun startAsInitiator(remotePublicKey: String, pairingCode: String? = null) { _started = true - _thread = Thread { - try { - handshakeAsInitiator(remotePublicKey) - _onHandshakeComplete.invoke(this) - receiveLoop() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to run as initiator", e) - } finally { - stop() - } - }.apply { start() } + try { + handshakeAsInitiator(remotePublicKey, pairingCode) + _onHandshakeComplete?.invoke(this) + receiveLoop() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to run as initiator", e) + } finally { + stop() + } } fun startAsResponder() { _started = true - _thread = Thread { - try { - handshakeAsResponder() - _onHandshakeComplete.invoke(this) + try { + if (handshakeAsResponder()) { + _onHandshakeComplete?.invoke(this) receiveLoop() - } catch(e: Throwable) { - Logger.e(TAG, "Failed to run as responder", e) - } finally { - stop() } - }.apply { start() } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to run as responder", e) + } finally { + stop() + } } private fun receiveLoop() { @@ -116,7 +153,7 @@ class SyncSocketSession { val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize) //Logger.i(TAG, "Decrypted message (size = ${plen})") - handleData(_bufferDecrypted, plen) + handleData(_bufferDecrypted, plen, null) } catch (e: Throwable) { Logger.e(TAG, "Exception while receiving data", e) break @@ -126,46 +163,129 @@ class SyncSocketSession { fun stop() { _started = false - _onClose(this) + _pendingConnectionInfoRequests.forEach { it.value.cancel() } + _pendingConnectionInfoRequests.clear() + _pendingPublishRequests.forEach { it.value.cancel() } + _pendingPublishRequests.clear() + _pendingDeleteRequests.forEach { it.value.cancel() } + _pendingDeleteRequests.clear() + _pendingListKeysRequests.forEach { it.value.cancel() } + _pendingListKeysRequests.clear() + _pendingGetRecordRequests.forEach { it.value.cancel() } + _pendingGetRecordRequests.clear() + _pendingBulkGetRecordRequests.forEach { it.value.cancel() } + _pendingBulkGetRecordRequests.clear() + _pendingBulkConnectionInfoRequests.forEach { it.value.cancel() } + _pendingBulkConnectionInfoRequests.clear() + _pendingChannels.forEach { it.value.first.close(); it.value.second.cancel() } + _pendingChannels.clear() + synchronized(_syncStreams) { + _syncStreams.clear() + } + _channels.values.forEach { it.close() } + _channels.clear() + _onClose?.invoke(this) _inputStream.close() _outputStream.close() - _thread = null + _cipherStatePair?.sender?.destroy() + _cipherStatePair?.receiver?.destroy() Logger.i(TAG, "Session closed") } - private fun handshakeAsInitiator(remotePublicKey: String) { + private fun handshakeAsInitiator(remotePublicKey: String, pairingCode: String?) { performVersionCheck() val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) initiator.localKeyPair.copyFrom(_localKeyPair) + initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) + initiator.start() - initiator.remotePublicKey.setPublicKey(java.util.Base64.getDecoder().decode(remotePublicKey), 0) - _cipherStatePair = handshake(initiator) - - _remotePublicKey = initiator.remotePublicKey.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let java.util.Base64.getEncoder().encodeToString(pkey) + val pairingMessage: ByteArray + val pairingMessageLength: Int + if (pairingCode != null) { + val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) + pairingHandshake.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) + pairingHandshake.start() + val pairingCodeBytes = pairingCode.toByteArray(Charsets.UTF_8) + val pairingBuffer = ByteArray(512) + pairingMessageLength = pairingHandshake.writeMessage(pairingBuffer, 0, pairingCodeBytes, 0, pairingCodeBytes.size) + pairingMessage = pairingBuffer.copyOf(pairingMessageLength) + } else { + pairingMessage = ByteArray(0) + pairingMessageLength = 0 } + + val mainBuffer = ByteArray(512) + val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) + + val messageData = ByteBuffer.allocate(4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN) + messageData.putInt(pairingMessageLength) + if (pairingMessageLength > 0) messageData.put(pairingMessage) + messageData.put(mainBuffer, 0, mainLength) + val messageDataArray = messageData.array() + _outputStream.writeInt(messageDataArray.size) + _outputStream.write(messageDataArray) + + val responseSize = _inputStream.readInt() + val responseMessage = ByteArray(responseSize) + _inputStream.readFully(responseMessage) + val plaintext = ByteArray(512) // Buffer for any payload (none expected here) + initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0) + + _cipherStatePair = initiator.split() + val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) + initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) } - private fun handshakeAsResponder() { + private fun handshakeAsResponder(): Boolean { performVersionCheck() val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER) responder.localKeyPair.copyFrom(_localKeyPair) - _cipherStatePair = handshake(responder) + responder.start() - _remotePublicKey = responder.remotePublicKey.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let java.util.Base64.getEncoder().encodeToString(pkey) + val messageSize = _inputStream.readInt() + val message = ByteArray(messageSize) + _inputStream.readFully(message) + val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) + + val pairingMessageLength = messageBuffer.int + val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() + val mainLength = messageSize - 4 - pairingMessageLength + val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } + + var pairingCode: String? = null + if (pairingMessageLength > 0) { + val pairingHandshake = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER) + pairingHandshake.localKeyPair.copyFrom(_localKeyPair) + pairingHandshake.start() + val pairingPlaintext = ByteArray(512) + val plaintextLength = pairingHandshake.readMessage(pairingMessage, 0, pairingMessageLength, pairingPlaintext, 0) + pairingCode = String(pairingPlaintext, 0, plaintextLength, Charsets.UTF_8) + } + + val plaintext = ByteArray(512) + responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) + + val responseBuffer = ByteArray(512) + val responseLength = responder.writeMessage(responseBuffer, 0, null, 0, 0) + _outputStream.writeInt(responseLength) + _outputStream.write(responseBuffer, 0, responseLength) + + _cipherStatePair = responder.split() + val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) + responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + + return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(this, _remotePublicKey!!, pairingCode) ?: true)).also { + if (!it) stop() } } private fun performVersionCheck() { - val CURRENT_VERSION = 3 - val MINIMUM_VERSION = 2 + val CURRENT_VERSION = 4 + val MINIMUM_VERSION = 4 _outputStream.writeInt(CURRENT_VERSION) remoteVersion = _inputStream.readInt() Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") @@ -173,44 +293,8 @@ class SyncSocketSession { throw Exception("Invalid version") } - private fun handshake(handshakeState: HandshakeState): CipherStatePair { - handshakeState.start() - - val message = ByteArray(8192) - val plaintext = ByteArray(8192) - - while (_started) { - when (handshakeState.action) { - HandshakeState.READ_MESSAGE -> { - val messageSize = _inputStream.readInt() - Logger.i(TAG, "Handshake read message (size = ${messageSize})") - - var bytesRead = 0 - while (bytesRead < messageSize) { - val read = _inputStream.read(message, bytesRead, messageSize - bytesRead) - if (read == -1) - throw Exception("Stream closed") - bytesRead += read - } - - handshakeState.readMessage(message, 0, messageSize, plaintext, 0) - } - HandshakeState.WRITE_MESSAGE -> { - val messageSize = handshakeState.writeMessage(message, 0, null, 0, 0) - Logger.i(TAG, "Handshake wrote message (size = ${messageSize})") - _outputStream.writeInt(messageSize) - _outputStream.write(message, 0, messageSize) - } - HandshakeState.SPLIT -> { - //Logger.i(TAG, "Handshake split") - return handshakeState.split() - } - else -> throw Exception("Unexpected state (handshakeState.action = ${handshakeState.action})") - } - } - - throw Exception("Handshake finished without completing") - } + fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } + private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { ensureNotMainThread() @@ -219,37 +303,35 @@ class SyncSocketSession { val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentData = ByteArray(segmentSize) var sendOffset = 0 - val id = synchronized(_streamIdGeneratorLock) { - _streamIdGenerator + 1 - } + val id = generateStreamId() while (sendOffset < data.remaining()) { val bytesRemaining = data.remaining() - sendOffset var bytesToSend: Int var segmentPacketSize: Int - val segmentOpcode: UByte + val streamOp: StreamOpcode if (sendOffset == 0) { - segmentOpcode = Opcode.STREAM_START.value + streamOp = StreamOpcode.START bytesToSend = segmentSize - 4 - 4 - 1 - 1 segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1 } else { bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining) - segmentOpcode = if (bytesToSend >= bytesRemaining) Opcode.STREAM_END.value else Opcode.STREAM_DATA.value + streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA segmentPacketSize = bytesToSend + 4 + 4 } ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(id) - putInt(if (segmentOpcode == Opcode.STREAM_START.value) data.remaining() else sendOffset) - if (segmentOpcode == Opcode.STREAM_START.value) { + putInt(if (streamOp == StreamOpcode.START) data.remaining() else sendOffset) + if (streamOp == StreamOpcode.START) { put(opcode.toByte()) put(subOpcode.toByte()) } put(data.array(), data.position() + sendOffset, bytesToSend) } - send(segmentOpcode, 0u, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) + send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) sendOffset += bytesToSend } } else { @@ -270,6 +352,7 @@ class SyncSocketSession { } } + @OptIn(ExperimentalUnsignedTypes::class) fun send(opcode: UByte, subOpcode: UByte = 0u) { ensureNotMainThread() @@ -288,108 +371,806 @@ class SyncSocketSession { } } - private fun handleData(data: ByteArray, length: Int) { + private fun handleData(data: ByteArray, length: Int, sourceChannel: ChannelRelayed?) { + return handleData(ByteBuffer.wrap(data, 0, length).order(ByteOrder.LITTLE_ENDIAN), sourceChannel) + } + + private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) { + val length = data.remaining() if (length < HEADER_SIZE) throw Exception("Packet must be at least 6 bytes (header size)") - val size = ByteBuffer.wrap(data, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + val size = data.int if (size != length - 4) throw Exception("Incomplete packet received") - val opcode = data.asUByteArray()[4] - val subOpcode = data.asUByteArray()[5] - val packetData = ByteBuffer.wrap(data, HEADER_SIZE, size - 2) - handlePacket(opcode, subOpcode, packetData.order(ByteOrder.LITTLE_ENDIAN)) + val opcode = data.get().toUByte() + val subOpcode = data.get().toUByte() + handlePacket(opcode, subOpcode, data, sourceChannel) } - private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + when (subOpcode) { + RequestOpcode.TRANSPORT_RELAYED.value -> { + Logger.i(TAG, "Received request for a relayed transport") + if (data.remaining() < 52) { + Logger.e(TAG, "HandleRequestTransport: Packet too short") + return + } + val remoteVersion = data.int + val connectionId = data.long + val requestId = data.int + val publicKeyBytes = ByteArray(32).also { data.get(it) } + val pairingMessageLength = data.int + if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128)") + val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0) + val channelMessageLength = data.int + if (data.remaining() != channelMessageLength) { + Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()}") + return + } + val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } + val publicKey = Base64.getEncoder().encodeToString(publicKeyBytes) + val pairingCode = if (pairingMessageLength > 0) { + val pairingProtocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val plaintext = ByteArray(1024) + val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) + String(plaintext, 0, length, Charsets.UTF_8) + } else null + val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(this, publicKey, pairingCode) ?: true) + if (!isAllowed) { + val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) + rp.putInt(2) // Status code for not allowed + rp.putLong(connectionId) + rp.putInt(requestId) + rp.rewind() + send(Opcode.RESPONSE.value, ResponseOpcode.TRANSPORT.value, rp) + return + } + val channel = ChannelRelayed(this, _localKeyPair, publicKey, false) + channel.connectionId = connectionId + _onNewChannel?.invoke(this, channel) + _channels[connectionId] = channel + channel.sendResponseTransport(remoteVersion, requestId, channelHandshakeMessage) + _onChannelEstablished?.invoke(this, channel, true) + } + else -> Logger.w(TAG, "Unhandled request opcode: $subOpcode") + } + } + + private fun handleResponse(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + if (data.remaining() < 8) { + Logger.e(TAG, "Response packet too short") + return + } + val requestId = data.int + val statusCode = data.int + when (subOpcode) { + ResponseOpcode.CONNECTION_INFO.value -> { + _pendingConnectionInfoRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val connectionInfo = parseConnectionInfo(data) + tcs.complete(connectionInfo) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else { + tcs.complete(null) + } + } ?: Logger.e(TAG, "No pending request for requestId $requestId") + } + ResponseOpcode.TRANSPORT_RELAYED.value -> { + if (statusCode == 0) { + if (data.remaining() < 16) { + Logger.e(TAG, "RESPONSE_TRANSPORT packet too short") + return + } + val remoteVersion = data.int + val connectionId = data.long + val messageLength = data.int + if (data.remaining() != messageLength) { + Logger.e(TAG, "Invalid RESPONSE_TRANSPORT packet size. Expected ${16 + messageLength}, got ${data.remaining() + 16}") + return + } + val handshakeMessage = ByteArray(messageLength).also { data.get(it) } + _pendingChannels.remove(requestId)?.let { (channel, tcs) -> + channel.handleTransportRelayed(remoteVersion, connectionId, handshakeMessage) + _channels[connectionId] = channel + tcs.complete(channel) + _onChannelEstablished?.invoke(this, channel, false) + } ?: Logger.e(TAG, "No pending channel for requestId $requestId") + } else { + _pendingChannels.remove(requestId)?.let { (channel, tcs) -> + channel.close() + tcs.completeExceptionally(Exception("Relayed transport request $requestId failed with code $statusCode")) + } + } + } + ResponseOpcode.PUBLISH_RECORD.value, ResponseOpcode.BULK_PUBLISH_RECORD.value -> { + _pendingPublishRequests.remove(requestId)?.complete(statusCode == 0) + ?: Logger.e(TAG, "No pending publish request for requestId $requestId") + } + ResponseOpcode.DELETE_RECORD.value, ResponseOpcode.BULK_DELETE_RECORD.value -> { + _pendingDeleteRequests.remove(requestId)?.complete(statusCode == 0) + ?: Logger.e(TAG, "No pending delete request for requestId $requestId") + } + ResponseOpcode.LIST_RECORD_KEYS.value -> { + _pendingListKeysRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val keyCount = data.int + val keys = mutableListOf>() + repeat(keyCount) { + val keyLength = data.get().toInt() + val key = ByteArray(keyLength).also { data.get(it) }.toString(Charsets.UTF_8) + val timestamp = data.long + keys.add(key to timestamp) + } + tcs.complete(keys) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else { + tcs.completeExceptionally(Exception("Error listing keys: status code $statusCode")) + } + } ?: Logger.e(TAG, "No pending list keys request for requestId $requestId") + } + ResponseOpcode.GET_RECORD.value -> { + _pendingGetRecordRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val blobLength = data.int + val encryptedBlob = ByteArray(blobLength).also { data.get(it) } + val timestamp = data.long + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val handshakeMessage = encryptedBlob.copyOf(48) + val plaintext = ByteArray(0) + protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) + val transportPair = protocol.split() + var blobOffset = 48 + val chunks = mutableListOf() + while (blobOffset + 4 <= encryptedBlob.size) { + val chunkLength = ByteBuffer.wrap(encryptedBlob, blobOffset, 4).order(ByteOrder.LITTLE_ENDIAN).int + blobOffset += 4 + val encryptedChunk = encryptedBlob.copyOfRange(blobOffset, blobOffset + chunkLength) + val decryptedChunk = ByteArray(chunkLength - 16) + transportPair.receiver.decryptWithAd(null, encryptedChunk, 0, decryptedChunk, 0, encryptedChunk.size) + chunks.add(decryptedChunk) + blobOffset += chunkLength + } + val dataResult = chunks.reduce { acc, bytes -> acc + bytes } + tcs.complete(dataResult to timestamp) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else if (statusCode == 2) { + tcs.complete(null) + } else { + tcs.completeExceptionally(Exception("Error getting record: statusCode $statusCode")) + } + } + } + ResponseOpcode.BULK_GET_RECORD.value -> { + _pendingBulkGetRecordRequests.remove(requestId)?.let { tcs -> + if (statusCode == 0) { + try { + val recordCount = data.get().toInt() + val records = mutableMapOf>() + repeat(recordCount) { + val publisherBytes = ByteArray(32).also { data.get(it) } + val publisher = Base64.getEncoder().encodeToString(publisherBytes) + val blobLength = data.int + val encryptedBlob = ByteArray(blobLength).also { data.get(it) } + val timestamp = data.long + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val handshakeMessage = encryptedBlob.copyOf(48) + val plaintext = ByteArray(0) + protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) + val transportPair = protocol.split() + var blobOffset = 48 + val chunks = mutableListOf() + while (blobOffset + 4 <= encryptedBlob.size) { + val chunkLength = ByteBuffer.wrap(encryptedBlob, blobOffset, 4).order(ByteOrder.LITTLE_ENDIAN).int + blobOffset += 4 + val encryptedChunk = encryptedBlob.copyOfRange(blobOffset, blobOffset + chunkLength) + val decryptedChunk = ByteArray(chunkLength - 16) + transportPair.receiver.decryptWithAd(null, encryptedChunk, 0, decryptedChunk, 0, encryptedChunk.size) + chunks.add(decryptedChunk) + blobOffset += chunkLength + } + val dataResult = chunks.reduce { acc, bytes -> acc + bytes } + records[publisher] = dataResult to timestamp + } + tcs.complete(records) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } else { + tcs.completeExceptionally(Exception("Error getting bulk records: statusCode $statusCode")) + } + } + } + ResponseOpcode.BULK_CONNECTION_INFO.value -> { + _pendingBulkConnectionInfoRequests.remove(requestId)?.let { tcs -> + try { + val numResponses = data.get().toInt() + val result = mutableMapOf() + repeat(numResponses) { + val publicKey = Base64.getEncoder().encodeToString(ByteArray(32).also { data.get(it) }) + val status = data.get().toInt() + if (status == 0) { + val infoSize = data.int + val infoData = ByteArray(infoSize).also { data.get(it) } + result[publicKey] = parseConnectionInfo(ByteBuffer.wrap(infoData).order(ByteOrder.LITTLE_ENDIAN)) + } + } + tcs.complete(result) + } catch (e: Exception) { + tcs.completeExceptionally(e) + } + } ?: Logger.e(TAG, "No pending bulk request for requestId $requestId") + } + } + } + + private fun parseConnectionInfo(data: ByteBuffer): ConnectionInfo { + val ipSize = data.get().toInt() + val remoteIpBytes = ByteArray(ipSize).also { data.get(it) } + val remoteIp = remoteIpBytes.joinToString(".") { it.toUByte().toString() } + val handshakeMessage = ByteArray(48).also { data.get(it) } + val ciphertext = ByteArray(data.remaining()).also { data.get(it) } + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.RESPONDER).apply { + localKeyPair.copyFrom(_localKeyPair) + start() + } + val plaintext = ByteArray(0) + protocol.readMessage(handshakeMessage, 0, 48, plaintext, 0) + val transportPair = protocol.split() + val decryptedData = ByteArray(ciphertext.size - 16) + transportPair.receiver.decryptWithAd(null, ciphertext, 0, decryptedData, 0, ciphertext.size) + val info = ByteBuffer.wrap(decryptedData).order(ByteOrder.LITTLE_ENDIAN) + val port = info.short.toUShort() + val nameLength = info.get().toInt() + val name = ByteArray(nameLength).also { info.get(it) }.toString(Charsets.UTF_8) + val ipv4Count = info.get().toInt() + val ipv4Addresses = List(ipv4Count) { ByteArray(4).also { info.get(it) }.joinToString(".") { it.toUByte().toString() } } + val ipv6Count = info.get().toInt() + val ipv6Addresses = List(ipv6Count) { ByteArray(16).also { info.get(it) }.joinToString(":") { it.toUByte().toString(16).padStart(2, '0') } } + val allowLocalDirect = info.get() != 0.toByte() + val allowRemoteDirect = info.get() != 0.toByte() + val allowRemoteHolePunched = info.get() != 0.toByte() + val allowRemoteProxied = info.get() != 0.toByte() + return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteProxied) + } + + private fun handleNotify(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + when (subOpcode) { + NotifyOpcode.AUTHORIZED.value, NotifyOpcode.UNAUTHORIZED.value -> { + if (sourceChannel != null) + sourceChannel.invokeDataHandler(Opcode.NOTIFY.value, subOpcode, data) + else + _onData?.invoke(this, Opcode.NOTIFY.value, subOpcode, data) + } + NotifyOpcode.CONNECTION_INFO.value -> { /* Handle connection info if needed */ } + } + } + + fun sendRelayError(connectionId: Long, errorCode: SyncErrorCode) { + val packet = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) + packet.putLong(connectionId) + packet.putInt(errorCode.value) + packet.rewind() + send(Opcode.RELAY.value, RelayOpcode.RELAY_ERROR.value, packet) + } + + private fun handleRelay(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + when (subOpcode) { + RelayOpcode.RELAYED_DATA.value -> { + if (data.remaining() < 8) { + Logger.e(TAG, "RELAYED_DATA packet too short") + return + } + val connectionId = data.long + val channel = _channels[connectionId] ?: run { + Logger.e(TAG, "No channel found for connectionId $connectionId") + return + } + val decryptedPayload = channel.decrypt(data) + try { + handleData(decryptedPayload, channel) + } catch (e: Exception) { + Logger.e(TAG, "Exception while handling relayed data", e) + channel.sendError(SyncErrorCode.ConnectionClosed) + channel.close() + _channels.remove(connectionId) + } + } + RelayOpcode.RELAYED_ERROR.value -> { + if (data.remaining() < 8) { + Logger.e(TAG, "RELAYED_ERROR packet too short") + return + } + val connectionId = data.long + val channel = _channels[connectionId] ?: run { + Logger.e(TAG, "No channel found for connectionId $connectionId") + sendRelayError(connectionId, SyncErrorCode.NotFound) + return + } + val decryptedPayload = channel.decrypt(data) + val errorCode = SyncErrorCode.entries.find { it.value == decryptedPayload.int } ?: SyncErrorCode.ConnectionClosed + Logger.e(TAG, "Received relayed error (errorCode = $errorCode) on connectionId $connectionId, closing") + channel.close() + _channels.remove(connectionId) + } + RelayOpcode.RELAY_ERROR.value -> { + if (data.remaining() < 12) { + Logger.e(TAG, "RELAY_ERROR packet too short") + return + } + val connectionId = data.long + val errorCode = SyncErrorCode.entries.find { it.value == data.int } ?: SyncErrorCode.ConnectionClosed + val channel = _channels[connectionId] ?: run { + Logger.e(TAG, "Received error code $errorCode for non-existent channel with connectionId $connectionId") + return + } + Logger.i(TAG, "Received relay error (errorCode = $errorCode) on connectionId $connectionId, closing") + channel.close() + _channels.remove(connectionId) + _pendingChannels.entries.find { it.value.first == channel }?.let { + _pendingChannels.remove(it.key)?.second?.cancel() + } + } + } + } + + private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})") when (opcode) { Opcode.PING.value -> { - send(Opcode.PONG.value) + if (sourceChannel != null) + sourceChannel.send(Opcode.PONG.value) + else + send(Opcode.PONG.value) //Logger.i(TAG, "Received ping, sent pong") return } Opcode.PONG.value -> { - //Logger.i(TAG, "Received pong") + Logger.v(TAG, "Received pong") return } - Opcode.NOTIFY_AUTHORIZED.value, - Opcode.NOTIFY_UNAUTHORIZED.value -> { - _onData.invoke(this, opcode, subOpcode, data) + Opcode.REQUEST.value -> { + handleRequest(subOpcode, data, sourceChannel) return } - } - - if (authorizable?.isAuthorized != true) { - return - } - - when (opcode) { - Opcode.STREAM_START.value -> { - val id = data.int - val expectedSize = data.int - val op = data.get().toUByte() - val subOp = data.get().toUByte() - - val syncStream = SyncStream(expectedSize, op, subOp) - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } - - synchronized(_syncStreams) { - _syncStreams[id] = syncStream - } + Opcode.RESPONSE.value -> { + handleResponse(subOpcode, data, sourceChannel) + return } - Opcode.STREAM_DATA.value -> { - val id = data.int - val expectedOffset = data.int - - val syncStream = synchronized(_syncStreams) { - _syncStreams[id] ?: throw Exception("Received data for sync stream that does not exist") - } - - if (expectedOffset != syncStream.bytesReceived) { - throw Exception("Expected offset does not match the amount of received bytes") - } - - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } + Opcode.NOTIFY.value -> { + handleNotify(subOpcode, data, sourceChannel) + return } - Opcode.STREAM_END.value -> { - val id = data.int - val expectedOffset = data.int - - val syncStream = synchronized(_syncStreams) { - _syncStreams.remove(id) ?: throw Exception("Received data for sync stream that does not exist") - } - - if (expectedOffset != syncStream.bytesReceived) { - throw Exception("Expected offset does not match the amount of received bytes") - } - - if (data.remaining() > 0) { - syncStream.add(data.array(), data.position(), data.remaining()) - } - - if (!syncStream.isComplete) { - throw Exception("After sync stream end, the stream must be complete") - } - - handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }) + Opcode.RELAY.value -> { + handleRelay(subOpcode, data, sourceChannel) + return } - Opcode.DATA.value -> { - _onData.invoke(this, opcode, subOpcode, data) - } - else -> { - Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})") + else -> if (isAuthorized) when (opcode) { + Opcode.STREAM.value -> when (subOpcode) + { + StreamOpcode.START.value -> { + val id = data.int + val expectedSize = data.int + val op = data.get().toUByte() + val subOp = data.get().toUByte() + + val syncStream = SyncStream(expectedSize, op, subOp) + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + + synchronized(_syncStreams) { + _syncStreams[id] = syncStream + } + } + StreamOpcode.DATA.value -> { + val id = data.int + val expectedOffset = data.int + + val syncStream = synchronized(_syncStreams) { + _syncStreams[id] ?: throw Exception("Received data for sync stream that does not exist") + } + + if (expectedOffset != syncStream.bytesReceived) { + throw Exception("Expected offset does not match the amount of received bytes") + } + + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + } + StreamOpcode.END.value -> { + val id = data.int + val expectedOffset = data.int + + val syncStream = synchronized(_syncStreams) { + _syncStreams.remove(id) ?: throw Exception("Received data for sync stream that does not exist") + } + + if (expectedOffset != syncStream.bytesReceived) { + throw Exception("Expected offset does not match the amount of received bytes") + } + + if (data.remaining() > 0) { + syncStream.add(data.array(), data.position(), data.remaining()) + } + + if (!syncStream.isComplete) { + throw Exception("After sync stream end, the stream must be complete") + } + + handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, sourceChannel) + } + } + Opcode.DATA.value -> { + if (sourceChannel != null) + sourceChannel.invokeDataHandler(opcode, subOpcode, data) + else + _onData?.invoke(this, opcode, subOpcode, data) + } + else -> { + Logger.w(TAG, "Unknown opcode received (opcode = ${opcode}, subOpcode = ${subOpcode})") + } } } } + suspend fun requestConnectionInfo(publicKey: String): ConnectionInfo? { + val requestId = generateRequestId() + val deferred = CompletableDeferred() + _pendingConnectionInfoRequests[requestId] = deferred + try { + val publicKeyBytes = Base64.getDecoder().decode(publicKey) + if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") + val packet = ByteBuffer.allocate(4 + 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publicKeyBytes) + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.CONNECTION_INFO.value, packet) + } catch (e: Exception) { + _pendingConnectionInfoRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun requestBulkConnectionInfo(publicKeys: Array): Map { + val requestId = generateRequestId() + val deferred = CompletableDeferred>() + _pendingBulkConnectionInfoRequests[requestId] = deferred + try { + val packet = ByteBuffer.allocate(4 + 1 + publicKeys.size * 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publicKeys.size.toByte()) + for (pk in publicKeys) { + val pkBytes = Base64.getDecoder().decode(pk) + if (pkBytes.size != 32) throw IllegalArgumentException("Invalid public key length for $pk") + packet.put(pkBytes) + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_CONNECTION_INFO.value, packet) + } catch (e: Exception) { + _pendingBulkConnectionInfoRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun startRelayedChannel(publicKey: String, pairingCode: String? = null): ChannelRelayed? { + val requestId = generateRequestId() + val deferred = CompletableDeferred() + val channel = ChannelRelayed(this, _localKeyPair, publicKey, true) + _onNewChannel?.invoke(this, channel) + _pendingChannels[requestId] = channel to deferred + try { + channel.sendRequestTransport(requestId, publicKey, pairingCode) + } catch (e: Exception) { + _pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) } + throw e + } + return deferred.await() + } + + private fun getDeviceName(): String { + val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase( + Locale.getDefault()) else it.toString() } + val model = Build.MODEL + + return if (model.startsWith(manufacturer, ignoreCase = true)) { + model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } else { + "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + } + + private fun getLimitedUtf8Bytes(str: String, maxByteLength: Int): ByteArray { + val bytes = str.toByteArray(Charsets.UTF_8) + if (bytes.size <= maxByteLength) return bytes + + var truncateAt = maxByteLength + while (truncateAt > 0 && (bytes[truncateAt].toInt() and 0xC0) == 0x80) { + truncateAt-- + } + return bytes.copyOf(truncateAt) + } + + fun publishConnectionInformation( + authorizedKeys: Array, + port: Int, + allowLocalDirect: Boolean, + allowRemoteDirect: Boolean, + allowRemoteHolePunched: Boolean, + allowRemoteProxied: Boolean + ) { + if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255") + + val ipv4Addresses = mutableListOf() + val ipv6Addresses = mutableListOf() + for (nic in NetworkInterface.getNetworkInterfaces()) { + if (nic.isUp) { + for (addr in nic.inetAddresses) { + if (!addr.isLoopbackAddress) { + when (addr) { + is Inet4Address -> ipv4Addresses.add(addr.hostAddress) + is Inet6Address -> ipv6Addresses.add(addr.hostAddress) + } + } + } + } + } + + val deviceName = getDeviceName() + val nameBytes = getLimitedUtf8Bytes(deviceName, 255) + + val blobSize = 2 + 1 + nameBytes.size + 1 + ipv4Addresses.size * 4 + 1 + ipv6Addresses.size * 16 + 1 + 1 + 1 + 1 + val data = ByteBuffer.allocate(blobSize).order(ByteOrder.LITTLE_ENDIAN) + data.putShort(port.toShort()) + data.put(nameBytes.size.toByte()) + data.put(nameBytes) + data.put(ipv4Addresses.size.toByte()) + for (addr in ipv4Addresses) { + val addrBytes = InetAddress.getByName(addr).address + data.put(addrBytes) + } + data.put(ipv6Addresses.size.toByte()) + for (addr in ipv6Addresses) { + val addrBytes = InetAddress.getByName(addr).address + data.put(addrBytes) + } + data.put(if (allowLocalDirect) 1 else 0) + data.put(if (allowRemoteDirect) 1 else 0) + data.put(if (allowRemoteHolePunched) 1 else 0) + data.put(if (allowRemoteProxied) 1 else 0) + + val handshakeSize = 48 // Noise handshake size for N pattern + + data.rewind() + val ciphertextSize = data.remaining() + 16 // Encrypted data size + val totalSize = 1 + authorizedKeys.size * (32 + handshakeSize + 4 + ciphertextSize) + val publishBytes = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) + publishBytes.put(authorizedKeys.size.toByte()) + + for (key in authorizedKeys) { + val publicKeyBytes = Base64.getDecoder().decode(key) + if (publicKeyBytes.size != 32) throw IllegalArgumentException("Public key must be 32 bytes") + + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR) + protocol.remotePublicKey.setPublicKey(publicKeyBytes, 0) + protocol.start() + + val handshakeMessage = ByteArray(handshakeSize) + val handshakeBytesWritten = protocol.writeMessage(handshakeMessage, 0, null, 0, 0) + if (handshakeBytesWritten != handshakeSize) throw IllegalStateException("Handshake message size mismatch") + + val transportPair = protocol.split() + + publishBytes.put(publicKeyBytes) + publishBytes.put(handshakeMessage) + + val ciphertext = ByteArray(ciphertextSize) + val ciphertextBytesWritten = transportPair.sender.encryptWithAd(null, data.array(), data.position(), ciphertext, 0, data.remaining()) + if (ciphertextBytesWritten != ciphertextSize) throw IllegalStateException("Ciphertext size mismatch") + + publishBytes.putInt(ciphertextBytesWritten) + publishBytes.put(ciphertext, 0, ciphertextBytesWritten) + } + + publishBytes.rewind() + send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes) + } + + suspend fun publishRecords(consumerPublicKeys: List, key: String, data: ByteArray): Boolean { + val keyBytes = key.toByteArray(Charsets.UTF_8) + if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes") + if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required") + val requestId = generateRequestId() + val deferred = CompletableDeferred() + _pendingPublishRequests[requestId] = deferred + try { + val MAX_PLAINTEXT_SIZE = 65535 + val HANDSHAKE_SIZE = 48 + val LENGTH_SIZE = 4 + val TAG_SIZE = 16 + val chunkCount = (data.size + MAX_PLAINTEXT_SIZE - 1) / MAX_PLAINTEXT_SIZE + + var blobSize = HANDSHAKE_SIZE + var dataOffset = 0 + for (i in 0 until chunkCount) { + val chunkSize = minOf(MAX_PLAINTEXT_SIZE, data.size - dataOffset) + blobSize += LENGTH_SIZE + (chunkSize + TAG_SIZE) + dataOffset += chunkSize + } + + val totalPacketSize = 4 + 1 + keyBytes.size + 1 + consumerPublicKeys.size * (32 + 4 + blobSize) + val packet = ByteBuffer.allocate(totalPacketSize).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + packet.put(consumerPublicKeys.size.toByte()) + + for (consumer in consumerPublicKeys) { + val consumerBytes = Base64.getDecoder().decode(consumer) + if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") + packet.put(consumerBytes) + val protocol = HandshakeState(SyncSocketSession.nProtocolName, HandshakeState.INITIATOR).apply { + remotePublicKey.setPublicKey(consumerBytes, 0) + start() + } + val handshakeMessage = ByteArray(HANDSHAKE_SIZE) + protocol.writeMessage(handshakeMessage, 0, null, 0, 0) + val transportPair = protocol.split() + packet.putInt(blobSize) + packet.put(handshakeMessage) + + dataOffset = 0 + for (i in 0 until chunkCount) { + val chunkSize = minOf(MAX_PLAINTEXT_SIZE, data.size - dataOffset) + val plaintext = data.copyOfRange(dataOffset, dataOffset + chunkSize) + val ciphertext = ByteArray(chunkSize + TAG_SIZE) + val written = transportPair.sender.encryptWithAd(null, plaintext, 0, ciphertext, 0, plaintext.size) + packet.putInt(written) + packet.put(ciphertext, 0, written) + dataOffset += chunkSize + } + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet) + } catch (e: Exception) { + _pendingPublishRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun getRecord(publisherPublicKey: String, key: String): Pair? { + if (key.isEmpty() || key.length > 32) throw IllegalArgumentException("Key must be 1-32 bytes") + val requestId = generateRequestId() + val deferred = CompletableDeferred?>() + _pendingGetRecordRequests[requestId] = deferred + try { + val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + val keyBytes = key.toByteArray(Charsets.UTF_8) + val packet = ByteBuffer.allocate(4 + 32 + 1 + keyBytes.size).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publisherBytes) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.GET_RECORD.value, packet) + } catch (e: Exception) { + _pendingGetRecordRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun getRecords(publisherPublicKeys: List, key: String): Map> { + if (key.isEmpty() || key.length > 32) throw IllegalArgumentException("Key must be 1-32 bytes") + if (publisherPublicKeys.isEmpty()) return emptyMap() + val requestId = generateRequestId() + val deferred = CompletableDeferred>>() + _pendingBulkGetRecordRequests[requestId] = deferred + try { + val keyBytes = key.toByteArray(Charsets.UTF_8) + val packet = ByteBuffer.allocate(4 + 1 + keyBytes.size + 1 + publisherPublicKeys.size * 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + packet.put(publisherPublicKeys.size.toByte()) + for (publisher in publisherPublicKeys) { + val bytes = Base64.getDecoder().decode(publisher) + if (bytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + packet.put(bytes) + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_GET_RECORD.value, packet) + } catch (e: Exception) { + _pendingBulkGetRecordRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun deleteRecords(publisherPublicKey: String, consumerPublicKey: String, keys: List): Boolean { + if (keys.any { it.toByteArray(Charsets.UTF_8).size > 32 }) throw IllegalArgumentException("Keys must be at most 32 bytes") + val requestId = generateRequestId() + val deferred = CompletableDeferred() + _pendingDeleteRequests[requestId] = deferred + try { + val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + val consumerBytes = Base64.getDecoder().decode(consumerPublicKey) + if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") + val packetSize = 4 + 32 + 32 + 1 + keys.sumOf { 1 + it.toByteArray(Charsets.UTF_8).size } + val packet = ByteBuffer.allocate(packetSize).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publisherBytes) + packet.put(consumerBytes) + packet.put(keys.size.toByte()) + for (key in keys) { + val keyBytes = key.toByteArray(Charsets.UTF_8) + packet.put(keyBytes.size.toByte()) + packet.put(keyBytes) + } + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.BULK_DELETE_RECORD.value, packet) + } catch (e: Exception) { + _pendingDeleteRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + + suspend fun listRecordKeys(publisherPublicKey: String, consumerPublicKey: String): List> { + val requestId = generateRequestId() + val deferred = CompletableDeferred>>() + _pendingListKeysRequests[requestId] = deferred + try { + val publisherBytes = Base64.getDecoder().decode(publisherPublicKey) + if (publisherBytes.size != 32) throw IllegalArgumentException("Publisher public key must be 32 bytes") + val consumerBytes = Base64.getDecoder().decode(consumerPublicKey) + if (consumerBytes.size != 32) throw IllegalArgumentException("Consumer public key must be 32 bytes") + val packet = ByteBuffer.allocate(4 + 32 + 32).order(ByteOrder.LITTLE_ENDIAN) + packet.putInt(requestId) + packet.put(publisherBytes) + packet.put(consumerBytes) + packet.rewind() + send(Opcode.REQUEST.value, RequestOpcode.LIST_RECORD_KEYS.value, packet) + } catch (e: Exception) { + _pendingListKeysRequests.remove(requestId)?.completeExceptionally(e) + throw e + } + return deferred.await() + } + companion object { + val dh = "25519" + val pattern = "N" + val cipher = "ChaChaPoly" + val hash = "BLAKE2b" + var nProtocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" + private const val TAG = "SyncSocketSession" const val MAXIMUM_PACKET_SIZE = 65535 - 16 const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16 From 38b9fe301721250c86448504a9b5eb8a9e9dc40d Mon Sep 17 00:00:00 2001 From: buzzcola3 Date: Sun, 13 Apr 2025 20:36:15 +0200 Subject: [PATCH 051/128] Fix prevQueueItem always returning the item from _queue, even when _queueShuffled is active --- app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index b8368ea5..15c91025 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -598,7 +598,7 @@ class StatePlayer { } if(_queuePosition < _queue.size) { - return _queue[_queuePosition]; + return getCurrentQueueItem(); } } return null; From 4e195dfbc3b7e7af48dd3aedbff089406e092a01 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 14 Apr 2025 10:38:17 +0200 Subject: [PATCH 052/128] Rename to direct and relayed. --- .../platformplayer/sync/internal/LinkType.java | 4 ++-- .../platformplayer/sync/internal/SyncSession.kt | 14 +++++++------- .../platformplayer/views/sync/SyncDeviceView.kt | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java b/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java index a3fe431c..256ed422 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/LinkType.java @@ -2,6 +2,6 @@ package com.futo.platformplayer.sync.internal; public enum LinkType { None, - Local, - Proxied + Direct, + Relayed } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 76af1edb..ae6e1340 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -35,25 +35,25 @@ class SyncSession : IAuthorizable { val linkType: LinkType get() { - var hasProxied = false + var hasRelayed = false var hasDirect = false synchronized(_channels) { for (channel in _channels) { if (channel is ChannelRelayed) - hasProxied = true + hasRelayed = true if (channel is ChannelSocket) hasDirect = true - if (hasProxied && hasDirect) - return LinkType.Local + if (hasRelayed && hasDirect) + return LinkType.Direct } } - if (hasProxied) - return LinkType.Proxied + if (hasRelayed) + return LinkType.Relayed if (hasDirect) - return LinkType.Local + return LinkType.Direct return LinkType.None } diff --git a/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt b/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt index 6bdcd3ed..c091d3cd 100644 --- a/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/sync/SyncDeviceView.kt @@ -43,13 +43,13 @@ class SyncDeviceView : ConstraintLayout { _layoutLinkType.visibility = View.VISIBLE _imageLinkType.setImageResource(when (linkType) { - LinkType.Proxied -> R.drawable.ic_internet - LinkType.Local -> R.drawable.ic_lan + LinkType.Relayed -> R.drawable.ic_internet + LinkType.Direct -> R.drawable.ic_lan else -> 0 }) _textLinkType.text = when(linkType) { - LinkType.Proxied -> "Proxied" - LinkType.Local -> "Local" + LinkType.Relayed -> "Relayed" + LinkType.Direct -> "Direct" else -> null } From b460f9915d4306eecb4ff08c9fe99588dedbe855 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 14 Apr 2025 14:41:47 +0200 Subject: [PATCH 053/128] Added settings for enabling/disabling remote sync features. Fixed device pairing success showing too early. --- .../java/com/futo/platformplayer/Settings.kt | 9 + .../futo/platformplayer/states/StateSync.kt | 310 +++++++++--------- .../sync/internal/SyncSocketSession.kt | 18 +- app/src/main/res/values/strings.xml | 6 + 4 files changed, 188 insertions(+), 155 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 2bd95905..b9b81ec6 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -936,6 +936,15 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3) var connectLast: Boolean = true; + + @FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3) + var discoverThroughRelay: Boolean = true; + + @FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3) + var pairThroughRelay: Boolean = true; + + @FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3) + var connectThroughRelay: Boolean = true; } @FormField(R.string.info, FieldForm.GROUP, -1, 21) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 57bcde5f..471f9de6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -32,11 +32,11 @@ import com.futo.platformplayer.sync.internal.ChannelSocket import com.futo.platformplayer.sync.internal.GJSyncOpcodes import com.futo.platformplayer.sync.internal.IAuthorizable import com.futo.platformplayer.sync.internal.IChannel +import com.futo.platformplayer.sync.internal.LinkType import com.futo.platformplayer.sync.internal.Opcode import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.SyncKeyPair import com.futo.platformplayer.sync.internal.SyncSession -import com.futo.platformplayer.sync.internal.SyncSession.Companion import com.futo.platformplayer.sync.internal.SyncSocketSession import com.futo.platformplayer.sync.models.SendToDevicePackage import com.futo.platformplayer.sync.models.SyncPlaylistsPackage @@ -52,13 +52,11 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream -import java.lang.Thread.sleep import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.nio.ByteBuffer -import java.nio.channels.Channel import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset @@ -88,6 +86,7 @@ class StateSync { val pairingCode: String? get() = _pairingCode private var _relaySession: SyncSocketSession? = null private var _threadRelay: Thread? = null + private val _remotePendingStatusUpdate = mutableMapOf Unit>() var keyPair: DHState? = null var publicKey: String? = null @@ -157,10 +156,7 @@ class StateSync { while (_started) { val socket = serverSocket.accept() - val session = createSocketSession(socket, true) { session -> - - } - + val session = createSocketSession(socket, true) session.startAsResponder() } } catch (e: Throwable) { @@ -219,137 +215,124 @@ class StateSync { }.apply { start() } } - _threadRelay = Thread { - while (_started) { - try { - Log.i(TAG, "Starting relay session...") + if (Settings.instance.synchronization.discoverThroughRelay) { + _threadRelay = Thread { + while (_started) { + try { + Log.i(TAG, "Starting relay session...") - var socketClosed = false; - val socket = Socket(RELAY_SERVER, 9000) - _relaySession = SyncSocketSession( - (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, - keyPair!!, - LittleEndianDataInputStream(socket.getInputStream()), - LittleEndianDataOutputStream(socket.getOutputStream()), - isHandshakeAllowed = { _, pk, pairingCode -> - Log.v(TAG, "Check if handshake allowed from '$pk'.") - if (pk == RELAY_PUBLIC_KEY) - return@SyncSocketSession true - - synchronized(_authorizedDevices) { - if (_authorizedDevices.values.contains(pk)) - return@SyncSocketSession true - } - - Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode'.") - if (_pairingCode == null || pairingCode.isNullOrEmpty()) - return@SyncSocketSession false - - _pairingCode == pairingCode - }, - onNewChannel = { _, c -> - val remotePublicKey = c.remotePublicKey - if (remotePublicKey == null) { - Log.e(TAG, "Remote public key should never be null in onNewChannel.") - return@SyncSocketSession - } - - Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") - - var session: SyncSession? - synchronized(_sessions) { - session = _sessions[remotePublicKey] - if (session == null) { - val remoteDeviceName = synchronized(_nameStorage) { - _nameStorage.get(remotePublicKey) - } - session = createNewSyncSession(remotePublicKey, remoteDeviceName) { } - _sessions[remotePublicKey] = session!! + var socketClosed = false; + val socket = Socket(RELAY_SERVER, 9000) + _relaySession = SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + LittleEndianDataInputStream(socket.getInputStream()), + LittleEndianDataOutputStream(socket.getOutputStream()), + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode) }, + onNewChannel = { _, c -> + val remotePublicKey = c.remotePublicKey + if (remotePublicKey == null) { + Log.e(TAG, "Remote public key should never be null in onNewChannel.") + return@SyncSocketSession } - session!!.addChannel(c) - } - c.setDataHandler { _, channel, opcode, subOpcode, data -> - session?.handlePacket(opcode, subOpcode, data) - } - c.setCloseHandler { channel -> - session?.removeChannel(channel) - } - }, - onChannelEstablished = { _, channel, isResponder -> - handleAuthorization(channel, isResponder) - }, - onClose = { socketClosed = true }, - onHandshakeComplete = { relaySession -> - Thread { - try { - while (_started && !socketClosed) { - val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) { - _authorizedDevices.values.filter { !isConnected(it) }.toTypedArray() + Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") + + var session: SyncSession? + synchronized(_sessions) { + session = _sessions[remotePublicKey] + if (session == null) { + val remoteDeviceName = synchronized(_nameStorage) { + _nameStorage.get(remotePublicKey) } + session = createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(c) + } - relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, true, false, false, true) + c.setDataHandler { _, channel, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + c.setCloseHandler { channel -> + session?.removeChannel(channel) + } + }, + onChannelEstablished = { _, channel, isResponder -> + handleAuthorization(channel, isResponder) + }, + onClose = { socketClosed = true }, + onHandshakeComplete = { relaySession -> + Thread { + try { + while (_started && !socketClosed) { + val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) { + _authorizedDevices.values.filter { !isConnected(it) }.toTypedArray() + } - val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } + relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, Settings.instance.synchronization.discoverThroughRelay, false, false, Settings.instance.synchronization.discoverThroughRelay && Settings.instance.synchronization.connectThroughRelay) - for ((targetKey, connectionInfo) in connectionInfos) { - val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses) - .filter { it != connectionInfo.remoteIp } - if (connectionInfo.allowLocalDirect) { - Thread { + val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } + + for ((targetKey, connectionInfo) in connectionInfos) { + val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses) + .filter { it != connectionInfo.remoteIp } + if (connectionInfo.allowLocalDirect) { + Thread { + try { + Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") + connect(potentialLocalAddresses.map { it }.toTypedArray(), PORT, targetKey, null) + } catch (e: Throwable) { + Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) + } + }.start() + } + + if (connectionInfo.allowRemoteDirect) { + // TODO: Implement direct remote connection if needed + } + + if (connectionInfo.allowRemoteHolePunched) { + // TODO: Implement hole punching if needed + } + + if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { try { - Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") - connect(potentialLocalAddresses.map { it }.toTypedArray(), PORT, targetKey, null) + Log.v(TAG, "Attempting relayed connection with '$targetKey'.") + runBlocking { relaySession.startRelayedChannel(targetKey, null) } } catch (e: Throwable) { - Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) + Log.e(TAG, "Failed to start relayed channel with $targetKey.", e) } - }.start() - } - - if (connectionInfo.allowRemoteDirect) { - // TODO: Implement direct remote connection if needed - } - - if (connectionInfo.allowRemoteHolePunched) { - // TODO: Implement hole punching if needed - } - - if (connectionInfo.allowRemoteProxied) { - try { - Log.v(TAG, "Attempting relayed connection with '$targetKey'.") - runBlocking { relaySession.startRelayedChannel(targetKey, null) } - } catch (e: Throwable) { - Log.e(TAG, "Failed to start relayed channel with $targetKey.", e) } } + + Thread.sleep(15000) } - - Thread.sleep(15000) + } catch (e: Throwable) { + Log.e(TAG, "Unhandled exception in relay session.", e) + relaySession.stop() } - } catch (e: Throwable) { - Log.e(TAG, "Unhandled exception in relay session.", e) - relaySession.stop() - } - }.start() + }.start() + } + ) + + _relaySession!!.authorizable = object : IAuthorizable { + override val isAuthorized: Boolean get() = true } - ) - _relaySession!!.authorizable = object : IAuthorizable { - override val isAuthorized: Boolean get() = true + _relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, null) + + Log.i(TAG, "Started relay session.") + } catch (e: Throwable) { + Log.e(TAG, "Relay session failed.", e) + Thread.sleep(5000) + } finally { + _relaySession?.stop() + _relaySession = null } - - _relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, null) - - Log.i(TAG, "Started relay session.") - } catch (e: Throwable) { - Log.e(TAG, "Relay session failed.", e) - Thread.sleep(5000) - } finally { - _relaySession?.stop() - _relaySession = null } - } - }.apply { start() } + }.apply { start() } + } } private fun getDeviceName(): String { @@ -680,7 +663,19 @@ class StateSync { } } - private fun createNewSyncSession(remotePublicKey: String, remoteDeviceName: String?, onAuthorized: ((SyncSession) -> Unit)?): SyncSession { + private fun onAuthorized(remotePublicKey: String) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") + } + } + + private fun onUnuthorized(remotePublicKey: String) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") + } + } + + private fun createNewSyncSession(remotePublicKey: String, remoteDeviceName: String?): SyncSession { return SyncSession( remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> @@ -694,8 +689,8 @@ class StateSync { } } - Logger.i(TAG, "${remotePublicKey} authorized (name: ${it.displayName})") - onAuthorized?.invoke(it) + Logger.i(TAG, "$remotePublicKey authorized (name: ${it.displayName})") + onAuthorized(remotePublicKey) _authorizedDevices.addDistinct(remotePublicKey) _authorizedDevices.save() deviceUpdatedOrAdded.emit(it.remotePublicKey, it) @@ -705,13 +700,16 @@ class StateSync { onUnauthorized = { unauthorize(remotePublicKey) + Logger.i(TAG, "$remotePublicKey unauthorized (name: ${it.displayName})") + onUnuthorized(remotePublicKey) + synchronized(_sessions) { it.close() _sessions.remove(remotePublicKey) } }, onConnectedChanged = { it, connected -> - Logger.i(TAG, "$remotePublicKey connected: " + connected) + Logger.i(TAG, "$remotePublicKey connected: $connected") deviceUpdatedOrAdded.emit(it.remotePublicKey, it) }, onClose = { @@ -723,6 +721,10 @@ class StateSync { } deviceRemoved.emit(it.remotePublicKey) + + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed") + } }, dataHandler = { it, opcode, subOpcode, data -> handleData(it, opcode, subOpcode, data) @@ -731,7 +733,30 @@ class StateSync { ) } - private fun createSocketSession(socket: Socket, isResponder: Boolean, onAuthorized: (session: SyncSession) -> Unit): SyncSocketSession { + private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?): Boolean { + Log.v(TAG, "Check if handshake allowed from '$publicKey'.") + if (publicKey == RELAY_PUBLIC_KEY) + return true + + synchronized(_authorizedDevices) { + if (_authorizedDevices.values.contains(publicKey)) { + if (linkType == LinkType.Relayed && !Settings.instance.synchronization.connectThroughRelay) + return false + return true + } + } + + Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode'.") + if (_pairingCode == null || pairingCode.isNullOrEmpty()) + return false + + if (linkType == LinkType.Relayed && !Settings.instance.synchronization.pairThroughRelay) + return false + + return _pairingCode == pairingCode + } + + private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession { var session: SyncSession? = null var channelSocket: ChannelSocket? = null return SyncSocketSession( @@ -743,21 +768,7 @@ class StateSync { if (channelSocket != null) session?.removeChannel(channelSocket!!) }, - isHandshakeAllowed = { _, pk, pairingCode -> - Logger.v(TAG, "Check if handshake allowed from '${pk}'.") - - synchronized (_authorizedDevices) - { - if (_authorizedDevices.values.contains(pk)) - return@SyncSocketSession true - } - - Logger.v(TAG, "Check if handshake allowed with pairing code '${pairingCode}' with active pairing code '${_pairingCode}'."); - if (_pairingCode == null || pairingCode.isNullOrEmpty()) - return@SyncSocketSession false - - return@SyncSocketSession _pairingCode == pairingCode - }, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode) }, onHandshakeComplete = { s -> val remotePublicKey = s.remotePublicKey if (remotePublicKey == null) { @@ -780,7 +791,7 @@ class StateSync { _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) } - session = createNewSyncSession(remotePublicKey, remoteDeviceName, onAuthorized) + session = createNewSyncSession(remotePublicKey, remoteDeviceName) _sessions[remotePublicKey] = session!! } session!!.addChannel(channelSocket!!) @@ -912,15 +923,19 @@ class StateSync { } catch (e: Throwable) { Logger.e(TAG, "Failed to connect directly", e) val relaySession = _relaySession - if (relaySession != null) { + if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) { onStatusUpdate?.invoke(null, "Connecting via relay...") runBlocking { + if (onStatusUpdate != null) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate[deviceInfo.publicKey] = onStatusUpdate + } + } relaySession.startRelayedChannel(deviceInfo.publicKey, deviceInfo.pairingCode) - onStatusUpdate?.invoke(true, "Connected") } } else { - throw Exception("Failed to connect.") + throw e } } } @@ -930,8 +945,11 @@ class StateSync { val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") onStatusUpdate?.invoke(null, "Handshaking...") - val session = createSocketSession(socket, false) { s -> - onStatusUpdate?.invoke(true, "Authorized") + val session = createSocketSession(socket, false) + if (onStatusUpdate != null) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate[publicKey] = onStatusUpdate + } } session.startAsInitiator(publicKey, pairingCode) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index c8f4f683..2b3a7e10 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -38,7 +38,7 @@ class SyncSocketSession { private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? - private val _isHandshakeAllowed: ((session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? + private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? private var _cipherStatePair: CipherStatePair? = null private var _remotePublicKey: String? = null val remotePublicKey: String? get() = _remotePublicKey @@ -74,7 +74,7 @@ class SyncSocketSession { val allowLocalDirect: Boolean, val allowRemoteDirect: Boolean, val allowRemoteHolePunched: Boolean, - val allowRemoteProxied: Boolean + val allowRemoteRelayed: Boolean ) constructor( @@ -87,7 +87,7 @@ class SyncSocketSession { onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, - isHandshakeAllowed: ((session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? = null + isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? = null ) { _inputStream = inputStream _outputStream = outputStream @@ -278,7 +278,7 @@ class SyncSocketSession { responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) - return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(this, _remotePublicKey!!, pairingCode) ?: true)).also { + return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, _remotePublicKey!!, pairingCode) ?: true)).also { if (!it) stop() } } @@ -420,7 +420,7 @@ class SyncSocketSession { val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) String(plaintext, 0, length, Charsets.UTF_8) } else null - val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(this, publicKey, pairingCode) ?: true) + val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode) ?: true) if (!isAllowed) { val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) rp.putInt(2) // Status code for not allowed @@ -649,8 +649,8 @@ class SyncSocketSession { val allowLocalDirect = info.get() != 0.toByte() val allowRemoteDirect = info.get() != 0.toByte() val allowRemoteHolePunched = info.get() != 0.toByte() - val allowRemoteProxied = info.get() != 0.toByte() - return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteProxied) + val allowRemoteRelayed = info.get() != 0.toByte() + return ConnectionInfo(port, name, remoteIp, ipv4Addresses, ipv6Addresses, allowLocalDirect, allowRemoteDirect, allowRemoteHolePunched, allowRemoteRelayed) } private fun handleNotify(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { @@ -920,7 +920,7 @@ class SyncSocketSession { allowLocalDirect: Boolean, allowRemoteDirect: Boolean, allowRemoteHolePunched: Boolean, - allowRemoteProxied: Boolean + allowRemoteRelayed: Boolean ) { if (authorizedKeys.size > 255) throw IllegalArgumentException("Number of authorized keys exceeds 255") @@ -960,7 +960,7 @@ class SyncSocketSession { data.put(if (allowLocalDirect) 1 else 0) data.put(if (allowRemoteDirect) 1 else 0) data.put(if (allowRemoteHolePunched) 1 else 0) - data.put(if (allowRemoteProxied) 1 else 0) + data.put(if (allowRemoteRelayed) 1 else 0) val handshakeSize = 48 // Noise handshake size for N pattern diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7c101c9..c6546d70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,12 @@ Allow device to search for and initiate connection with known paired devices Try connect last Allow device to automatically connect to last known + Discover through relay + Allow paired devices to be discovered and connected to through the relay + Pair through relay + Allow devices to be paired through the relay + Connection through relay + Allow devices to be connected to through the relay Gesture controls Volume slider Enable slide gesture to change volume From 0ef1f2d40f377ea40ea9120c2479c5b174142b52 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 14 Apr 2025 15:19:16 +0200 Subject: [PATCH 054/128] Added LinkType to Channel. --- .../platformplayer/sync/internal/Channel.kt | 3 +++ .../sync/internal/SyncSession.kt | 21 ++++++------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 2d3f1580..bfcee6fd 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -17,6 +17,7 @@ interface IChannel : AutoCloseable { fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null) fun setCloseHandler(onClose: ((IChannel) -> Unit)?) + val linkType: LinkType } class ChannelSocket(private val session: SyncSocketSession) : IChannel { @@ -24,6 +25,7 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel { override val remoteVersion: Int? get() = session.remoteVersion private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null private var onClose: ((IChannel) -> Unit)? = null + override val linkType: LinkType get() = LinkType.Direct override var authorizable: IAuthorizable? get() = session.authorizable @@ -83,6 +85,7 @@ class ChannelRelayed( override var remoteVersion: Int? = null private set override var syncSession: SyncSession? = null + override val linkType: LinkType get() = LinkType.Relayed private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null private var onClose: ((IChannel) -> Unit)? = null diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index ae6e1340..51a40902 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -35,26 +35,18 @@ class SyncSession : IAuthorizable { val linkType: LinkType get() { - var hasRelayed = false - var hasDirect = false + var linkType = LinkType.None synchronized(_channels) { for (channel in _channels) { - if (channel is ChannelRelayed) - hasRelayed = true - if (channel is ChannelSocket) - hasDirect = true - if (hasRelayed && hasDirect) + if (channel.linkType == LinkType.Direct) return LinkType.Direct + if (channel.linkType == LinkType.Relayed) + linkType = LinkType.Relayed } } - - if (hasRelayed) - return LinkType.Relayed - if (hasDirect) - return LinkType.Direct - return LinkType.None + return linkType } var connected: Boolean = false @@ -212,8 +204,7 @@ class SyncSession : IAuthorizable { } fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) { - //TODO: Prioritize local connections - val channels = synchronized(_channels) { _channels.toList() } + val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() } if (channels.isEmpty()) { //TODO: Should this throw? Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets") From 9347351c37607fe6b571c01a0a4d054fe51e73ae Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 15 Apr 2025 09:39:35 +0200 Subject: [PATCH 055/128] Fixed issue where it would continuously try to connect over relay. --- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 471f9de6..5f580d55 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -190,8 +190,6 @@ class StateSync { for (connectPair in addressesToConnect) { try { - val syncDeviceInfo = SyncDeviceInfo(connectPair.first, arrayOf(connectPair.second), PORT, null) - val now = System.currentTimeMillis() val lastConnectTime = synchronized(_lastConnectTimesIp) { _lastConnectTimesIp[connectPair.first] ?: 0 @@ -204,7 +202,7 @@ class StateSync { } Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") - connect(syncDeviceInfo) + connect(arrayOf(connectPair.second), PORT, connectPair.first, null) } } catch (e: Throwable) { Logger.i(TAG, "Failed to connect to " + connectPair.first, e) From ad468413971cc747449bd6e0f1bc98d22d27a3f3 Mon Sep 17 00:00:00 2001 From: Chris Olin Date: Wed, 16 Apr 2025 11:17:39 -0400 Subject: [PATCH 056/128] Update AndroidManifest.xml resolves https://github.com/futo-org/grayjay-android/issues/47 --- app/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c9917a2d..7bc88932 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,7 +55,7 @@ - \ No newline at end of file + From f8f1cababe68f47ef0dd558fb08b0f0a5f7cbdad Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 17 Apr 2025 15:06:30 -0500 Subject: [PATCH 057/128] increase analyze duration Changelog: changed --- .../java/com/futo/platformplayer/downloads/VideoDownload.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index ede24707..1937e939 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Contextual +import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.Transient import java.io.File import java.io.FileOutputStream @@ -72,6 +73,7 @@ import java.util.concurrent.ThreadLocalRandom import kotlin.coroutines.resumeWithException import kotlin.time.times +@InternalSerializationApi @kotlinx.serialization.Serializable class VideoDownload { var state: State = State.QUEUED; @@ -633,7 +635,9 @@ class VideoDownload { val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) - val cmd = "-f concat -safe 0 -i \"${fileList.absolutePath}\" -c copy \"${targetFile.absolutePath}\"" + // 8 second analyze duration is needed for some Rumble HLS downloads + val cmd = "-analyzeduration 8M -f concat -safe 0 -i \"${fileList.absolutePath}\"" + + " -c copy \"${targetFile.absolutePath}\"" val statisticsCallback = StatisticsCallback { _ -> //TODO: Show progress? From d10026acd119de2a056a949066bb6f78683e74c7 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 21 Apr 2025 13:32:58 +0200 Subject: [PATCH 058/128] Added ping loop. --- .../casting/FCastCastingDevice.kt | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 85b928c2..74440194 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -92,7 +92,7 @@ class FCastCastingDevice : CastingDevice { private var _version: Long = 1; private var _thread: Thread? = null private var _pingThread: Thread? = null - private var _lastPongTime = -1L + @Volatile private var _lastPongTime = System.currentTimeMillis() private var _outputStreamLock = Object() constructor(name: String, addresses: Array, port: Int) : super() { @@ -326,9 +326,9 @@ class FCastCastingDevice : CastingDevice { continue; } - localAddress = _socket?.localAddress; - connectionState = CastConnectionState.CONNECTED; - _lastPongTime = -1L + localAddress = _socket?.localAddress + _lastPongTime = System.currentTimeMillis() + connectionState = CastConnectionState.CONNECTED val buffer = ByteArray(4096); @@ -404,36 +404,32 @@ class FCastCastingDevice : CastingDevice { _pingThread = Thread { Logger.i(TAG, "Started ping loop.") - while (_scopeIO?.isActive == true) { - try { - send(Opcode.Ping) - } catch (e: Throwable) { - Log.w(TAG, "Failed to send ping.") - + if (connectionState == CastConnectionState.CONNECTED) { try { - _socket?.close() - _inputStream?.close() - _outputStream?.close() + send(Opcode.Ping) + if (System.currentTimeMillis() - _lastPongTime > 15000) { + Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.") + try { + _socket?.close() + } catch (e: Throwable) { + Log.w(TAG, "Failed to close socket.", e) + } + } } catch (e: Throwable) { - Log.w(TAG, "Failed to close socket.", e) + Log.w(TAG, "Failed to send ping.") + try { + _socket?.close() + _inputStream?.close() + _outputStream?.close() + } catch (e: Throwable) { + Log.w(TAG, "Failed to close socket.", e) + } } } - - /*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) { - Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.") - - try { - _socket?.close() - } catch (e: Throwable) { - Log.w(TAG, "Failed to close socket.", e) - } - }*/ - - Thread.sleep(2000) + Thread.sleep(5000) } - - Logger.i(TAG, "Stopped ping loop."); + Logger.i(TAG, "Stopped ping loop.") }.apply { start() } } else { Log.i(TAG, "Thread was still alive, not restarted") From 5091a5485a9b21f0776df9fa8e24a7ca004bd970 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 21 Apr 2025 12:37:12 -0500 Subject: [PATCH 059/128] grayjay only supports dark theme. set that on launch Changelog: changed --- .../java/com/futo/platformplayer/activities/MainActivity.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 25febdb1..3a86a306 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint +import android.app.UiModeManager import android.content.ComponentName import android.content.Context import android.content.Intent @@ -9,6 +10,7 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.media.AudioManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.StrictMode import android.os.StrictMode.VmPolicy @@ -262,6 +264,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { StateApp.instance.mainAppStarting(this); super.onCreate(savedInstanceState); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiMode = getSystemService(UiModeManager::class.java) + uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES) + } setContentView(R.layout.activity_main); setNavigationBarColorAndIcons(); if (Settings.instance.playback.allowVideoToGoUnderCutout) From 63761cfc9aad7a34fe8ce151cc753644d37895ce Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 22 Apr 2025 13:08:23 +0200 Subject: [PATCH 060/128] Simplified all searches to use ContentSearchResultsFragment. --- .../api/media/IPlatformClient.kt | 6 ++ .../api/media/models/PlatformAuthorLink.kt | 20 +++++ .../api/media/models/contents/ContentType.kt | 1 + .../api/media/platforms/js/JSClient.kt | 6 ++ .../media/platforms/js/models/IJSContent.kt | 2 + .../platforms/js/models/JSChannelPager.kt | 1 - .../media/platforms/js/models/JSContent.kt | 4 +- .../platforms/js/models/JSContentPager.kt | 11 +++ .../mainactivity/main/ContentFeedView.kt | 11 +-- .../main/ContentSearchResultsFragment.kt | 21 ++++- .../fragment/mainactivity/main/FeedView.kt | 1 - .../mainactivity/main/SuggestionsFragment.kt | 9 +- .../platformplayer/states/StatePlatform.kt | 21 +++++ .../views/adapters/ChannelView.kt | 88 +++++++++++++++++++ .../feedtypes/PreviewChannelViewHolder.kt | 40 +++++++++ .../feedtypes/PreviewContentListAdapter.kt | 6 ++ 16 files changed, 230 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt create mode 100644 app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index 590ecc32..010fd3c1 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.api.media +import com.futo.platformplayer.api.media.models.IPlatformChannelContent import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -66,6 +67,11 @@ interface IPlatformClient { */ fun searchChannels(query: String): IPager; + /** + * Searches for channels and returns a content pager + */ + fun searchChannelsAsContent(query: String): IPager; + //Video Pages /** diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt index e0acb91e..330597a4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt @@ -2,7 +2,10 @@ package com.futo.platformplayer.api.media.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.PlatformID +import com.futo.platformplayer.api.media.models.contents.ContentType +import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig +import com.futo.platformplayer.api.media.platforms.js.models.JSContent import com.futo.platformplayer.getOrDefault import com.futo.platformplayer.getOrThrow @@ -42,4 +45,21 @@ open class PlatformAuthorLink { ); } } +} + +interface IPlatformChannelContent : IPlatformContent { + val thumbnail: String? + val subscribers: Long? +} + +open class JSChannelContent : JSContent, IPlatformChannelContent { + override val contentType: ContentType get() = ContentType.CHANNEL + override val thumbnail: String? + override val subscribers: Long? + + constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) { + val contextName = "Channel"; + thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null) + subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt index a310e089..d181c6da 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt @@ -12,6 +12,7 @@ enum class ContentType(val value: Int) { URL(9), NESTED_VIDEO(11), + CHANNEL(60), LOCKED(70), diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 2b6deaf8..371d846b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.PlatformClientCapabilities +import com.futo.platformplayer.api.media.models.IPlatformChannelContent import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.channels.IPlatformChannel @@ -31,6 +32,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs import com.futo.platformplayer.api.media.platforms.js.models.IJSContent import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.JSChannel +import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager import com.futo.platformplayer.api.media.platforms.js.models.JSChapter import com.futo.platformplayer.api.media.platforms.js.models.JSComment @@ -361,6 +363,10 @@ open class JSClient : IPlatformClient { return@isBusyWith JSChannelPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); } + override fun searchChannelsAsContent(query: String): IPager = isBusyWith("searchChannels") { + ensureEnabled(); + return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), ); + } @JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform") @JSDocsParameter("url", "A channel url (May not be your platform)") diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt index a6a15fb6..fd1f0894 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/IJSContent.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject +import com.futo.platformplayer.api.media.models.JSChannelContent import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient @@ -26,6 +27,7 @@ interface IJSContent: IPlatformContent { ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.LOCKED -> JSLockedContent(config, obj); + ContentType.CHANNEL -> JSChannelContent(config, obj) else -> throw NotImplementedError("Unknown content type ${type}"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt index 683c64af..3a0331d4 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSChannelPager.kt @@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink 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.IPager -import com.futo.platformplayer.engine.V8Plugin class JSChannelPager : JSPager, IPager { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt index ab3b6f10..1e74bd0d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt @@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced { else author = PlatformAuthorLink.UNKNOWN; - val datetimeInt = _content.getOrThrow(config, "datetime", contextName).toLong(); - if(datetimeInt == 0.toLong()) + val datetimeInt = _content.getOrDefault(config, "datetime", contextName, null)?.toLong(); + if(datetimeInt == null || datetimeInt == 0.toLong()) datetime = null; else datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt index 490fa7c4..256e8a5a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContentPager.kt @@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.IPluginSourced +import com.futo.platformplayer.api.media.models.JSChannelContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig @@ -15,4 +16,14 @@ class JSContentPager : JSPager, IPluginSourced { override fun convertResult(obj: V8ValueObject): IPlatformContent { return IJSContent.fromV8(plugin, obj); } +} + +class JSChannelContentPager : JSPager, IPluginSourced { + override val sourceConfig: SourcePluginConfig get() = config; + + constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {} + + override fun convertResult(obj: V8ValueObject): IPlatformContent { + return JSChannelContent(config, obj); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt index 4390a80c..76103d4a 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt @@ -201,11 +201,12 @@ abstract class ContentFeedView : FeedView { - StatePlayer.instance.clearQueue(); - fragment.navigate(url).maximizeVideoDetail(); - }; - ContentType.PLAYLIST -> fragment.navigate(url); - ContentType.URL -> fragment.navigate(url); + StatePlayer.instance.clearQueue() + fragment.navigate(url).maximizeVideoDetail() + } + ContentType.PLAYLIST -> fragment.navigate(url) + ContentType.URL -> fragment.navigate(url) + ContentType.CHANNEL -> fragment.navigate(url) else -> {}; } } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index b8b0b567..f5d518f8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -11,6 +11,7 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.ResultCapabilities +import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.TaskHandler @@ -18,6 +19,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.isHttpUrl import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.FeedStyle @@ -84,6 +86,7 @@ class ContentSearchResultsFragment : MainFragment() { private var _filterValues: HashMap> = hashMapOf(); private var _enabledClientIds: List? = null; private var _channelUrl: String? = null; + private var _searchType: SearchType? = null; private val _taskSearch: TaskHandler>; override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar @@ -95,7 +98,13 @@ class ContentSearchResultsFragment : MainFragment() { if (channelUrl != null) { StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds) } else { - StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) + when (_searchType) + { + SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) + SearchType.CREATOR -> StatePlatform.instance.searchChannelsAsContent(query) + SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query) + else -> throw Exception("Search type must be specified") + } } }) .success { loadedResult(it); }.exception { } @@ -116,6 +125,7 @@ class ContentSearchResultsFragment : MainFragment() { if(parameter is SuggestionsFragmentData) { setQuery(parameter.query, false); setChannelUrl(parameter.channelUrl, false); + setSearchType(parameter.searchType, false) fragment.topBar?.apply { if (this is SearchTopBarFragment) { @@ -258,6 +268,15 @@ class ContentSearchResultsFragment : MainFragment() { } } + private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) { + _searchType = searchType + + if (updateResults) { + clearResults(); + loadResults(); + } + } + private fun setSortBy(sortBy: String?, updateResults: Boolean = true) { _sortBy = sortBy; 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 3c915ebe..a9ea33b2 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 @@ -136,7 +136,6 @@ abstract class FeedView : L _toolbarContentView = findViewById(R.id.container_toolbar_content); _nextPageHandler = TaskHandler>({fragment.lifecycleScope}, { - if (it is IAsyncPager<*>) it.nextPageAsync(); else diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index a07de94e..9cc4e6a7 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -49,14 +49,7 @@ class SuggestionsFragment : MainFragment { _adapterSuggestions.onClicked.subscribe { suggestion -> val storage = FragmentedStorage.get(); storage.add(suggestion); - - if (_searchType == SearchType.CREATOR) { - navigate(suggestion); - } else if (_searchType == SearchType.PLAYLIST) { - navigate(suggestion); - } else { - navigate(SuggestionsFragmentData(suggestion, SearchType.VIDEO, _channelUrl)); - } + navigate(SuggestionsFragmentData(suggestion, _searchType, _channelUrl)); } _adapterSuggestions.onRemove.subscribe { suggestion -> val index = _suggestions.indexOf(suggestion); 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 dfddd51f..389d8a5e 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -632,6 +632,27 @@ class StatePlatform { return pager; } + fun searchChannelsAsContent(query: String): IPager { + Logger.i(TAG, "Platform - searchChannels"); + val pagers = mutableMapOf, Float>(); + getSortedEnabledClient().parallelStream().forEach { + try { + if (it.capabilities.hasChannelSearch) + pagers.put(it.searchChannelsAsContent(query), 1f); + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed search channels", ex) + UIDialogs.toast("Failed search channels on [${it.name}]\n(${ex.message})"); + } + }; + if(pagers.isEmpty()) + return EmptyPager(); + + val pager = MultiDistributionContentPager(pagers); + pager.initialize(); + return pager; + } + //Video fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt new file mode 100644 index 00000000..28225da9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt @@ -0,0 +1,88 @@ +package com.futo.platformplayer.views.adapters + +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.futo.platformplayer.R +import com.futo.platformplayer.api.media.models.IPlatformChannelContent +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.toHumanNumber +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.others.CreatorThumbnail +import com.futo.platformplayer.views.platform.PlatformIndicator +import com.futo.platformplayer.views.subscriptions.SubscribeButton + + +open class ChannelView : LinearLayout { + protected val _feedStyle : FeedStyle; + protected val _tiny: Boolean + + private val _textName: TextView; + private val _creatorThumbnail: CreatorThumbnail; + private val _textMetadata: TextView; + private val _buttonSubscribe: SubscribeButton; + private val _platformIndicator: PlatformIndicator; + + val onClick = Event1(); + + var currentChannel: IPlatformChannelContent? = null + private set + + val content: IPlatformContent? get() = currentChannel; + + constructor(context: Context, feedStyle: FeedStyle, tiny: Boolean) : super(context) { + inflate(feedStyle); + _feedStyle = feedStyle; + _tiny = tiny + + _textName = findViewById(R.id.text_channel_name); + _creatorThumbnail = findViewById(R.id.creator_thumbnail); + _textMetadata = findViewById(R.id.text_channel_metadata); + _buttonSubscribe = findViewById(R.id.button_subscribe); + _platformIndicator = findViewById(R.id.platform_indicator); + + if (_tiny) { + _buttonSubscribe.visibility = View.GONE; + _textMetadata.visibility = View.GONE; + } + + findViewById(R.id.root).setOnClickListener { + val s = currentChannel ?: return@setOnClickListener; + onClick.emit(s); + } + } + + protected open fun inflate(feedStyle: FeedStyle) { + inflate(context, when(feedStyle) { + FeedStyle.PREVIEW -> R.layout.list_creator + else -> R.layout.list_creator + }, this) + } + + open fun bind(content: IPlatformContent) { + isClickable = true; + + if(content !is IPlatformChannelContent) + return + + _creatorThumbnail.setThumbnail(content.thumbnail, false); + _textName.text = content.name; + + if(content.subscribers == null || (content.subscribers ?: 0) <= 0L) + _textMetadata.visibility = View.GONE; + else { + _textMetadata.text = if((content.subscribers ?: 0) > 0) content.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else ""; + _textMetadata.visibility = View.VISIBLE; + } + _buttonSubscribe.setSubscribeChannel(content.url); + _platformIndicator.setPlatformFromClientID(content.id.pluginId); + } + + companion object { + private val TAG = "ChannelView" + } +} diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt new file mode 100644 index 00000000..17754984 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewChannelViewHolder.kt @@ -0,0 +1,40 @@ +package com.futo.platformplayer.views.adapters.feedtypes + +import android.view.ViewGroup +import com.futo.platformplayer.api.media.models.IPlatformChannelContent +import com.futo.platformplayer.api.media.models.PlatformAuthorLink +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails +import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist +import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.fragment.mainactivity.main.CreatorFeedView +import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.adapters.ChannelView +import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder +import com.futo.platformplayer.views.adapters.PlaylistView + + +class PreviewChannelViewHolder : ContentPreviewViewHolder { + val onClick = Event1(); + + val currentChannel: IPlatformChannelContent? get() = view.currentChannel; + + override val content: IPlatformContent? get() = currentChannel; + + private val view: ChannelView get() = itemView as ChannelView; + + constructor(viewGroup: ViewGroup, feedStyle: FeedStyle, tiny: Boolean): super(ChannelView(viewGroup.context, feedStyle, tiny)) { + view.onClick.subscribe(onClick::emit); + } + + override fun bind(content: IPlatformContent) = view.bind(content); + + override fun preview(details: IPlatformContentDetails?, paused: Boolean) = Unit; + override fun stopPreview() = Unit; + override fun pausePreview() = Unit; + override fun resumePreview() = Unit; + + companion object { + private val TAG = "PreviewChannelViewHolder" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt index 225cf6d7..35dd2d7b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/feedtypes/PreviewContentListAdapter.kt @@ -23,6 +23,7 @@ import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.EmptyPreviewViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader +import okhttp3.internal.platform.Platform class PreviewContentListAdapter : InsertedViewAdapterWithLoader { private var _initialPlay = true; @@ -82,6 +83,7 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader createPlaylistViewHolder(viewGroup); ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup); ContentType.LOCKED -> createLockedViewHolder(viewGroup); + ContentType.CHANNEL -> createChannelViewHolder(viewGroup) else -> EmptyPreviewViewHolder(viewGroup) } } @@ -115,6 +117,10 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader Date: Tue, 22 Apr 2025 13:40:05 +0200 Subject: [PATCH 061/128] Add search type selector to suggestions fragment. --- .../mainactivity/main/SuggestionsFragment.kt | 47 ++++++++++------- .../res/layout/fragment_suggestion_list.xml | 50 +++++++++++++++++-- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt index 9cc4e6a7..0dfb867d 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SuggestionsFragment.kt @@ -18,6 +18,8 @@ import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.SearchHistoryStorage import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter +import com.futo.platformplayer.views.others.RadioGroupView +import com.futo.platformplayer.views.others.TagsView data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); @@ -28,6 +30,7 @@ class SuggestionsFragment : MainFragment { private var _recyclerSuggestions: RecyclerView? = null; private var _llmSuggestions: LinearLayoutManager? = null; + private var _radioGroupView: RadioGroupView? = null; private val _suggestions: ArrayList = ArrayList(); private var _query: String? = null; private var _searchType: SearchType = SearchType.VIDEO; @@ -73,6 +76,15 @@ class SuggestionsFragment : MainFragment { recyclerSuggestions.adapter = _adapterSuggestions; _recyclerSuggestions = recyclerSuggestions; + _radioGroupView = view.findViewById(R.id.radio_group).apply { + onSelectedChange.subscribe { + if (it.size != 1) + _searchType = SearchType.VIDEO + else + _searchType = (it[0] ?: SearchType.VIDEO) as SearchType + } + } + loadSuggestions(); return view; } @@ -103,31 +115,27 @@ class SuggestionsFragment : MainFragment { _channelUrl = null; } + _radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) + topBar?.apply { if (this is SearchTopBarFragment) { onSearch.subscribe(this) { - if (_searchType == SearchType.CREATOR) { - navigate(it); - } else if (_searchType == SearchType.PLAYLIST) { - navigate(it); - } else { - if(it.isHttpUrl()) { - if(StatePlatform.instance.hasEnabledPlaylistClient(it)) - navigate(it); - else if(StatePlatform.instance.hasEnabledChannelClient(it)) - navigate(it); - else { - val url = it; - activity?.let { - close() - if(it is MainActivity) - it.navigate(it.getFragment(), url); - } + if(it.isHttpUrl()) { + if(StatePlatform.instance.hasEnabledPlaylistClient(it)) + navigate(it); + else if(StatePlatform.instance.hasEnabledChannelClient(it)) + navigate(it); + else { + val url = it; + activity?.let { + close() + if(it is MainActivity) + it.navigate(it.getFragment(), url); } } - else - navigate(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); } + else + navigate(SuggestionsFragmentData(it, _searchType, _channelUrl)); }; onTextChange.subscribe(this) { @@ -189,6 +197,7 @@ class SuggestionsFragment : MainFragment { super.onDestroyMainView(); _getSuggestions.onError.clear(); _recyclerSuggestions = null; + _radioGroupView = null } override fun onDestroy() { diff --git a/app/src/main/res/layout/fragment_suggestion_list.xml b/app/src/main/res/layout/fragment_suggestion_list.xml index 3fec1999..41d5d06d 100644 --- a/app/src/main/res/layout/fragment_suggestion_list.xml +++ b/app/src/main/res/layout/fragment_suggestion_list.xml @@ -1,14 +1,58 @@ - + + + + + + + + + + + + + + android:orientation="vertical" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - \ No newline at end of file + \ No newline at end of file From 1a061268de84061461c5f1e17f2b33fa894e50b7 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 23 Apr 2025 09:30:07 -0500 Subject: [PATCH 062/128] restore brightness when leaving app while a video is full screen Changelog: changed --- .../mainactivity/main/VideoDetailFragment.kt | 8 +++ .../mainactivity/main/VideoDetailView.kt | 7 +++ .../views/behavior/GestureControlView.kt | 59 +++++++++++-------- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 6477fa1f..fd3319f0 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -455,6 +455,10 @@ class VideoDetailFragment() : MainFragment() { activity?.enterPictureInPictureMode(params); } } + + if (isFullscreen) { + viewDetail?.restoreBrightness() + } } fun forcePictureInPicture() { @@ -487,6 +491,10 @@ class VideoDetailFragment() : MainFragment() { _isActive = true; _leavingPiP = false; + if (isFullscreen) { + _viewDetail?.saveBrightness() + } + _viewDetail?.let { Logger.v(TAG, "onResume preventPictureInPicture=false"); it.preventPictureInPicture = false; 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 7176d125..c7e5604d 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 @@ -2485,6 +2485,13 @@ class VideoDetailView : ConstraintLayout { } } + fun saveBrightness() { + _player.gestureControl.saveBrightness() + } + fun restoreBrightness() { + _player.gestureControl.restoreBrightness() + } + fun setFullscreen(fullscreen : Boolean) { Logger.i(TAG, "setFullscreen(fullscreen=$fullscreen)") _player.setFullScreen(fullscreen) 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 7e3c90c9..65a750a8 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 @@ -735,24 +735,43 @@ class GestureControlView : LinearLayout { _animatorBrightness?.start(); } + fun saveBrightness() { + try { + _originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE) + + val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) + _brightnessFactor = brightness / 255.0f; + Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode") + + _originalBrightnessFactor = _brightnessFactor + } catch (e: Throwable) { + Settings.instance.gestureControls.useSystemBrightness = false + Settings.instance.save() + UIDialogs.toast(context, "useSystemBrightness disabled due to an error") + } + } + fun restoreBrightness() { + if (Settings.instance.gestureControls.restoreSystemBrightness) { + onBrightnessAdjusted.emit(_originalBrightnessFactor) + + if (android.provider.Settings.System.canWrite(context)) { + Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode") + + android.provider.Settings.System.putInt( + context.contentResolver, + android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, + _originalBrightnessMode + ) + } + } + } + fun setFullscreen(isFullScreen: Boolean) { resetZoomPan() if (isFullScreen) { if (Settings.instance.gestureControls.useSystemBrightness) { - try { - _originalBrightnessMode = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE) - - val brightness = android.provider.Settings.System.getInt(context.contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) - _brightnessFactor = brightness / 255.0f; - Log.i(TAG, "Starting brightness brightness: $brightness, _brightnessFactor: $_brightnessFactor, _originalBrightnessMode: $_originalBrightnessMode") - - _originalBrightnessFactor = _brightnessFactor - } catch (e: Throwable) { - Settings.instance.gestureControls.useSystemBrightness = false - Settings.instance.save() - UIDialogs.toast(context, "useSystemBrightness disabled due to an error") - } + saveBrightness() } if (Settings.instance.gestureControls.useSystemVolume) { @@ -766,19 +785,7 @@ class GestureControlView : LinearLayout { onSoundAdjusted.emit(_soundFactor); } else { if (Settings.instance.gestureControls.useSystemBrightness) { - if (Settings.instance.gestureControls.restoreSystemBrightness) { - onBrightnessAdjusted.emit(_originalBrightnessFactor) - - if (android.provider.Settings.System.canWrite(context)) { - Log.i(TAG, "Restoring system brightness mode _originalBrightnessMode: $_originalBrightnessMode") - - android.provider.Settings.System.putInt( - context.contentResolver, - android.provider.Settings.System.SCREEN_BRIGHTNESS_MODE, - _originalBrightnessMode - ) - } - } + restoreBrightness() } else { onBrightnessAdjusted.emit(1.0f); } From 4acc86763420f66adb3a1a114b4f8fcba6f7e246 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 23 Apr 2025 11:38:09 -0500 Subject: [PATCH 063/128] make channel shorts tab work correctly for linked channels Changelog: changed --- .../channel/tab/ChannelContentsFragment.kt | 2 +- .../platformplayer/states/StatePlatform.kt | 123 ++++++++++-------- .../platformplayer/states/StatePolycentric.kt | 8 +- 3 files changed, 73 insertions(+), 60 deletions(-) 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 b26c9b35..93639fba 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 @@ -71,7 +71,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), val lastPolycentricProfile = _lastPolycentricProfile; var pager: IPager? = null; if (lastPolycentricProfile != null) - pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); + pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); if(pager == null) { if(subType != 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 389d8a5e..0bb8fb85 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -718,7 +718,7 @@ class StatePlatform { } } - fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0): IPager { + fun getChannelContent(baseClient: IPlatformClient, channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, type: String? = null): IPager { val clientCapabilities = baseClient.getChannelCapabilities(); val client = if(usePooledClients > 1) _channelClientPool.getClientPooled(baseClient, usePooledClients); @@ -727,66 +727,75 @@ class StatePlatform { var lastStream: OffsetDateTime? = null; val pagerResult: IPager; - if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && - ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || - clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || - clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || - clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) - )) { - val toQuery = mutableListOf(); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) - toQuery.add(ResultCapabilities.TYPE_VIDEOS); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) - toQuery.add(ResultCapabilities.TYPE_STREAMS); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) - toQuery.add(ResultCapabilities.TYPE_LIVE); - if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) - toQuery.add(ResultCapabilities.TYPE_POSTS); + if (type == null) { + if(!clientCapabilities.hasType(ResultCapabilities.TYPE_MIXED) && + ( clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS) || + clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS) || + clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE) || + clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS) + )) { + val toQuery = mutableListOf(); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + toQuery.add(ResultCapabilities.TYPE_VIDEOS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_STREAMS)) + toQuery.add(ResultCapabilities.TYPE_STREAMS); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_LIVE)) + toQuery.add(ResultCapabilities.TYPE_LIVE); + if(clientCapabilities.hasType(ResultCapabilities.TYPE_POSTS)) + toQuery.add(ResultCapabilities.TYPE_POSTS); - if(isSubscriptionOptimized) { - val sub = StateSubscriptions.instance.getSubscription(channelUrl); - if(sub != null) { - if(!sub.shouldFetchStreams()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_LIVE); - } - if(!sub.shouldFetchLiveStreams()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_STREAMS); - } - if(!sub.shouldFetchPosts()) { - Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); - toQuery.remove(ResultCapabilities.TYPE_POSTS); - } - } - } - - //Merged pager - val pagers = toQuery - .parallelStream() - .map { - val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; - - when(it) { - ResultCapabilities.TYPE_STREAMS -> { - val streamResults = results.getResults(); - if(streamResults.size == 0) - lastStream = OffsetDateTime.MIN; - else - lastStream = results.getResults().firstOrNull()?.datetime; + if(isSubscriptionOptimized) { + val sub = StateSubscriptions.instance.getSubscription(channelUrl); + if(sub != null) { + if(!sub.shouldFetchStreams()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 7 days, skipping live streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_LIVE); + } + if(!sub.shouldFetchLiveStreams()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 15 days, skipping streams [${sub.lastLiveStream.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_STREAMS); + } + if(!sub.shouldFetchPosts()) { + Logger.i(TAG, "Subscription [${sub.channel.name}:${channelUrl}] Last livestream > 5 days, skipping posts [${sub.lastPost.getNowDiffDays()} days ago]"); + toQuery.remove(ResultCapabilities.TYPE_POSTS); } } - return@map results; } - .asSequence() - .toList(); - val pager = MultiChronoContentPager(pagers.toTypedArray()); - pager.initialize(); - pagerResult = pager; + //Merged pager + val pagers = toQuery + .parallelStream() + .map { + val results = client.getChannelContents(channelUrl, it, ResultCapabilities.ORDER_CHONOLOGICAL) ; + + when(it) { + ResultCapabilities.TYPE_STREAMS -> { + val streamResults = results.getResults(); + if(streamResults.size == 0) + lastStream = OffsetDateTime.MIN; + else + lastStream = results.getResults().firstOrNull()?.datetime; + } + } + return@map results; + } + .asSequence() + .toList(); + + val pager = MultiChronoContentPager(pagers.toTypedArray()); + pager.initialize(); + pagerResult = pager; + } + else { + pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); + } + } else { + pagerResult = if (type == ResultCapabilities.TYPE_SHORTS) { + client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL); + } else { + EmptyPager() + } } - else - pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); //Subscription optimization val sub = StateSubscriptions.instance.getSubscription(channelUrl); @@ -838,10 +847,10 @@ class StatePlatform { return pagerResult; } - fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List? = null): IPager { + fun getChannelContent(channelUrl: String, isSubscriptionOptimized: Boolean = false, usePooledClients: Int = 0, ignorePlugins: List? = null, type: String? = null): IPager { Logger.i(TAG, "Platform - getChannelVideos"); val baseClient = getChannelClient(channelUrl, ignorePlugins); - return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients); + return getChannelContent(baseClient, channelUrl, isSubscriptionOptimized, usePooledClients, type); } fun getChannelContent(channelUrl: String, type: String?, ordering: String = ResultCapabilities.ORDER_CHONOLOGICAL): IPager { val client = getChannelClient(channelUrl); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt index 9d6f7437..86ae541a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePolycentric.kt @@ -236,7 +236,7 @@ class StatePolycentric { return Pair(didUpdate, listOf(url)); } - fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1): IPager? { + fun getChannelContent(scope: CoroutineScope, profile: PolycentricProfile, isSubscriptionOptimized: Boolean = false, channelConcurrency: Int = -1, type: String? = null): IPager? { ensureEnabled() //TODO: Currently abusing subscription concurrency for parallelism @@ -248,7 +248,11 @@ class StatePolycentric { return@mapNotNull Pair(client, scope.async(Dispatchers.IO) { try { - return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); + if (type == null) { + return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency); + } else { + return@async StatePlatform.instance.getChannelContent(url, isSubscriptionOptimized, concurrency, type = type); + } } catch (ex: Throwable) { Logger.e(TAG, "getChannelContent", ex); return@async null; From 9e17dce9a9c5372278e505c5125d0112d297ae64 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 25 Apr 2025 17:40:45 +0200 Subject: [PATCH 064/128] Fix edgecase where activity killed before 5s after opening --- .../java/com/futo/platformplayer/states/StateApp.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 3adbe9c8..bdad1395 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -519,12 +519,16 @@ class StateApp { Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]"); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n"); - val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }; - if (isRateLimitReached) { + val isBelowRateLimit = !subRequestCounts.any { clientCount -> + clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true + }; + if (isBelowRateLimit) { Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); delay(5000); - if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) - StateSubscriptions.instance.updateSubscriptionFeed(scope, false); + scopeOrNull?.let { + if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) + StateSubscriptions.instance.updateSubscriptionFeed(it, false); + } } else Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); From c4623c80ffdd8910074cdc103688daf4427860b2 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 28 Apr 2025 13:59:50 +0200 Subject: [PATCH 065/128] Implemented app id and updated unit tests. --- .../futo/platformplayer/SyncServerTests.kt | 80 ++- .../java/com/futo/platformplayer/SyncTests.kt | 512 ++++++++++++++++++ .../java/com/futo/platformplayer/Utility.kt | 9 +- .../futo/platformplayer/states/StateSync.kt | 19 +- .../platformplayer/sync/internal/Channel.kt | 5 +- .../sync/internal/SyncSocketSession.kt | 85 +-- .../futo/platformplayer/NoiseProtocolTests.kt | 11 +- 7 files changed, 664 insertions(+), 57 deletions(-) create mode 100644 app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt index 7348c3c7..7607a2c9 100644 --- a/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncServerTests.kt @@ -3,19 +3,21 @@ package com.futo.platformplayer import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.sync.internal.* import kotlinx.coroutines.* +import kotlinx.coroutines.selects.select import org.junit.Assert.* import org.junit.Test import java.net.Socket import java.nio.ByteBuffer import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class SyncServerTests { //private val relayHost = "relay.grayjay.app" //private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw=" - private val relayHost = "192.168.1.175" + private val relayHost = "192.168.1.138" private val relayPort = 9000 /** Creates a client connected to the live relay server. */ @@ -23,7 +25,8 @@ class SyncServerTests { onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null, onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null, onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null, - isHandshakeAllowed: ((SyncSocketSession, String, String?) -> Boolean)? = null + isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null, + onException: ((Throwable) -> Unit)? = null ): SyncSocketSession = withContext(Dispatchers.IO) { val p = Noise.createDH("25519") p.generateKeyPair() @@ -43,10 +46,14 @@ class SyncServerTests { }, onData = onData ?: { _, _, _, _ -> }, onNewChannel = onNewChannel ?: { _, _ -> }, - isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _ -> true } + isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true } ) socketSession.authorizable = AlwaysAuthorized() - socketSession.startAsInitiator(relayKey) + try { + socketSession.startAsInitiator(relayKey) + } catch (e: Throwable) { + onException?.invoke(e) + } withTimeout(5000.milliseconds) { tcs.await() } return@withContext socketSession } @@ -259,6 +266,71 @@ class SyncServerTests { clientA.stop() clientB.stop() } + + @Test + fun relayedTransport_WithValidAppId_Success() = runBlocking { + // Arrange: Set up clients + val allowedAppId = 1234u + val tcsB = CompletableDeferred() + + // Client B requires appId 1234 + val clientB = createClient( + onNewChannel = { _, c -> tcsB.complete(c) }, + isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId } + ) + + val clientA = createClient() + + // Act: Start relayed channel with valid appId + val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) } + val channelB = withTimeout(5.seconds) { tcsB.await() } + withTimeout(5.seconds) { channelTask.await() } + + // Assert: Channel is established + assertNotNull("Channel should be created on target with valid appId", channelB) + + // Clean up + clientA.stop() + clientB.stop() + } + + @Test + fun relayedTransport_WithInvalidAppId_Fails() = runBlocking { + // Arrange: Set up clients + val allowedAppId = 1234u + val invalidAppId = 5678u + val tcsB = CompletableDeferred() + + // Client B requires appId 1234 + val clientB = createClient( + onNewChannel = { _, c -> tcsB.complete(c) }, + isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }, + onException = { } + ) + + val clientA = createClient() + + // Act & Assert: Attempt with invalid appId should fail + try { + withTimeout(5.seconds) { + clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId) + } + fail("Starting relayed channel with invalid appId should fail") + } catch (e: Throwable) { + // Expected: The channel creation should time out or fail + } + + // Ensure no channel was created on client B + val completedTask = select { + tcsB.onAwait { "channel" } + async { delay(1.seconds); "timeout" }.onAwait { "timeout" } + } + assertEquals("No channel should be created with invalid appId", "timeout", completedTask) + + // Clean up + clientA.stop() + clientB.stop() + } } class AlwaysAuthorized : IAuthorizable { diff --git a/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt new file mode 100644 index 00000000..1b9f19cd --- /dev/null +++ b/app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt @@ -0,0 +1,512 @@ +package com.futo.platformplayer + +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.sync.internal.* +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.nio.ByteBuffer +import kotlin.random.Random +import java.io.InputStream +import java.io.OutputStream +import kotlin.time.Duration.Companion.seconds + +data class PipeStreams( + val initiatorInput: LittleEndianDataInputStream, + val initiatorOutput: LittleEndianDataOutputStream, + val responderInput: LittleEndianDataInputStream, + val responderOutput: LittleEndianDataOutputStream +) + +typealias OnHandshakeComplete = (SyncSocketSession) -> Unit +typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean +typealias OnClose = (SyncSocketSession) -> Unit +typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit + +class SyncSocketTests { + private fun createPipeStreams(): PipeStreams { + val initiatorOutput = PipedOutputStream() + val responderOutput = PipedOutputStream() + val responderInput = PipedInputStream(initiatorOutput) + val initiatorInput = PipedInputStream(responderOutput) + return PipeStreams( + LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput), + LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput) + ) + } + + fun generateKeyPair(): DHState { + val p = Noise.createDH("25519") + p.generateKeyPair() + return p + } + + private fun createSessions( + initiatorInput: LittleEndianDataInputStream, + initiatorOutput: LittleEndianDataOutputStream, + responderInput: LittleEndianDataInputStream, + responderOutput: LittleEndianDataOutputStream, + initiatorKeyPair: DHState, + responderKeyPair: DHState, + onInitiatorHandshakeComplete: OnHandshakeComplete, + onResponderHandshakeComplete: OnHandshakeComplete, + onInitiatorClose: OnClose? = null, + onResponderClose: OnClose? = null, + onClose: OnClose? = null, + isHandshakeAllowed: IsHandshakeAllowed? = null, + onDataA: OnData? = null, + onDataB: OnData? = null + ): Pair { + val initiatorSession = SyncSocketSession( + "", initiatorKeyPair, initiatorInput, initiatorOutput, + onClose = { + onClose?.invoke(it) + onInitiatorClose?.invoke(it) + }, + onHandshakeComplete = onInitiatorHandshakeComplete, + onData = onDataA, + isHandshakeAllowed = isHandshakeAllowed + ) + + val responderSession = SyncSocketSession( + "", responderKeyPair, responderInput, responderOutput, + onClose = { + onClose?.invoke(it) + onResponderClose?.invoke(it) + }, + onHandshakeComplete = onResponderHandshakeComplete, + onData = onDataB, + isHandshakeAllowed = isHandshakeAllowed + ) + + return Pair(initiatorSession, responderSession) + } + + @Test + fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val validPairingCode = "secret" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode) + responderSession.startAsResponder() + + withTimeout(5.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + } + + @Test + fun handshake_WithInvalidPairingCode_Fails() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val validPairingCode = "secret" + val invalidPairingCode = "wrong" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val initiatorClosed = CompletableDeferred() + val responderClosed = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onInitiatorClose = { + initiatorClosed.complete(true) + }, + onResponderClose = { + responderClosed.complete(true) + }, + isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode) + responderSession.startAsResponder() + + withTimeout(100.seconds) { + initiatorClosed.await() + responderClosed.await() + } + + assertFalse(handshakeInitiatorCompleted.isCompleted) + assertFalse(handshakeResponderCompleted.isCompleted) + } + + @Test + fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val validPairingCode = "secret" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val initiatorClosed = CompletableDeferred() + val responderClosed = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onInitiatorClose = { + initiatorClosed.complete(true) + }, + onResponderClose = { + responderClosed.complete(true) + }, + isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code + responderSession.startAsResponder() + + withTimeout(5.seconds) { + initiatorClosed.await() + responderClosed.await() + } + + assertFalse(handshakeInitiatorCompleted.isCompleted) + assertFalse(handshakeResponderCompleted.isCompleted) + } + + @Test + fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val pairingCode = "unnecessary" + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + } + + @Test + fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Ensure both sessions are authorized + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val smallData = byteArrayOf(1, 2, 3) + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(smallData, receivedData) + } + + @Test + fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Ensure both sessions are authorized + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) } + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(maxData, receivedData) + } + + @Test + fun stream_LargeData_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Ensure both sessions are authorized + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) } + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(largeData, receivedData) + } + + @Test + fun authorizedSession_CanSendData() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, opcode, subOpcode, data -> + if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) { + val b = ByteArray(data.remaining()) + data.get(b) + tcsDataReceived.complete(b) + } + } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Authorize both sessions + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Authorized() + + val data = byteArrayOf(1, 2, 3) + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data)) + + val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() } + assertArrayEquals(data, receivedData) + } + + @Test + fun unauthorizedSession_CannotSendData() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val tcsDataReceived = CompletableDeferred() + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onDataB = { _, _, _, _ -> } + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey) + responderSession.startAsResponder() + + withTimeout(10.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + // Authorize initiator but not responder + initiatorSession.authorizable = Authorized() + responderSession.authorizable = Unauthorized() + + val data = byteArrayOf(1, 2, 3) + initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data)) + + delay(1.seconds) + assertFalse(tcsDataReceived.isCompleted) + } + + @Test + fun directHandshake_WithValidAppId_Succeeds() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val allowedAppId = 1234u + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + + val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt -> + linkType == LinkType.Direct && appId == allowedAppId + } + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + isHandshakeAllowed = responderIsHandshakeAllowed + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId) + responderSession.startAsResponder() + + withTimeout(5.seconds) { + handshakeInitiatorCompleted.await() + handshakeResponderCompleted.await() + } + + assertNotNull(initiatorSession.remotePublicKey) + assertNotNull(responderSession.remotePublicKey) + } + + @Test + fun directHandshake_WithInvalidAppId_Fails() = runBlocking { + val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams() + val initiatorKeyPair = generateKeyPair() + val responderKeyPair = generateKeyPair() + val allowedAppId = 1234u + val invalidAppId = 5678u + + val handshakeInitiatorCompleted = CompletableDeferred() + val handshakeResponderCompleted = CompletableDeferred() + val initiatorClosed = CompletableDeferred() + val responderClosed = CompletableDeferred() + + val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt -> + linkType == LinkType.Direct && appId == allowedAppId + } + + val (initiatorSession, responderSession) = createSessions( + initiatorInput, initiatorOutput, responderInput, responderOutput, + initiatorKeyPair, responderKeyPair, + { handshakeInitiatorCompleted.complete(true) }, + { handshakeResponderCompleted.complete(true) }, + onInitiatorClose = { + initiatorClosed.complete(true) + }, + onResponderClose = { + responderClosed.complete(true) + }, + isHandshakeAllowed = responderIsHandshakeAllowed + ) + + initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId) + responderSession.startAsResponder() + + withTimeout(5.seconds) { + initiatorClosed.await() + responderClosed.await() + } + + assertFalse(handshakeInitiatorCompleted.isCompleted) + assertFalse(handshakeResponderCompleted.isCompleted) + } +} + +class Authorized : IAuthorizable { + override val isAuthorized: Boolean = true +} + +class Unauthorized : IAuthorizable { + override val isAuthorized: Boolean = false +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index 7ddefc79..c1c7d3e8 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -69,7 +69,14 @@ fun warnIfMainThread(context: String) { } fun ensureNotMainThread() { - if (Looper.myLooper() == Looper.getMainLooper()) { + val isMainLooper = try { + Looper.myLooper() == Looper.getMainLooper() + } catch (e: Throwable) { + //Ignore, for unit tests where its not mocked + false + } + + if (isMainLooper) { Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread") throw IllegalStateException("Cannot run on main thread") } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 5f580d55..7e5d02e9 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -226,7 +226,7 @@ class StateSync { keyPair!!, LittleEndianDataInputStream(socket.getInputStream()), LittleEndianDataOutputStream(socket.getOutputStream()), - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode) }, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, onNewChannel = { _, c -> val remotePublicKey = c.remotePublicKey if (remotePublicKey == null) { @@ -297,7 +297,7 @@ class StateSync { if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { try { Log.v(TAG, "Attempting relayed connection with '$targetKey'.") - runBlocking { relaySession.startRelayedChannel(targetKey, null) } + runBlocking { relaySession.startRelayedChannel(targetKey, APP_ID, null) } } catch (e: Throwable) { Log.e(TAG, "Failed to start relayed channel with $targetKey.", e) } @@ -318,7 +318,7 @@ class StateSync { override val isAuthorized: Boolean get() = true } - _relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, null) + _relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, APP_ID, null) Log.i(TAG, "Started relay session.") } catch (e: Throwable) { @@ -731,8 +731,8 @@ class StateSync { ) } - private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?): Boolean { - Log.v(TAG, "Check if handshake allowed from '$publicKey'.") + private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean { + Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).") if (publicKey == RELAY_PUBLIC_KEY) return true @@ -744,7 +744,7 @@ class StateSync { } } - Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode'.") + Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).") if (_pairingCode == null || pairingCode.isNullOrEmpty()) return false @@ -766,7 +766,7 @@ class StateSync { if (channelSocket != null) session?.removeChannel(channelSocket!!) }, - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode) }, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, onHandshakeComplete = { s -> val remotePublicKey = s.remotePublicKey if (remotePublicKey == null) { @@ -930,7 +930,7 @@ class StateSync { _remotePendingStatusUpdate[deviceInfo.publicKey] = onStatusUpdate } } - relaySession.startRelayedChannel(deviceInfo.publicKey, deviceInfo.pairingCode) + relaySession.startRelayedChannel(deviceInfo.publicKey, APP_ID, deviceInfo.pairingCode) } } else { throw e @@ -950,7 +950,7 @@ class StateSync { } } - session.startAsInitiator(publicKey, pairingCode) + session.startAsInitiator(publicKey, APP_ID, pairingCode) return session } @@ -1008,6 +1008,7 @@ class StateSync { val version = 1 val RELAY_SERVER = "relay.grayjay.app" val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" + val APP_ID = 0x534A5247u //GRayJaySync (GRJS) private const val TAG = "StateSync" const val PORT = 12315 diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index bfcee6fd..84c1445c 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -246,7 +246,7 @@ class ChannelRelayed( } } - fun sendRequestTransport(requestId: Int, publicKey: String, pairingCode: String? = null) { + fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) { throwIfDisposed() synchronized(sendLock) { @@ -270,10 +270,11 @@ class ChannelRelayed( 0 to ByteArray(0) } - val packetSize = 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten + val packetSize = 4 + 4 + 32 + 4 + pairingMessageLength + 4 + channelBytesWritten val packet = ByteArray(packetSize) ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(requestId) + putInt(appId.toInt()) put(publicKeyBytes) putInt(pairingMessageLength) if (pairingMessageLength > 0) put(pairingMessage) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 2b3a7e10..ad928698 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -38,12 +38,13 @@ class SyncSocketSession { private val _onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? private val _onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? private val _onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? - private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? + private val _isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? private var _cipherStatePair: CipherStatePair? = null private var _remotePublicKey: String? = null val remotePublicKey: String? get() = _remotePublicKey private var _started: Boolean = false private val _localKeyPair: DHState + private var _thread: Thread? = null private var _localPublicKey: String val localPublicKey: String get() = _localPublicKey private val _onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? @@ -87,7 +88,7 @@ class SyncSocketSession { onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, onNewChannel: ((session: SyncSocketSession, channel: ChannelRelayed) -> Unit)? = null, onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, - isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?) -> Boolean)? = null + isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? = null ) { _inputStream = inputStream _outputStream = outputStream @@ -105,31 +106,35 @@ class SyncSocketSession { _localPublicKey = Base64.getEncoder().encodeToString(localPublicKey) } - fun startAsInitiator(remotePublicKey: String, pairingCode: String? = null) { + fun startAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { _started = true - try { - handshakeAsInitiator(remotePublicKey, pairingCode) - _onHandshakeComplete?.invoke(this) - receiveLoop() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to run as initiator", e) - } finally { - stop() - } + _thread = Thread { + try { + handshakeAsInitiator(remotePublicKey, appId, pairingCode) + _onHandshakeComplete?.invoke(this) + receiveLoop() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to run as initiator", e) + } finally { + stop() + } + }.apply { start() } } fun startAsResponder() { _started = true - try { - if (handshakeAsResponder()) { - _onHandshakeComplete?.invoke(this) - receiveLoop() + _thread = Thread { + try { + if (handshakeAsResponder()) { + _onHandshakeComplete?.invoke(this) + receiveLoop() + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to run as responder", e) + } finally { + stop() } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to run as responder", e) - } finally { - stop() - } + }.apply { start() } } private fun receiveLoop() { @@ -187,12 +192,13 @@ class SyncSocketSession { _onClose?.invoke(this) _inputStream.close() _outputStream.close() + _thread = null _cipherStatePair?.sender?.destroy() _cipherStatePair?.receiver?.destroy() Logger.i(TAG, "Session closed") } - private fun handshakeAsInitiator(remotePublicKey: String, pairingCode: String?) { + private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) { performVersionCheck() val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) @@ -218,7 +224,8 @@ class SyncSocketSession { val mainBuffer = ByteArray(512) val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) - val messageData = ByteBuffer.allocate(4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN) + val messageData = ByteBuffer.allocate(4 + 4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN) + messageData.putInt(appId.toInt()) messageData.putInt(pairingMessageLength) if (pairingMessageLength > 0) messageData.put(pairingMessage) messageData.put(mainBuffer, 0, mainLength) @@ -250,9 +257,10 @@ class SyncSocketSession { _inputStream.readFully(message) val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) + val appId = messageBuffer.int.toUInt() val pairingMessageLength = messageBuffer.int val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() - val mainLength = messageSize - 4 - pairingMessageLength + val mainLength = messageSize - 4 - 4 - pairingMessageLength val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } var pairingCode: String? = null @@ -267,6 +275,15 @@ class SyncSocketSession { val plaintext = ByteArray(512) responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) + val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) + responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) + val remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + + val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true) + if (!isAllowedToConnect) { + stop() + return false + } val responseBuffer = ByteArray(512) val responseLength = responder.writeMessage(responseBuffer, 0, null, 0, 0) @@ -274,13 +291,8 @@ class SyncSocketSession { _outputStream.write(responseBuffer, 0, responseLength) _cipherStatePair = responder.split() - val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) - responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) - - return (_remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, _remotePublicKey!!, pairingCode) ?: true)).also { - if (!it) stop() - } + _remotePublicKey = remotePublicKey + return true } private fun performVersionCheck() { @@ -400,13 +412,14 @@ class SyncSocketSession { val remoteVersion = data.int val connectionId = data.long val requestId = data.int + val appId = data.int.toUInt() val publicKeyBytes = ByteArray(32).also { data.get(it) } val pairingMessageLength = data.int - if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128)") + if (pairingMessageLength > 128) throw IllegalArgumentException("Pairing message length ($pairingMessageLength) exceeds maximum (128) (app id: $appId)") val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { data.get(it) } else ByteArray(0) val channelMessageLength = data.int if (data.remaining() != channelMessageLength) { - Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()}") + Logger.e(TAG, "Invalid packet size. Expected ${52 + pairingMessageLength + 4 + channelMessageLength}, got ${data.capacity()} (app id: $appId)") return } val channelHandshakeMessage = ByteArray(channelMessageLength).also { data.get(it) } @@ -420,7 +433,7 @@ class SyncSocketSession { val length = pairingProtocol.readMessage(pairingMessage, 0, pairingMessageLength, plaintext, 0) String(plaintext, 0, length, Charsets.UTF_8) } else null - val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode) ?: true) + val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true) if (!isAllowed) { val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) rp.putInt(2) // Status code for not allowed @@ -876,14 +889,14 @@ class SyncSocketSession { return deferred.await() } - suspend fun startRelayedChannel(publicKey: String, pairingCode: String? = null): ChannelRelayed? { + suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? { val requestId = generateRequestId() val deferred = CompletableDeferred() val channel = ChannelRelayed(this, _localKeyPair, publicKey, true) _onNewChannel?.invoke(this, channel) _pendingChannels[requestId] = channel to deferred try { - channel.sendRequestTransport(requestId, publicKey, pairingCode) + channel.sendRequestTransport(requestId, publicKey, appId, pairingCode) } catch (e: Exception) { _pendingChannels.remove(requestId)?.let { it.first.close(); it.second.completeExceptionally(e) } throw e diff --git a/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt b/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt index 33b640f9..189fc767 100644 --- a/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt +++ b/app/src/test/java/com/futo/platformplayer/NoiseProtocolTests.kt @@ -9,6 +9,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.sync.internal.IAuthorizable +import com.futo.platformplayer.sync.internal.Opcode import com.futo.platformplayer.sync.internal.SyncSocketSession import com.futo.platformplayer.sync.internal.SyncStream import junit.framework.TestCase.assertEquals @@ -586,16 +587,16 @@ class NoiseProtocolTest { handshakeLatch.await(10, TimeUnit.SECONDS) // Simulate initiator sending a PING and responder replying with PONG - initiatorSession.send(SyncSocketSession.Opcode.PING.value) - responderSession.send(SyncSocketSession.Opcode.PONG.value) + initiatorSession.send(Opcode.PING.value) + responderSession.send(Opcode.PONG.value) // Test data transfer - responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesExactlyOnePacket) - initiatorSession.send(SyncSocketSession.Opcode.DATA.value, 1u, randomBytes) + responderSession.send(Opcode.DATA.value, 0u, randomBytesExactlyOnePacket) + initiatorSession.send(Opcode.DATA.value, 1u, randomBytes) // Send large data to test stream handling val start = System.currentTimeMillis() - responderSession.send(SyncSocketSession.Opcode.DATA.value, 0u, randomBytesBig) + responderSession.send(Opcode.DATA.value, 0u, randomBytesBig) println("Sent 10MB in ${System.currentTimeMillis() - start}ms") // Wait for a brief period to simulate delay and allow communication From d9d00e452e3e48532ab0185b6a1c088226d9b618 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 28 Apr 2025 16:59:11 +0200 Subject: [PATCH 066/128] Explicitly set network interface in joinGroup. --- .../java/com/futo/platformplayer/mdns/MDNSListener.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt index 2b972d87..b8ef3eea 100644 --- a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt +++ b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt @@ -7,6 +7,7 @@ import java.util.* import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock + class MDNSListener { companion object { private val TAG = "MDNSListener" @@ -60,6 +61,7 @@ class MDNSListener { reuseAddress = true bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort)) } + _receiver4 = receiver4 val receiver6 = MulticastSocket(null).apply { @@ -171,7 +173,9 @@ class MDNSListener { is Inet4Address -> { _receiver4?.let { receiver4 -> //receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) - receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) + val ni = NetworkInterface.getByInetAddress(address) + receiver4.networkInterface = ni + receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), ni) } val sender = MulticastSocket(null).apply { @@ -185,7 +189,9 @@ class MDNSListener { is Inet6Address -> { _receiver6?.let { receiver6 -> //receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) - receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) + val ni = NetworkInterface.getByInetAddress(address) + receiver6.networkInterface = ni + receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), ni) } val sender = MulticastSocket(null).apply { From e3800426c9982a2498752c6fdc6fb58ffa65ca86 Mon Sep 17 00:00:00 2001 From: Kai Date: Mon, 28 Apr 2025 10:13:26 -0500 Subject: [PATCH 067/128] Fix https://github.com/futo-org/grayjay-android/issues/1721 By setting video information section to visibility gone when going fullscreen Changelog: changed --- .../fragment/mainactivity/main/VideoDetailView.kt | 2 ++ 1 file changed, 2 insertions(+) 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 7176d125..c6be80f2 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 @@ -2413,6 +2413,7 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "handleFullScreen(fullscreen=$fullscreen)") if(fullscreen) { + _container_content.visibility = GONE _layoutPlayerContainer.setPadding(0, 0, 0, 0); val lp = _container_content.layoutParams as LayoutParams; @@ -2426,6 +2427,7 @@ class VideoDetailView : ConstraintLayout { setProgressBarOverlayed(null); } else { + _container_content.visibility = VISIBLE _layoutPlayerContainer.setPadding(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 6.0f, Resources.getSystem().displayMetrics).toInt()); val lp = _container_content.layoutParams as LayoutParams; From 5b143bdc76d589c994a44b1eae482442fcf421a0 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 29 Apr 2025 08:39:05 +0200 Subject: [PATCH 068/128] Switch to NsdManager. --- .../platformplayer/casting/StateCasting.kt | 143 +++-- .../platformplayer/mdns/BroadcastService.kt | 11 - .../com/futo/platformplayer/mdns/DnsPacket.kt | 93 ---- .../futo/platformplayer/mdns/DnsQuestion.kt | 110 ---- .../com/futo/platformplayer/mdns/DnsReader.kt | 514 ------------------ .../platformplayer/mdns/DnsResourceRecord.kt | 117 ---- .../com/futo/platformplayer/mdns/DnsWriter.kt | 208 ------- .../futo/platformplayer/mdns/Extensions.kt | 63 --- .../futo/platformplayer/mdns/MDNSListener.kt | 501 ----------------- .../futo/platformplayer/mdns/NICMonitor.kt | 66 --- .../platformplayer/mdns/ServiceDiscoverer.kt | 71 --- .../mdns/ServiceRecordAggregator.kt | 226 -------- .../futo/platformplayer/states/StateApp.kt | 2 +- .../futo/platformplayer/states/StateSync.kt | 196 +++++-- .../sync/internal/SyncSocketSession.kt | 13 + 15 files changed, 250 insertions(+), 2084 deletions(-) delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt delete mode 100644 app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 25d2bcd1..865af56f 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -4,10 +4,12 @@ import android.app.AlertDialog import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build import android.os.Looper import android.util.Base64 import android.util.Log -import android.util.Xml import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.futo.platformplayer.R @@ -40,8 +42,6 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.mdns.DnsService -import com.futo.platformplayer.mdns.ServiceDiscoverer import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp @@ -55,7 +55,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import java.io.ByteArrayInputStream import java.net.InetAddress import java.net.URLDecoder import java.net.URLEncoder @@ -84,48 +83,15 @@ class StateCasting { private var _audioExecutor: JSRequestExecutor? = null private val _client = ManagedHttpClient(); var _resumeCastingDevice: CastingDeviceInfo? = null; - val _serviceDiscoverer = ServiceDiscoverer(arrayOf( - "_googlecast._tcp.local", - "_airplay._tcp.local", - "_fastcast._tcp.local", - "_fcast._tcp.local" - )) { handleServiceUpdated(it) } - + private var _nsdManager: NsdManager? = null val isCasting: Boolean get() = activeDevice != null; - private fun handleServiceUpdated(services: List) { - for (s in services) { - //TODO: Addresses IPv4 only? - val addresses = s.addresses.toTypedArray() - val port = s.port.toInt() - var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length) - if (s.name.endsWith("._googlecast._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length) - } - - addOrUpdateChromeCastDevice(name, addresses, port) - } else if (s.name.endsWith("._airplay._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length) - } - - addOrUpdateAirPlayDevice(name, addresses, port) - } else if (s.name.endsWith("._fastcast._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length) - } - - addOrUpdateFastCastDevice(name, addresses, port) - } else if (s.name.endsWith("._fcast._tcp.local")) { - if (name == null) { - name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length) - } - - addOrUpdateFastCastDevice(name, addresses, port) - } - } - } + private val _discoveryListeners = mapOf( + "_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice), + "_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice), + "_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice), + "_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice) + ) fun handleUrl(context: Context, url: String) { val uri = Uri.parse(url) @@ -197,23 +163,25 @@ class StateCasting { enableDeveloper(true); Logger.i(TAG, "CastingService started."); + + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager } @Synchronized fun startDiscovering() { - try { - _serviceDiscoverer.start() - } catch (e: Throwable) { - Logger.i(TAG, "Failed to start ServiceDiscoverer", e) + _nsdManager?.apply { + _discoveryListeners.forEach { + discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value) + } } } @Synchronized fun stopDiscovering() { - try { - _serviceDiscoverer.stop() - } catch (e: Throwable) { - Logger.i(TAG, "Failed to stop ServiceDiscoverer", e) + _nsdManager?.apply { + _discoveryListeners.forEach { + stopServiceDiscovery(it.value) + } } } @@ -239,6 +207,77 @@ class StateCasting { _castServer.removeAllHandlers(); Logger.i(TAG, "CastingService stopped.") + + _nsdManager = null + } + + private fun createDiscoveryListener(addOrUpdate: (String, Array, Int) -> Unit): NsdManager.DiscoveryListener { + return object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + _nsdManager?.stopServiceDiscovery(this) + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + _nsdManager?.stopServiceDiscovery(this) + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + arrayOf(service.host) + } + addOrUpdate(service.serviceName, addresses, service.port) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port) + } + }) + } + } + } } private val _castingDialogLock = Any(); diff --git a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt deleted file mode 100644 index ac3c61e0..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.futo.platformplayer.mdns - -data class BroadcastService( - val deviceName: String, - val serviceName: String, - val port: UShort, - val ttl: UInt, - val weight: UShort, - val priority: UShort, - val texts: List? = null -) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt deleted file mode 100644 index 2c27edf8..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.futo.platformplayer.mdns - -import java.nio.ByteBuffer -import java.nio.ByteOrder - -enum class QueryResponse(val value: Byte) { - Query(0), - Response(1) -} - -enum class DnsOpcode(val value: Byte) { - StandardQuery(0), - InverseQuery(1), - ServerStatusRequest(2) -} - -enum class DnsResponseCode(val value: Byte) { - NoError(0), - FormatError(1), - ServerFailure(2), - NameError(3), - NotImplemented(4), - Refused(5) -} - -data class DnsPacketHeader( - val identifier: UShort, - val queryResponse: Int, - val opcode: Int, - val authoritativeAnswer: Boolean, - val truncated: Boolean, - val recursionDesired: Boolean, - val recursionAvailable: Boolean, - val answerAuthenticated: Boolean, - val nonAuthenticatedData: Boolean, - val responseCode: DnsResponseCode -) - -data class DnsPacket( - val header: DnsPacketHeader, - val questions: List, - val answers: List, - val authorities: List, - val additionals: List -) { - companion object { - fun parse(data: ByteArray): DnsPacket { - val span = data.asUByteArray() - val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort() - val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort() - val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort() - val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort() - val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort() - - var position = 12 - - val questions = List(questionCount.toInt()) { - DnsQuestion.parse(data, position).also { position = it.second } - }.map { it.first } - - val answers = List(answerCount.toInt()) { - DnsResourceRecord.parse(data, position).also { position = it.second } - }.map { it.first } - - val authorities = List(authorityCount.toInt()) { - DnsResourceRecord.parse(data, position).also { position = it.second } - }.map { it.first } - - val additionals = List(additionalCount.toInt()) { - DnsResourceRecord.parse(data, position).also { position = it.second } - }.map { it.first } - - return DnsPacket( - header = DnsPacketHeader( - identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(), - queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(), - opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(), - authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0, - truncated = (flags.toInt() shr 9) and 0b1 != 0, - recursionDesired = (flags.toInt() shr 8) and 0b1 != 0, - recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0, - answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0, - nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0, - responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111] - ), - questions = questions, - answers = answers, - authorities = authorities, - additionals = additionals - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt deleted file mode 100644 index 01a7bd77..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.mdns.Extensions.readDomainName -import java.nio.ByteBuffer -import java.nio.ByteOrder - - -enum class QuestionType(val value: UShort) { - A(1u), - NS(2u), - MD(3u), - MF(4u), - CNAME(5u), - SOA(6u), - MB(7u), - MG(8u), - MR(9u), - NULL(10u), - WKS(11u), - PTR(12u), - HINFO(13u), - MINFO(14u), - MX(15u), - TXT(16u), - RP(17u), - AFSDB(18u), - SIG(24u), - KEY(25u), - AAAA(28u), - LOC(29u), - SRV(33u), - NAPTR(35u), - KX(36u), - CERT(37u), - DNAME(39u), - APL(42u), - DS(43u), - SSHFP(44u), - IPSECKEY(45u), - RRSIG(46u), - NSEC(47u), - DNSKEY(48u), - DHCID(49u), - NSEC3(50u), - NSEC3PARAM(51u), - TSLA(52u), - SMIMEA(53u), - HIP(55u), - CDS(59u), - CDNSKEY(60u), - OPENPGPKEY(61u), - CSYNC(62u), - ZONEMD(63u), - SVCB(64u), - HTTPS(65u), - EUI48(108u), - EUI64(109u), - TKEY(249u), - TSIG(250u), - URI(256u), - CAA(257u), - TA(32768u), - DLV(32769u), - AXFR(252u), - IXFR(251u), - OPT(41u), - MAILB(253u), - MALA(254u), - All(252u) -} - -enum class QuestionClass(val value: UShort) { - IN(1u), - CS(2u), - CH(3u), - HS(4u), - All(255u) -} - -data class DnsQuestion( - override val name: String, - override val type: Int, - override val clazz: Int, - val queryUnicast: Boolean -) : DnsResourceRecordBase(name, type, clazz) { - companion object { - fun parse(data: ByteArray, startPosition: Int): Pair { - val span = data.asUByteArray() - var position = startPosition - val qname = span.readDomainName(position).also { position = it.second } - val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - - return DnsQuestion( - name = qname.first, - type = qtype.toInt(), - queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0, - clazz = qclass.toInt() and 0b111111111111111 - ) to position - } - } -} - -open class DnsResourceRecordBase( - open val name: String, - open val type: Int, - open val clazz: Int -) diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt deleted file mode 100644 index 83c329ff..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt +++ /dev/null @@ -1,514 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.mdns.Extensions.readDomainName -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.charset.StandardCharsets -import kotlin.math.pow -import java.net.InetAddress - -data class PTRRecord(val domainName: String) - -data class ARecord(val address: InetAddress) - -data class AAAARecord(val address: InetAddress) - -data class MXRecord(val preference: UShort, val exchange: String) - -data class CNAMERecord(val cname: String) - -data class TXTRecord(val texts: List) - -data class SOARecord( - val primaryNameServer: String, - val responsibleAuthorityMailbox: String, - val serialNumber: Int, - val refreshInterval: Int, - val retryInterval: Int, - val expiryLimit: Int, - val minimumTTL: Int -) - -data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String) - -data class NSRecord(val nameServer: String) - -data class CAARecord(val flags: Byte, val tag: String, val value: String) - -data class HINFORecord(val cpu: String, val os: String) - -data class RPRecord(val mailbox: String, val txtDomainName: String) - - -data class AFSDBRecord(val subtype: UShort, val hostname: String) -data class LOCRecord( - val version: Byte, - val size: Double, - val horizontalPrecision: Double, - val verticalPrecision: Double, - val latitude: Double, - val longitude: Double, - val altitude: Double -) { - companion object { - fun decodeSizeOrPrecision(coded: Byte): Double { - val baseValue = (coded.toInt() shr 4) and 0x0F - val exponent = coded.toInt() and 0x0F - return baseValue * 10.0.pow(exponent.toDouble()) - } - - fun decodeLatitudeOrLongitude(coded: Int): Double { - val arcSeconds = coded / 1E3 - return arcSeconds / 3600.0 - } - - fun decodeAltitude(coded: Int): Double { - return (coded / 100.0) - 100000.0 - } - } -} - -data class NAPTRRecord( - val order: UShort, - val preference: UShort, - val flags: String, - val services: String, - val regexp: String, - val replacement: String -) - -data class RRSIGRecord( - val typeCovered: UShort, - val algorithm: Byte, - val labels: Byte, - val originalTTL: UInt, - val signatureExpiration: UInt, - val signatureInception: UInt, - val keyTag: UShort, - val signersName: String, - val signature: ByteArray -) - -data class KXRecord(val preference: UShort, val exchanger: String) - -data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray) - - - -data class DNAMERecord(val target: String) - -data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray) - -data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray) - -data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) - -data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray) - -data class URIRecord(val priority: UShort, val weight: UShort, val target: String) - -data class NSECRecord(val ownerName: String, val typeBitMaps: List>) -data class NSEC3Record( - val hashAlgorithm: Byte, - val flags: Byte, - val iterations: UShort, - val salt: ByteArray, - val nextHashedOwnerName: ByteArray, - val typeBitMaps: List -) - -data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray) -data class SPFRecord(val texts: List) -data class TKEYRecord( - val algorithm: String, - val inception: UInt, - val expiration: UInt, - val mode: UShort, - val error: UShort, - val keyData: ByteArray, - val otherData: ByteArray -) - -data class TSIGRecord( - val algorithmName: String, - val timeSigned: UInt, - val fudge: UShort, - val mac: ByteArray, - val originalID: UShort, - val error: UShort, - val otherData: ByteArray -) - -data class OPTRecordOption(val code: UShort, val data: ByteArray) -data class OPTRecord(val options: List) - -class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) { - - private val endPosition: Int = position + length - - fun readDomainName(): String { - return data.asUByteArray().readDomainName(position).also { position = it.second }.first - } - - fun readDouble(): Double { - checkRemainingBytes(Double.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double - position += Double.SIZE_BYTES - return result - } - - fun readInt16(): Short { - checkRemainingBytes(Short.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short - position += Short.SIZE_BYTES - return result - } - - fun readInt32(): Int { - checkRemainingBytes(Int.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int - position += Int.SIZE_BYTES - return result - } - - fun readInt64(): Long { - checkRemainingBytes(Long.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long - position += Long.SIZE_BYTES - return result - } - - fun readSingle(): Float { - checkRemainingBytes(Float.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float - position += Float.SIZE_BYTES - return result - } - - fun readByte(): Byte { - checkRemainingBytes(Byte.SIZE_BYTES) - return data[position++] - } - - fun readBytes(length: Int): ByteArray { - checkRemainingBytes(length) - return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) } - .also { position += length } - } - - fun readUInt16(): UShort { - checkRemainingBytes(Short.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort() - position += Short.SIZE_BYTES - return result - } - - fun readUInt32(): UInt { - checkRemainingBytes(Int.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt() - position += Int.SIZE_BYTES - return result - } - - fun readUInt64(): ULong { - checkRemainingBytes(Long.SIZE_BYTES) - val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong() - position += Long.SIZE_BYTES - return result - } - - fun readString(): String { - val length = data[position++].toInt() - checkRemainingBytes(length) - return String(data, position, length, StandardCharsets.UTF_8).also { position += length } - } - - private fun checkRemainingBytes(requiredBytes: Int) { - if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException() - } - - fun readRPRecord(): RPRecord { - return RPRecord(readDomainName(), readDomainName()) - } - - fun readKXRecord(): KXRecord { - val preference = readUInt16() - val exchanger = readDomainName() - return KXRecord(preference, exchanger) - } - - fun readCERTRecord(): CERTRecord { - val type = readUInt16() - val keyTag = readUInt16() - val algorithm = readByte() - val certificateLength = readUInt16().toInt() - 5 - val certificate = readBytes(certificateLength) - return CERTRecord(type, keyTag, algorithm, certificate) - } - - fun readPTRRecord(): PTRRecord { - return PTRRecord(readDomainName()) - } - - fun readARecord(): ARecord { - val address = readBytes(4) - return ARecord(InetAddress.getByAddress(address)) - } - - fun readAAAARecord(): AAAARecord { - val address = readBytes(16) - return AAAARecord(InetAddress.getByAddress(address)) - } - - fun readMXRecord(): MXRecord { - val preference = readUInt16() - val exchange = readDomainName() - return MXRecord(preference, exchange) - } - - fun readCNAMERecord(): CNAMERecord { - return CNAMERecord(readDomainName()) - } - - fun readTXTRecord(): TXTRecord { - val texts = mutableListOf() - while (position < endPosition) { - val textLength = data[position++].toInt() - checkRemainingBytes(textLength) - val text = String(data, position, textLength, StandardCharsets.UTF_8) - texts.add(text) - position += textLength - } - return TXTRecord(texts) - } - - fun readSOARecord(): SOARecord { - val primaryNameServer = readDomainName() - val responsibleAuthorityMailbox = readDomainName() - val serialNumber = readInt32() - val refreshInterval = readInt32() - val retryInterval = readInt32() - val expiryLimit = readInt32() - val minimumTTL = readInt32() - return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL) - } - - fun readSRVRecord(): SRVRecord { - val priority = readUInt16() - val weight = readUInt16() - val port = readUInt16() - val target = readDomainName() - return SRVRecord(priority, weight, port, target) - } - - fun readNSRecord(): NSRecord { - return NSRecord(readDomainName()) - } - - fun readCAARecord(): CAARecord { - val length = readUInt16().toInt() - val flags = readByte() - val tagLength = readByte().toInt() - val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength } - val valueLength = length - 1 - 1 - tagLength - val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength } - return CAARecord(flags, tag, value) - } - - fun readHINFORecord(): HINFORecord { - val cpuLength = readByte().toInt() - val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength } - val osLength = readByte().toInt() - val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength } - return HINFORecord(cpu, os) - } - - fun readAFSDBRecord(): AFSDBRecord { - return AFSDBRecord(readUInt16(), readDomainName()) - } - - fun readLOCRecord(): LOCRecord { - val version = readByte() - val size = LOCRecord.decodeSizeOrPrecision(readByte()) - val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) - val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte()) - val latitudeCoded = readInt32() - val longitudeCoded = readInt32() - val altitudeCoded = readInt32() - val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded) - val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded) - val altitude = LOCRecord.decodeAltitude(altitudeCoded) - return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude) - } - - fun readNAPTRRecord(): NAPTRRecord { - val order = readUInt16() - val preference = readUInt16() - val flags = readString() - val services = readString() - val regexp = readString() - val replacement = readDomainName() - return NAPTRRecord(order, preference, flags, services, regexp, replacement) - } - - fun readDNAMERecord(): DNAMERecord { - return DNAMERecord(readDomainName()) - } - - fun readDSRecord(): DSRecord { - val keyTag = readUInt16() - val algorithm = readByte() - val digestType = readByte() - val digestLength = readUInt16().toInt() - 4 - val digest = readBytes(digestLength) - return DSRecord(keyTag, algorithm, digestType, digest) - } - - fun readSSHFPRecord(): SSHFPRecord { - val algorithm = readByte() - val fingerprintType = readByte() - val fingerprintLength = readUInt16().toInt() - 2 - val fingerprint = readBytes(fingerprintLength) - return SSHFPRecord(algorithm, fingerprintType, fingerprint) - } - - fun readTLSARecord(): TLSARecord { - val usage = readByte() - val selector = readByte() - val matchingType = readByte() - val dataLength = readUInt16().toInt() - 3 - val certificateAssociationData = readBytes(dataLength) - return TLSARecord(usage, selector, matchingType, certificateAssociationData) - } - - fun readSMIMEARecord(): SMIMEARecord { - val usage = readByte() - val selector = readByte() - val matchingType = readByte() - val dataLength = readUInt16().toInt() - 3 - val certificateAssociationData = readBytes(dataLength) - return SMIMEARecord(usage, selector, matchingType, certificateAssociationData) - } - - fun readURIRecord(): URIRecord { - val priority = readUInt16() - val weight = readUInt16() - val length = readUInt16().toInt() - val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length } - return URIRecord(priority, weight, target) - } - - fun readRRSIGRecord(): RRSIGRecord { - val typeCovered = readUInt16() - val algorithm = readByte() - val labels = readByte() - val originalTTL = readUInt32() - val signatureExpiration = readUInt32() - val signatureInception = readUInt32() - val keyTag = readUInt16() - val signersName = readDomainName() - val signatureLength = readUInt16().toInt() - val signature = readBytes(signatureLength) - return RRSIGRecord( - typeCovered, - algorithm, - labels, - originalTTL, - signatureExpiration, - signatureInception, - keyTag, - signersName, - signature - ) - } - - fun readNSECRecord(): NSECRecord { - val ownerName = readDomainName() - val typeBitMaps = mutableListOf>() - while (position < endPosition) { - val windowBlock = readByte() - val bitmapLength = readByte().toInt() - val bitmap = readBytes(bitmapLength) - typeBitMaps.add(windowBlock to bitmap) - } - return NSECRecord(ownerName, typeBitMaps) - } - - fun readNSEC3Record(): NSEC3Record { - val hashAlgorithm = readByte() - val flags = readByte() - val iterations = readUInt16() - val saltLength = readByte().toInt() - val salt = readBytes(saltLength) - val hashLength = readByte().toInt() - val nextHashedOwnerName = readBytes(hashLength) - val bitMapLength = readUInt16().toInt() - val typeBitMaps = mutableListOf() - val endPos = position + bitMapLength - while (position < endPos) { - typeBitMaps.add(readUInt16()) - } - return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps) - } - - fun readNSEC3PARAMRecord(): NSEC3PARAMRecord { - val hashAlgorithm = readByte() - val flags = readByte() - val iterations = readUInt16() - val saltLength = readByte().toInt() - val salt = readBytes(saltLength) - return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt) - } - - - fun readSPFRecord(): SPFRecord { - val length = readUInt16().toInt() - val texts = mutableListOf() - val endPos = position + length - while (position < endPos) { - val textLength = readByte().toInt() - val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength } - texts.add(text) - } - return SPFRecord(texts) - } - - fun readTKEYRecord(): TKEYRecord { - val algorithm = readDomainName() - val inception = readUInt32() - val expiration = readUInt32() - val mode = readUInt16() - val error = readUInt16() - val keySize = readUInt16().toInt() - val keyData = readBytes(keySize) - val otherSize = readUInt16().toInt() - val otherData = readBytes(otherSize) - return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData) - } - - fun readTSIGRecord(): TSIGRecord { - val algorithmName = readDomainName() - val timeSigned = readUInt32() - val fudge = readUInt16() - val macSize = readUInt16().toInt() - val mac = readBytes(macSize) - val originalID = readUInt16() - val error = readUInt16() - val otherSize = readUInt16().toInt() - val otherData = readBytes(otherSize) - return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData) - } - - - - fun readOPTRecord(): OPTRecord { - val options = mutableListOf() - while (position < endPosition) { - val optionCode = readUInt16() - val optionLength = readUInt16().toInt() - val optionData = readBytes(optionLength) - options.add(OPTRecordOption(optionCode, optionData)) - } - return OPTRecord(options) - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt deleted file mode 100644 index 87ec0e5f..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.mdns.Extensions.readDomainName - -enum class ResourceRecordType(val value: UShort) { - None(0u), - A(1u), - NS(2u), - MD(3u), - MF(4u), - CNAME(5u), - SOA(6u), - MB(7u), - MG(8u), - MR(9u), - NULL(10u), - WKS(11u), - PTR(12u), - HINFO(13u), - MINFO(14u), - MX(15u), - TXT(16u), - RP(17u), - AFSDB(18u), - SIG(24u), - KEY(25u), - AAAA(28u), - LOC(29u), - SRV(33u), - NAPTR(35u), - KX(36u), - CERT(37u), - DNAME(39u), - APL(42u), - DS(43u), - SSHFP(44u), - IPSECKEY(45u), - RRSIG(46u), - NSEC(47u), - DNSKEY(48u), - DHCID(49u), - NSEC3(50u), - NSEC3PARAM(51u), - TSLA(52u), - SMIMEA(53u), - HIP(55u), - CDS(59u), - CDNSKEY(60u), - OPENPGPKEY(61u), - CSYNC(62u), - ZONEMD(63u), - SVCB(64u), - HTTPS(65u), - EUI48(108u), - EUI64(109u), - TKEY(249u), - TSIG(250u), - URI(256u), - CAA(257u), - TA(32768u), - DLV(32769u), - AXFR(252u), - IXFR(251u), - OPT(41u) -} - -enum class ResourceRecordClass(val value: UShort) { - IN(1u), - CS(2u), - CH(3u), - HS(4u) -} - -data class DnsResourceRecord( - override val name: String, - override val type: Int, - override val clazz: Int, - val timeToLive: UInt, - val cacheFlush: Boolean, - val dataPosition: Int = -1, - val dataLength: Int = -1, - private val data: ByteArray? = null -) : DnsResourceRecordBase(name, type, clazz) { - - companion object { - fun parse(data: ByteArray, startPosition: Int): Pair { - val span = data.asUByteArray() - var position = startPosition - val name = span.readDomainName(position).also { position = it.second } - val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - position += 2 - val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or - (span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt() - position += 4 - val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort() - val rdposition = position + 2 - position += 2 + rdlength.toInt() - - return DnsResourceRecord( - name = name.first, - type = type.toInt(), - clazz = clazz.toInt() and 0b1111111_11111111, - timeToLive = ttl, - cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0, - dataPosition = rdposition, - dataLength = rdlength.toInt(), - data = data - ) to position - } - } - - fun getDataReader(): DnsReader { - return DnsReader(data!!, dataPosition, dataLength) - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt deleted file mode 100644 index 48a04580..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.futo.platformplayer.mdns - -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.charset.StandardCharsets - -class DnsWriter { - private val data = mutableListOf() - private val namePositions = mutableMapOf() - - fun toByteArray(): ByteArray = data.toByteArray() - - fun writePacket( - header: DnsPacketHeader, - questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null, - answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null, - authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null, - additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null - ) { - if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null) - throw Exception("When question count is given, question writer should also be given.") - if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null) - throw Exception("When answer count is given, answer writer should also be given.") - if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null) - throw Exception("When authority count is given, authority writer should also be given.") - if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null) - throw Exception("When additionals count is given, additional writer should also be given.") - - writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0) - - repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) } - repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) } - repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) } - repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) } - } - - fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) { - write(header.identifier) - - var flags: UShort = 0u - flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort() - flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort() - flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort() - flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort() - flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort() - flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort() - flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort() - flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort() - flags = flags or header.responseCode.value.toUShort() - write(flags) - - write(questionCount.toUShort()) - write(answerCount.toUShort()) - write(authorityCount.toUShort()) - write(additionalsCount.toUShort()) - } - - fun writeDomainName(name: String) { - synchronized(namePositions) { - val labels = name.split('.') - for (label in labels) { - val nameAtOffset = name.substring(name.indexOf(label)) - if (namePositions.containsKey(nameAtOffset)) { - val position = namePositions[nameAtOffset]!! - val pointer = (0b11000000_00000000 or position).toUShort() - write(pointer) - return - } - if (label.isNotEmpty()) { - val labelBytes = label.toByteArray(StandardCharsets.UTF_8) - val nameStartPos = data.size - write(labelBytes.size.toByte()) - write(labelBytes) - namePositions[nameAtOffset] = nameStartPos - } - } - write(0.toByte()) - } - } - - fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) { - writeDomainName(value.name) - write(value.type.toUShort()) - val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort() - write(cls) - write(value.timeToLive) - - val lengthOffset = data.size - write(0.toUShort()) - dataWriter(this) - val rdLength = data.size - lengthOffset - 2 - val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array() - data[lengthOffset] = rdLengthBytes[0] - data[lengthOffset + 1] = rdLengthBytes[1] - } - - fun write(value: DnsQuestion) { - writeDomainName(value.name) - write(value.type.toUShort()) - write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort())) - } - - fun write(value: Double) { - val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array() - write(bytes) - } - - fun write(value: Short) { - val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array() - write(bytes) - } - - fun write(value: Int) { - val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array() - write(bytes) - } - - fun write(value: Long) { - val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array() - write(bytes) - } - - fun write(value: Float) { - val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array() - write(bytes) - } - - fun write(value: Byte) { - data.add(value) - } - - fun write(value: ByteArray) { - data.addAll(value.asIterable()) - } - - fun write(value: ByteArray, offset: Int, length: Int) { - data.addAll(value.slice(offset until offset + length)) - } - - fun write(value: UShort) { - val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array() - write(bytes) - } - - fun write(value: UInt) { - val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array() - write(bytes) - } - - fun write(value: ULong) { - val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array() - write(bytes) - } - - fun write(value: String) { - val bytes = value.toByteArray(StandardCharsets.UTF_8) - write(bytes.size.toByte()) - write(bytes) - } - - fun write(value: PTRRecord) { - writeDomainName(value.domainName) - } - - fun write(value: ARecord) { - val bytes = value.address.address - if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.") - write(bytes) - } - - fun write(value: AAAARecord) { - val bytes = value.address.address - if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.") - write(bytes) - } - - fun write(value: TXTRecord) { - value.texts.forEach { - val bytes = it.toByteArray(StandardCharsets.UTF_8) - write(bytes.size.toByte()) - write(bytes) - } - } - - fun write(value: SRVRecord) { - write(value.priority) - write(value.weight) - write(value.port) - writeDomainName(value.target) - } - - fun write(value: NSECRecord) { - writeDomainName(value.ownerName) - value.typeBitMaps.forEach { (windowBlock, bitmap) -> - write(windowBlock) - write(bitmap.size.toByte()) - write(bitmap) - } - } - - fun write(value: OPTRecord) { - value.options.forEach { option -> - write(option.code) - write(option.data.size.toUShort()) - write(option.data) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt deleted file mode 100644 index 48bb4c6a..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.futo.platformplayer.mdns - -import android.util.Log - -object Extensions { - fun ByteArray.toByteDump(): String { - val result = StringBuilder() - for (i in indices) { - result.append(String.format("%02X ", this[i])) - - if ((i + 1) % 16 == 0 || i == size - 1) { - val padding = 3 * (16 - (i % 16 + 1)) - if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding)) - - result.append("; ") - val start = i - (i % 16) - val end = minOf(i, size - 1) - for (j in start..end) { - val ch = if (this[j] in 32..127) this[j].toChar() else '.' - result.append(ch) - } - if (i != size - 1) result.appendLine() - } - } - return result.toString() - } - - fun UByteArray.readDomainName(startPosition: Int): Pair { - var position = startPosition - return readDomainName(position, 0) - } - - private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair { - if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.") - - val domainParts = mutableListOf() - var newPosition = position - - while (true) { - if (newPosition < 0) - println() - - val length = this[newPosition].toUByte() - if ((length and 0b11000000u).toUInt() == 0b11000000u) { - val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt() - val (part, _) = this.readDomainName(offset.toInt(), depth + 1) - domainParts.add(part) - newPosition += 2 - break - } else if (length.toUInt() == 0u) { - newPosition++ - break - } else { - newPosition++ - val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8) - domainParts.add(part) - newPosition += length.toInt() - } - } - - return domainParts.joinToString(".") to newPosition - } -} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt deleted file mode 100644 index b8ef3eea..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt +++ /dev/null @@ -1,501 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.logging.Logger -import kotlinx.coroutines.* -import java.net.* -import java.util.* -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - - -class MDNSListener { - companion object { - private val TAG = "MDNSListener" - const val MulticastPort = 5353 - val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251") - val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB") - val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort) - val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort) - } - - private val _lockObject = ReentrantLock() - private var _receiver4: MulticastSocket? = null - private var _receiver6: MulticastSocket? = null - private val _senders = mutableListOf() - private val _nicMonitor = NICMonitor() - private val _serviceRecordAggregator = ServiceRecordAggregator() - private var _started = false - private var _threadReceiver4: Thread? = null - private var _threadReceiver6: Thread? = null - private var _scope: CoroutineScope? = null - - var onPacket: ((DnsPacket) -> Unit)? = null - var onServicesUpdated: ((List) -> Unit)? = null - - private val _recordLockObject = ReentrantLock() - private val _recordsA = mutableListOf>() - private val _recordsAAAA = mutableListOf>() - private val _recordsPTR = mutableListOf>() - private val _recordsTXT = mutableListOf>() - private val _recordsSRV = mutableListOf>() - private val _services = mutableListOf() - - init { - _nicMonitor.added = { onNicsAdded(it) } - _nicMonitor.removed = { onNicsRemoved(it) } - _serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) } - } - - fun start() { - if (_started) { - Logger.i(TAG, "Already started.") - return - } - _started = true - - _scope = CoroutineScope(Dispatchers.IO); - - Logger.i(TAG, "Starting") - _lockObject.withLock { - val receiver4 = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort)) - } - - _receiver4 = receiver4 - - val receiver6 = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort)) - } - _receiver6 = receiver6 - - _nicMonitor.start() - _serviceRecordAggregator.start() - onNicsAdded(_nicMonitor.current) - - _threadReceiver4 = Thread { - receiveLoop(receiver4) - }.apply { start() } - - _threadReceiver6 = Thread { - receiveLoop(receiver6) - }.apply { start() } - } - } - - fun queryServices(names: Array) { - if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") - - val writer = DnsWriter() - writer.writePacket( - DnsPacketHeader( - identifier = 0u, - queryResponse = QueryResponse.Query.value.toInt(), - opcode = DnsOpcode.StandardQuery.value.toInt(), - truncated = false, - nonAuthenticatedData = false, - recursionDesired = false, - answerAuthenticated = false, - authoritativeAnswer = false, - recursionAvailable = false, - responseCode = DnsResponseCode.NoError - ), - questionCount = names.size, - questionWriter = { w, i -> - w.write( - DnsQuestion( - name = names[i], - type = QuestionType.PTR.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ) - ) - } - ) - - send(writer.toByteArray()) - } - - private fun send(data: ByteArray) { - _lockObject.withLock { - for (sender in _senders) { - try { - val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6 - sender.send(DatagramPacket(data, data.size, endPoint)) - } catch (e: Exception) { - Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.") - } - } - } - } - - fun queryAllQuestions(names: Array) { - if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") - - val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) } - questions.groupBy { it.name }.forEach { (_, questionsForHost) -> - val writer = DnsWriter() - writer.writePacket( - DnsPacketHeader( - identifier = 0u, - queryResponse = QueryResponse.Query.value.toInt(), - opcode = DnsOpcode.StandardQuery.value.toInt(), - truncated = false, - nonAuthenticatedData = false, - recursionDesired = false, - answerAuthenticated = false, - authoritativeAnswer = false, - recursionAvailable = false, - responseCode = DnsResponseCode.NoError - ), - questionCount = questionsForHost.size, - questionWriter = { w, i -> w.write(questionsForHost[i]) } - ) - send(writer.toByteArray()) - } - } - - private fun onNicsAdded(nics: List) { - _lockObject.withLock { - if (!_started) return - - val addresses = nics.flatMap { nic -> - nic.interfaceAddresses.map { it.address } - .filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) } - } - - addresses.forEach { address -> - Logger.i(TAG, "New address discovered $address") - - try { - when (address) { - is Inet4Address -> { - _receiver4?.let { receiver4 -> - //receiver4.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) - val ni = NetworkInterface.getByInetAddress(address) - receiver4.networkInterface = ni - receiver4.joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), ni) - } - - val sender = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(address, MulticastPort)) - joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address)) - } - _senders.add(sender) - } - - is Inet6Address -> { - _receiver6?.let { receiver6 -> - //receiver6.setOption(StandardSocketOptions.IP_MULTICAST_IF, NetworkInterface.getByInetAddress(address)) - val ni = NetworkInterface.getByInetAddress(address) - receiver6.networkInterface = ni - receiver6.joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), ni) - } - - val sender = MulticastSocket(null).apply { - reuseAddress = true - bind(InetSocketAddress(address, MulticastPort)) - joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address)) - } - _senders.add(sender) - } - - else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.") - } - } catch (e: Exception) { - Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.") - // Close the socket if there was an error - (_senders.lastOrNull() as? MulticastSocket)?.close() - } - } - } - - if (nics.isNotEmpty()) { - try { - updateBroadcastRecords() - broadcastRecords() - } catch (e: Exception) { - Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.") - } - } - } - - private fun onNicsRemoved(nics: List) { - _lockObject.withLock { - if (!_started) return - //TODO: Cleanup? - } - - if (nics.isNotEmpty()) { - try { - updateBroadcastRecords() - broadcastRecords() - } catch (e: Exception) { - Logger.e(TAG, "Exception occurred when broadcasting records", e) - } - } - } - - private fun receiveLoop(client: DatagramSocket) { - Logger.i(TAG, "Started receive loop") - - val buffer = ByteArray(8972) - val packet = DatagramPacket(buffer, buffer.size) - while (_started) { - try { - client.receive(packet) - handleResult(packet) - } catch (e: Exception) { - Logger.e(TAG, "An exception occurred while handling UDP result:", e) - } - } - - Logger.i(TAG, "Stopped receive loop") - } - - fun broadcastService( - deviceName: String, - serviceName: String, - port: UShort, - ttl: UInt = 120u, - weight: UShort = 0u, - priority: UShort = 0u, - texts: List? = null - ) { - _recordLockObject.withLock { - _services.add( - BroadcastService( - deviceName = deviceName, - port = port, - priority = priority, - serviceName = serviceName, - texts = texts, - ttl = ttl, - weight = weight - ) - ) - } - - updateBroadcastRecords() - broadcastRecords() - } - - private fun updateBroadcastRecords() { - _recordLockObject.withLock { - _recordsSRV.clear() - _recordsPTR.clear() - _recordsA.clear() - _recordsAAAA.clear() - _recordsTXT.clear() - - _services.forEach { service -> - val id = UUID.randomUUID().toString() - val deviceDomainName = "${service.deviceName}.${service.serviceName}" - val addressName = "$id.local" - - _recordsSRV.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.SRV.value.toInt(), - timeToLive = service.ttl, - name = deviceDomainName, - cacheFlush = false - ) to SRVRecord( - target = addressName, - port = service.port, - priority = service.priority, - weight = service.weight - ) - ) - - _recordsPTR.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.PTR.value.toInt(), - timeToLive = service.ttl, - name = service.serviceName, - cacheFlush = false - ) to PTRRecord( - domainName = deviceDomainName - ) - ) - - val addresses = _nicMonitor.current.flatMap { nic -> - nic.interfaceAddresses.map { it.address } - } - - addresses.forEach { address -> - when (address) { - is Inet4Address -> _recordsA.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.A.value.toInt(), - timeToLive = service.ttl, - name = addressName, - cacheFlush = false - ) to ARecord( - address = address - ) - ) - - is Inet6Address -> _recordsAAAA.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.AAAA.value.toInt(), - timeToLive = service.ttl, - name = addressName, - cacheFlush = false - ) to AAAARecord( - address = address - ) - ) - - else -> Logger.i(TAG, "Invalid address type: $address.") - } - } - - if (service.texts != null) { - _recordsTXT.add( - DnsResourceRecord( - clazz = ResourceRecordClass.IN.value.toInt(), - type = ResourceRecordType.TXT.value.toInt(), - timeToLive = service.ttl, - name = deviceDomainName, - cacheFlush = false - ) to TXTRecord( - texts = service.texts - ) - ) - } - } - } - } - - private fun broadcastRecords(questions: List? = null) { - val writer = DnsWriter() - _recordLockObject.withLock { - val recordsA: List> - val recordsAAAA: List> - val recordsPTR: List> - val recordsTXT: List> - val recordsSRV: List> - - if (questions != null) { - recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } } - } else { - recordsA = _recordsA - recordsAAAA = _recordsAAAA - recordsPTR = _recordsPTR - recordsSRV = _recordsSRV - recordsTXT = _recordsTXT - } - - val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size - if (answerCount < 1) return - - val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size - val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size - val ptrOffset = recordsA.size + recordsAAAA.size - val aaaaOffset = recordsA.size - - writer.writePacket( - DnsPacketHeader( - identifier = 0u, - queryResponse = QueryResponse.Response.value.toInt(), - opcode = DnsOpcode.StandardQuery.value.toInt(), - truncated = false, - nonAuthenticatedData = false, - recursionDesired = false, - answerAuthenticated = false, - authoritativeAnswer = true, - recursionAvailable = false, - responseCode = DnsResponseCode.NoError - ), - answerCount = answerCount, - answerWriter = { w, i -> - when { - i >= txtOffset -> { - val record = recordsTXT[i - txtOffset] - w.write(record.first) { it.write(record.second) } - } - - i >= srvOffset -> { - val record = recordsSRV[i - srvOffset] - w.write(record.first) { it.write(record.second) } - } - - i >= ptrOffset -> { - val record = recordsPTR[i - ptrOffset] - w.write(record.first) { it.write(record.second) } - } - - i >= aaaaOffset -> { - val record = recordsAAAA[i - aaaaOffset] - w.write(record.first) { it.write(record.second) } - } - - else -> { - val record = recordsA[i] - w.write(record.first) { it.write(record.second) } - } - } - } - ) - } - - send(writer.toByteArray()) - } - - private fun handleResult(result: DatagramPacket) { - try { - val packet = DnsPacket.parse(result.data) - if (packet.questions.isNotEmpty()) { - _scope?.launch(Dispatchers.IO) { - try { - broadcastRecords(packet.questions) - } catch (e: Throwable) { - Logger.i(TAG, "Broadcasting records failed", e) - } - } - - } - _serviceRecordAggregator.add(packet) - onPacket?.invoke(packet) - } catch (e: Exception) { - Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e) - } - } - - fun stop() { - _lockObject.withLock { - _started = false - - _scope?.cancel() - _scope = null - - _nicMonitor.stop() - _serviceRecordAggregator.stop() - - _receiver4?.close() - _receiver4 = null - - _receiver6?.close() - _receiver6 = null - - _senders.forEach { it.close() } - _senders.clear() - } - - _threadReceiver4?.join() - _threadReceiver4 = null - - _threadReceiver6?.join() - _threadReceiver6 = null - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt deleted file mode 100644 index 884e1514..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.futo.platformplayer.mdns - -import kotlinx.coroutines.* -import java.net.NetworkInterface - -class NICMonitor { - private val lockObject = Any() - private val nics = mutableListOf() - private var cts: Job? = null - - val current: List - get() = synchronized(nics) { nics.toList() } - - var added: ((List) -> Unit)? = null - var removed: ((List) -> Unit)? = null - - fun start() { - synchronized(lockObject) { - if (cts != null) throw Exception("Already started.") - - cts = CoroutineScope(Dispatchers.Default).launch { - loopAsync() - } - } - - nics.clear() - nics.addAll(getCurrentInterfaces().toList()) - } - - fun stop() { - synchronized(lockObject) { - cts?.cancel() - cts = null - } - - synchronized(nics) { - nics.clear() - } - } - - private suspend fun loopAsync() { - while (cts?.isActive == true) { - try { - val currentNics = getCurrentInterfaces().toList() - removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } }) - added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } }) - - synchronized(nics) { - nics.clear() - nics.addAll(currentNics) - } - } catch (ex: Exception) { - // Ignored - } - delay(5000) - } - } - - private fun getCurrentInterfaces(): List { - val nics = NetworkInterface.getNetworkInterfaces().toList() - .filter { it.isUp && !it.isLoopback } - - return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList() - .filter { it.isUp } - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt deleted file mode 100644 index f4a3e5e9..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.logging.Logger -import java.lang.Thread.sleep - -class ServiceDiscoverer(names: Array, private val _onServicesUpdated: (List) -> Unit) { - private val _names: Array - private var _listener: MDNSListener? = null - private var _started = false - private var _thread: Thread? = null - - init { - if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.") - _names = names - } - - fun broadcastService( - deviceName: String, - serviceName: String, - port: UShort, - ttl: UInt = 120u, - weight: UShort = 0u, - priority: UShort = 0u, - texts: List? = null - ) { - _listener?.let { - it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts) - } - } - - fun stop() { - _started = false - _listener?.stop() - _listener = null - _thread?.join() - _thread = null - } - - fun start() { - if (_started) { - Logger.i(TAG, "Already started.") - return - } - _started = true - - val listener = MDNSListener() - _listener = listener - listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) } - listener.start() - - _thread = Thread { - try { - sleep(2000) - - while (_started) { - listener.queryServices(_names) - sleep(2000) - listener.queryAllQuestions(_names) - sleep(2000) - } - } catch (e: Throwable) { - Logger.i(TAG, "Exception in loop thread", e) - stop() - } - }.apply { start() } - } - - companion object { - private val TAG = "ServiceDiscoverer" - } -} diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt deleted file mode 100644 index 5292d375..00000000 --- a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.futo.platformplayer.mdns - -import com.futo.platformplayer.logging.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.net.InetAddress -import java.util.Date - -data class DnsService( - var name: String, - var target: String, - var port: UShort, - val addresses: MutableList = mutableListOf(), - val pointers: MutableList = mutableListOf(), - val texts: MutableList = mutableListOf() -) - -data class CachedDnsAddressRecord( - val expirationTime: Date, - val address: InetAddress -) - -data class CachedDnsTxtRecord( - val expirationTime: Date, - val texts: List -) - -data class CachedDnsPtrRecord( - val expirationTime: Date, - val target: String -) - -data class CachedDnsSrvRecord( - val expirationTime: Date, - val service: SRVRecord -) - -class ServiceRecordAggregator { - private val _lockObject = Any() - private val _cachedAddressRecords = mutableMapOf>() - private val _cachedTxtRecords = mutableMapOf() - private val _cachedPtrRecords = mutableMapOf>() - private val _cachedSrvRecords = mutableMapOf() - private val _currentServices = mutableListOf() - private var _cts: Job? = null - - var onServicesUpdated: ((List) -> Unit)? = null - - fun start() { - synchronized(_lockObject) { - if (_cts != null) throw Exception("Already started.") - - _cts = CoroutineScope(Dispatchers.Default).launch { - try { - while (isActive) { - val now = Date() - synchronized(_currentServices) { - _cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } - _cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) } - _cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) } - _cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } } - - val newServices = getCurrentServices() - _currentServices.clear() - _currentServices.addAll(newServices) - } - - onServicesUpdated?.invoke(_currentServices.toList()) - delay(5000) - } - } catch (e: Throwable) { - Logger.e(TAG, "Unexpected failure in MDNS loop", e) - } - } - } - } - - fun stop() { - synchronized(_lockObject) { - _cts?.cancel() - _cts = null - } - } - - fun add(packet: DnsPacket) { - val currentServices: List - val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities - val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() } - val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() } - val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() } - val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() } - val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() } - - /*val builder = StringBuilder() - builder.appendLine("Received records:") - srvRecords.forEach { builder.appendLine("SRV ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") } - ptrRecords.forEach { builder.appendLine("PTR ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") } - txtRecords.forEach { builder.appendLine("TXT ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") } - aRecords.forEach { builder.appendLine("A ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } - aaaaRecords.forEach { builder.appendLine("AAAA ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") } - Logger.i(TAG, "$builder")*/ - - synchronized(this._currentServices) { - ptrRecords.forEach { record -> - val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() } - val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName) - cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName } - } - - aRecords.forEach { aRecord -> - val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() } - val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address) - cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address } - } - - aaaaRecords.forEach { aaaaRecord -> - val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() } - val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address) - cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address } - } - - txtRecords.forEach { txtRecord -> - _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts) - } - - srvRecords.forEach { srvRecord -> - _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second) - } - - currentServices = getCurrentServices() - this._currentServices.clear() - this._currentServices.addAll(currentServices) - } - - onServicesUpdated?.invoke(currentServices) - } - - fun getAllQuestions(serviceName: String): List { - val questions = mutableListOf() - synchronized(_currentServices) { - val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList() - - val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target } - questions.addAll(ptrWithoutSrvRecord.flatMap { s -> - listOf( - DnsQuestion( - name = s, - type = QuestionType.SRV.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ) - ) - }) - - val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) } - questions.addAll(incompleteCurrentServices.flatMap { s -> - listOf( - DnsQuestion( - name = s.name, - type = QuestionType.TXT.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ), - DnsQuestion( - name = s.target, - type = QuestionType.A.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ), - DnsQuestion( - name = s.target, - type = QuestionType.AAAA.value.toInt(), - clazz = QuestionClass.IN.value.toInt(), - queryUnicast = false - ) - ) - }) - } - return questions - } - - private fun getCurrentServices(): MutableList { - val currentServices = _cachedSrvRecords.map { (key, value) -> - DnsService( - name = key, - target = value.service.target, - port = value.service.port - ) - }.toMutableList() - - currentServices.forEach { service -> - _cachedAddressRecords[service.target]?.let { - service.addresses.addAll(it.map { record -> record.address }) - } - } - - currentServices.forEach { service -> - service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key }) - } - - currentServices.forEach { service -> - _cachedTxtRecords[service.name]?.let { - service.texts.addAll(it.texts) - } - } - - return currentServices - } - - private inline fun MutableList.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) { - val index = indexOfFirst(predicate) - if (index >= 0) { - this[index] = newElement - } else { - add(newElement) - } - } - - private companion object { - private const val TAG = "ServiceRecordAggregator" - } -} 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 bdad1395..d15a604a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -411,7 +411,7 @@ class StateApp { } if (Settings.instance.synchronization.enabled) { - StateSync.instance.start() + StateSync.instance.start(context) } Logger.onLogSubmitted.subscribe { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 7e5d02e9..6bbd0c7d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -1,5 +1,8 @@ package com.futo.platformplayer.states +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo import android.os.Build import android.util.Log import com.futo.platformplayer.LittleEndianDataInputStream @@ -9,14 +12,14 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity import com.futo.platformplayer.api.media.Serializer +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.casting.StateCasting.Companion import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.encryption.GEncryptionProvider import com.futo.platformplayer.generateReadablePassword import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.logging.Logger -import com.futo.platformplayer.mdns.DnsService -import com.futo.platformplayer.mdns.ServiceDiscoverer import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.noise.protocol.DHState @@ -81,12 +84,30 @@ class StateSync { //TODO: Should sync mdns and casting mdns be merged? //TODO: Decrease interval that devices are updated //TODO: Send less data - private val _serviceDiscoverer = ServiceDiscoverer(arrayOf("_gsync._tcp.local")) { handleServiceUpdated(it) } + private val _pairingCode: String? = generateReadablePassword(8) val pairingCode: String? get() = _pairingCode private var _relaySession: SyncSocketSession? = null private var _threadRelay: Thread? = null private val _remotePendingStatusUpdate = mutableMapOf Unit>() + private var _nsdManager: NsdManager? = null + private val _registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") + } + } var keyPair: DHState? = null var publicKey: String? = null @@ -101,15 +122,116 @@ class StateSync { } } - fun start() { + fun start(context: Context) { if (_started) { Logger.i(TAG, "Already started.") return } _started = true + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager - if (Settings.instance.synchronization.broadcast || Settings.instance.synchronization.connectDiscovered) { - _serviceDiscoverer.start() + if (Settings.instance.synchronization.connectDiscovered) { + _nsdManager?.apply { + discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + _nsdManager?.stopServiceDiscovery(this) + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + _nsdManager?.stopServiceDiscovery(this) + } + + fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { + if (!Settings.instance.synchronization.connectDiscovered) { + return + } + + val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return + val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) + val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) + val authorized = isAuthorized(pkey) + + if (authorized && !isConnected(pkey)) { + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] = now + } + + Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") + + try { + connect(syncDeviceInfo) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to connect to $pkey", e) + } + } + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + arrayOf(service.host) + }, service.port, service.attributes) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) + } + }) + } + } + }) + } } try { @@ -142,7 +264,19 @@ class StateSync { } if (Settings.instance.synchronization.broadcast) { - publicKey?.let { _serviceDiscoverer.broadcastService(getDeviceName(), "_gsync._tcp.local", PORT.toUShort(), texts = arrayListOf("pk=${it.replace('+', '-').replace('/', '_').replace("=", "")}")) } + val pk = publicKey + val nsdManager = _nsdManager + + if (pk != null && nsdManager != null) { + val serviceInfo = NsdServiceInfo().apply { + serviceName = getDeviceName() + serviceType = "_gsync._tcp" + port = PORT + setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", "")) + } + + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener) + } } Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") @@ -318,7 +452,7 @@ class StateSync { override val isAuthorized: Boolean get() = true } - _relaySession!!.startAsInitiator(RELAY_PUBLIC_KEY, APP_ID, null) + _relaySession!!.runAsInitiator(RELAY_PUBLIC_KEY, APP_ID, null) Log.i(TAG, "Started relay session.") } catch (e: Throwable) { @@ -331,6 +465,8 @@ class StateSync { } }.apply { start() } } + + } private fun getDeviceName(): String { @@ -382,48 +518,6 @@ class StateSync { _syncSessionData.setAndSave(data.publicKey, data); } - private fun handleServiceUpdated(services: List) { - if (!Settings.instance.synchronization.connectDiscovered) { - return - } - - for (s in services) { - //TODO: Addresses IPv4 only? - val addresses = s.addresses.mapNotNull { it.hostAddress }.toTypedArray() - val port = s.port.toInt() - if (s.name.endsWith("._gsync._tcp.local")) { - val name = s.name.substring(0, s.name.length - "._gsync._tcp.local".length) - val urlSafePkey = s.texts.firstOrNull { it.startsWith("pk=") }?.substring("pk=".length) ?: continue - val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) - - val syncDeviceInfo = SyncDeviceInfo(pkey, addresses, port, null) - val authorized = isAuthorized(pkey) - - if (authorized && !isConnected(pkey)) { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] = now - } - - Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") - - try { - connect(syncDeviceInfo) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to $pkey", e) - } - } - } - } - } - } - private fun unauthorize(remotePublicKey: String) { Logger.i(TAG, "${remotePublicKey} unauthorized received") _authorizedDevices.remove(remotePublicKey) @@ -899,7 +993,7 @@ class StateSync { fun stop() { _started = false - _serviceDiscoverer.stop() + _nsdManager?.unregisterService(_registrationListener) _serverSocket?.close() _serverSocket = null diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index ad928698..e66da337 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -121,6 +121,19 @@ class SyncSocketSession { }.apply { start() } } + fun runAsInitiator(remotePublicKey: String, appId: UInt = 0u, pairingCode: String? = null) { + _started = true + try { + handshakeAsInitiator(remotePublicKey, appId, pairingCode) + _onHandshakeComplete?.invoke(this) + receiveLoop() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to run as initiator", e) + } finally { + stop() + } + } + fun startAsResponder() { _started = true _thread = Thread { From ee7b89ec6e3de793f8d22590d09680031ee10ae7 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 29 Apr 2025 15:22:06 +0200 Subject: [PATCH 069/128] Added new casting dialog. --- .../platformplayer/casting/StateCasting.kt | 28 +-- .../dialogs/ConnectCastingDialog.kt | 214 ++++++++++-------- .../stores/CastingDeviceInfoStorage.kt | 5 + .../views/adapters/DeviceAdapter.kt | 18 +- .../views/adapters/DeviceViewHolder.kt | 135 +++++------ app/src/main/res/drawable/keep_24px.xml | 9 + .../res/layout/dialog_casting_connect.xml | 190 ++++++++++------ app/src/main/res/layout/list_device.xml | 113 +++------ app/src/main/res/values/strings.xml | 3 + 9 files changed, 375 insertions(+), 340 deletions(-) create mode 100644 app/src/main/res/drawable/keep_24px.xml diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 865af56f..f25cd410 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -69,7 +69,6 @@ class StateCasting { private var _started = false; var devices: HashMap = hashMapOf(); - var rememberedDevices: ArrayList = arrayListOf(); val onDeviceAdded = Event1(); val onDeviceChanged = Event1(); val onDeviceRemoved = Event1(); @@ -156,9 +155,6 @@ class StateCasting { Logger.i(TAG, "CastingService starting..."); - rememberedDevices.clear(); - rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) }); - _castServer.start(); enableDeveloper(true); @@ -370,9 +366,6 @@ class StateCasting { invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; }; - addRememberedDevice(device); - Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.") - try { device.start(); } catch (e: Throwable) { @@ -394,21 +387,22 @@ class StateCasting { return addRememberedDevice(device); } + fun getRememberedCastingDevices(): List { + return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) } + } + + fun getRememberedCastingDeviceNames(): List { + return _storage.getDeviceNames() + } + fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { val deviceInfo = device.getDeviceInfo() - val foundInfo = _storage.addDevice(deviceInfo) - if (foundInfo == deviceInfo) { - rememberedDevices.add(device); - return foundInfo; - } - - return foundInfo; + return _storage.addDevice(deviceInfo) } fun removeRememberedDevice(device: CastingDevice) { - val name = device.name ?: return; - _storage.removeDevice(name); - rememberedDevices.remove(device); + val name = device.name ?: return + _storage.removeDevice(name) } private fun invokeInMainScopeIfRequired(action: () -> Unit){ diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt index 8f3b836c..f00bd191 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt @@ -9,7 +9,9 @@ import android.view.View import android.widget.Button import android.widget.ImageButton import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R @@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.views.adapters.DeviceAdapter +import com.futo.platformplayer.views.adapters.DeviceAdapterEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ConnectCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _imageLoader: ImageView; private lateinit var _buttonClose: Button; - private lateinit var _buttonAdd: ImageButton; - private lateinit var _buttonScanQR: ImageButton; + private lateinit var _buttonAdd: LinearLayout; + private lateinit var _buttonScanQR: LinearLayout; private lateinit var _textNoDevicesFound: TextView; - private lateinit var _textNoDevicesRemembered: TextView; private lateinit var _recyclerDevices: RecyclerView; - private lateinit var _recyclerRememberedDevices: RecyclerView; private lateinit var _adapter: DeviceAdapter; - private lateinit var _rememberedAdapter: DeviceAdapter; - private val _devices: ArrayList = arrayListOf(); - private val _rememberedDevices: ArrayList = arrayListOf(); + private val _devices: MutableSet = mutableSetOf() + private val _rememberedDevices: MutableSet = mutableSetOf() + private val _unifiedDevices: MutableList = mutableListOf() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState); @@ -45,42 +46,40 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { _imageLoader = findViewById(R.id.image_loader); _buttonClose = findViewById(R.id.button_close); _buttonAdd = findViewById(R.id.button_add); - _buttonScanQR = findViewById(R.id.button_scan_qr); + _buttonScanQR = findViewById(R.id.button_qr); _recyclerDevices = findViewById(R.id.recycler_devices); - _recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices); _textNoDevicesFound = findViewById(R.id.text_no_devices_found); - _textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered); - _adapter = DeviceAdapter(_devices, false); + _adapter = DeviceAdapter(_unifiedDevices) _recyclerDevices.adapter = _adapter; _recyclerDevices.layoutManager = LinearLayoutManager(context); - _rememberedAdapter = DeviceAdapter(_rememberedDevices, true); - _rememberedAdapter.onRemove.subscribe { d -> - if (StateCasting.instance.activeDevice == d) { - d.stopCasting(); + _adapter.onPin.subscribe { d -> + val isRemembered = _rememberedDevices.contains(d.name) + val newIsRemembered = !isRemembered + if (newIsRemembered) { + StateCasting.instance.addRememberedDevice(d) + val name = d.name + if (name != null) { + _rememberedDevices.add(name) + } + } else { + StateCasting.instance.removeRememberedDevice(d) + _rememberedDevices.remove(d.name) } - - StateCasting.instance.removeRememberedDevice(d); - val index = _rememberedDevices.indexOf(d); - if (index != -1) { - _rememberedDevices.removeAt(index); - _rememberedAdapter.notifyItemRemoved(index); - } - - _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; - }; - _rememberedAdapter.onConnect.subscribe { _ -> - dismiss() - //UIDialogs.showCastingDialog(context) + updateUnifiedList() } + + //TODO: Integrate remembered into the main list + //TODO: Add green indicator to indicate a device is oneline + //TODO: Add pinning + //TODO: Implement QR code as an option in add manually + //TODO: Remove start button + _adapter.onConnect.subscribe { _ -> dismiss() //UIDialogs.showCastingDialog(context) } - _recyclerRememberedDevices.adapter = _rememberedAdapter; - _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _buttonClose.setOnClickListener { dismiss(); }; _buttonAdd.setOnClickListener { @@ -105,77 +104,112 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) { Logger.i(TAG, "Dialog shown."); StateCasting.instance.startDiscovering() - (_imageLoader.drawable as Animatable?)?.start(); - _devices.clear(); - synchronized (StateCasting.instance.devices) { - _devices.addAll(StateCasting.instance.devices.values); + synchronized(StateCasting.instance.devices) { + _devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name }) } - _rememberedDevices.clear(); - synchronized (StateCasting.instance.rememberedDevices) { - _rememberedDevices.addAll(StateCasting.instance.rememberedDevices); + _rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames()) + updateUnifiedList() + + StateCasting.instance.onDeviceAdded.subscribe(this) { d -> + val name = d.name + if (name != null) + _devices.add(name) + updateUnifiedList() + } + + StateCasting.instance.onDeviceChanged.subscribe(this) { d -> + val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name } + if (index != -1) { + _unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice) + _adapter.notifyItemChanged(index) + } + } + + StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> + _devices.remove(d.name) + updateUnifiedList() + } + + StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> + if (connectionState == CastConnectionState.CONNECTED) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + dismiss() + } + } } _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; - _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; - - StateCasting.instance.onDeviceAdded.subscribe(this) { d -> - _devices.add(d); - _adapter.notifyItemInserted(_devices.size - 1); - _textNoDevicesFound.visibility = View.GONE; - _recyclerDevices.visibility = View.VISIBLE; - }; - - StateCasting.instance.onDeviceChanged.subscribe(this) { d -> - val index = _devices.indexOf(d); - if (index == -1) { - return@subscribe; - } - - _devices[index] = d; - _adapter.notifyItemChanged(index); - }; - - StateCasting.instance.onDeviceRemoved.subscribe(this) { d -> - val index = _devices.indexOf(d); - if (index == -1) { - return@subscribe; - } - - _devices.removeAt(index); - _adapter.notifyItemRemoved(index); - _textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE; - _recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE; - }; - - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> - if (connectionState != CastConnectionState.CONNECTED) { - return@subscribe; - } - - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - dismiss(); - }; - }; - - _adapter.notifyDataSetChanged(); - _rememberedAdapter.notifyDataSetChanged(); } override fun dismiss() { - super.dismiss(); - - (_imageLoader.drawable as Animatable?)?.stop(); - + super.dismiss() + (_imageLoader.drawable as Animatable?)?.stop() StateCasting.instance.stopDiscovering() - StateCasting.instance.onDeviceAdded.remove(this); - StateCasting.instance.onDeviceChanged.remove(this); - StateCasting.instance.onDeviceRemoved.remove(this); - StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); + StateCasting.instance.onDeviceAdded.remove(this) + StateCasting.instance.onDeviceChanged.remove(this) + StateCasting.instance.onDeviceRemoved.remove(this) + StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this) + } + + private fun updateUnifiedList() { + val oldList = ArrayList(_unifiedDevices) + val newList = buildUnifiedList() + + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize() = oldList.size + override fun getNewListSize() = newList.size + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.castingDevice.name == newItem.castingDevice.name + && oldItem.castingDevice.isReady == newItem.castingDevice.isReady + && oldItem.isOnlineDevice == newItem.isOnlineDevice + && oldItem.isPinnedDevice == newItem.isPinnedDevice + } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.castingDevice.name == newItem.castingDevice.name + && oldItem.castingDevice.isReady == newItem.castingDevice.isReady + && oldItem.isOnlineDevice == newItem.isOnlineDevice + && oldItem.isPinnedDevice == newItem.isPinnedDevice + } + }) + + _unifiedDevices.clear() + _unifiedDevices.addAll(newList) + diffResult.dispatchUpdatesTo(_adapter) + + _textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE + _recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE + } + + private fun buildUnifiedList(): List { + val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name } + val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name } + + val unifiedList = mutableListOf() + + val intersectionNames = _devices.intersect(_rememberedDevices) + for (name in intersectionNames) { + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) } + } + + val onlineOnlyNames = _devices - _rememberedDevices + for (name in onlineOnlyNames) { + onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) } + } + + val rememberedOnlyNames = _rememberedDevices - _devices + for (name in rememberedOnlyNames) { + rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) } + } + + return unifiedList } companion object { diff --git a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt index 3ecff95a..bddbe862 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt @@ -19,6 +19,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() { return deviceInfos.toList(); } + @Synchronized + fun getDeviceNames() : List { + return deviceInfos.map { it.name }.toList(); + } + @Synchronized fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo { val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt index 711a1675..a2ff2435 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceAdapter.kt @@ -7,16 +7,16 @@ import com.futo.platformplayer.R import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.constructs.Event1 -class DeviceAdapter : RecyclerView.Adapter { - private val _devices: ArrayList; - private val _isRememberedDevice: Boolean; +data class DeviceAdapterEntry(val castingDevice: CastingDevice, val isPinnedDevice: Boolean, val isOnlineDevice: Boolean) - var onRemove = Event1(); +class DeviceAdapter : RecyclerView.Adapter { + private val _devices: List; + + var onPin = Event1(); var onConnect = Event1(); - constructor(devices: ArrayList, isRememberedDevice: Boolean) : super() { + constructor(devices: List) : super() { _devices = devices; - _isRememberedDevice = isRememberedDevice; } override fun getItemCount() = _devices.size; @@ -24,13 +24,13 @@ class DeviceAdapter : RecyclerView.Adapter { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DeviceViewHolder { val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.list_device, viewGroup, false); val holder = DeviceViewHolder(view); - holder.setIsRememberedDevice(_isRememberedDevice); - holder.onRemove.subscribe { d -> onRemove.emit(d); }; + holder.onPin.subscribe { d -> onPin.emit(d); }; holder.onConnect.subscribe { d -> onConnect.emit(d); } return holder; } override fun onBindViewHolder(viewHolder: DeviceViewHolder, position: Int) { - viewHolder.bind(_devices[position]); + val p = _devices[position]; + viewHolder.bind(p.castingDevice, p.isOnlineDevice, p.isPinnedDevice); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index 3fa42219..d5060fe5 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -2,9 +2,11 @@ package com.futo.platformplayer.views.adapters import android.graphics.drawable.Animatable import android.view.View +import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.futo.platformplayer.R import com.futo.platformplayer.casting.AirPlayCastingDevice @@ -14,70 +16,62 @@ import com.futo.platformplayer.casting.ChromecastCastingDevice import com.futo.platformplayer.casting.FCastCastingDevice import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.constructs.Event2 +import androidx.core.view.isVisible class DeviceViewHolder : ViewHolder { + private val _layoutDevice: FrameLayout; private val _imageDevice: ImageView; private val _textName: TextView; private val _textType: TextView; private val _textNotReady: TextView; - private val _buttonDisconnect: LinearLayout; - private val _buttonConnect: LinearLayout; - private val _buttonRemove: LinearLayout; private val _imageLoader: ImageView; + private val _imageOnline: ImageView; + private val _root: ConstraintLayout; private var _animatableLoader: Animatable? = null; - private var _isRememberedDevice: Boolean = false; + private var _imagePin: ImageView; var device: CastingDevice? = null private set - var onRemove = Event1(); + var onPin = Event1(); val onConnect = Event1(); constructor(view: View) : super(view) { + _root = view.findViewById(R.id.layout_root); + _layoutDevice = view.findViewById(R.id.layout_device); _imageDevice = view.findViewById(R.id.image_device); _textName = view.findViewById(R.id.text_name); _textType = view.findViewById(R.id.text_type); _textNotReady = view.findViewById(R.id.text_not_ready); - _buttonDisconnect = view.findViewById(R.id.button_disconnect); - _buttonConnect = view.findViewById(R.id.button_connect); - _buttonRemove = view.findViewById(R.id.button_remove); _imageLoader = view.findViewById(R.id.image_loader); + _imageOnline = view.findViewById(R.id.image_online); + _imagePin = view.findViewById(R.id.image_pin); val d = _imageLoader.drawable; if (d is Animatable) { _animatableLoader = d; } - _buttonDisconnect.setOnClickListener { - StateCasting.instance.activeDevice?.stopCasting(); - updateButton(); - }; - - _buttonConnect.setOnClickListener { - val dev = device ?: return@setOnClickListener; - StateCasting.instance.activeDevice?.stopCasting(); - StateCasting.instance.connectDevice(dev); - onConnect.emit(dev); - }; - - _buttonRemove.setOnClickListener { - val dev = device ?: return@setOnClickListener; - onRemove.emit(dev); - }; - - StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ -> - updateButton(); + val connect = { + device?.let { dev -> + StateCasting.instance.activeDevice?.stopCasting(); + StateCasting.instance.connectDevice(dev); + onConnect.emit(dev); + } } - setIsRememberedDevice(false); + _textName.setOnClickListener { connect() }; + _textType.setOnClickListener { connect() }; + _layoutDevice.setOnClickListener { connect() }; + + _imagePin.setOnClickListener { + val dev = device ?: return@setOnClickListener; + onPin.emit(dev); + } } - fun setIsRememberedDevice(isRememberedDevice: Boolean) { - _isRememberedDevice = isRememberedDevice; - _buttonRemove.visibility = if (isRememberedDevice) View.VISIBLE else View.GONE; - } - - fun bind(d: CastingDevice) { + fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) { if (d is ChromecastCastingDevice) { _imageDevice.setImageResource(R.drawable.ic_chromecast); _textType.text = "Chromecast"; @@ -90,54 +84,47 @@ class DeviceViewHolder : ViewHolder { } _textName.text = d.name; - device = d; - updateButton(); - } - - private fun updateButton() { - val d = device ?: return; + _imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE if (!d.isReady) { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.GONE; _imageLoader.visibility = View.GONE; _textNotReady.visibility = View.VISIBLE; - return; - } - - _textNotReady.visibility = View.GONE; - - val dev = StateCasting.instance.activeDevice; - if (dev == d) { - if (dev.connectionState == CastConnectionState.CONNECTED) { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.VISIBLE; - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; - } else { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.VISIBLE; - _imageLoader.visibility = View.VISIBLE; - _textNotReady.visibility = View.GONE; - } + _imagePin.visibility = View.GONE; } else { - if (d.isReady) { - _buttonConnect.visibility = View.VISIBLE; - _buttonDisconnect.visibility = View.GONE; - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + + val dev = StateCasting.instance.activeDevice; + if (dev == d) { + if (dev.connectionState == CastConnectionState.CONNECTED) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.VISIBLE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } } else { - _buttonConnect.visibility = View.GONE; - _buttonDisconnect.visibility = View.GONE; - _imageLoader.visibility = View.GONE; - _textNotReady.visibility = View.VISIBLE; + if (d.isReady) { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.GONE; + _imagePin.visibility = View.VISIBLE; + } else { + _imageLoader.visibility = View.GONE; + _textNotReady.visibility = View.VISIBLE; + _imagePin.visibility = View.VISIBLE; + } + } + + _imagePin.setImageResource(if (isPinnedDevice) R.drawable.keep_24px else R.drawable.ic_pin) + + if (_imageLoader.isVisible) { + _animatableLoader?.start(); + } else { + _animatableLoader?.stop(); } } - if (_imageLoader.visibility == View.VISIBLE) { - _animatableLoader?.start(); - } else { - _animatableLoader?.stop(); - } + device = d; } } \ No newline at end of file diff --git a/app/src/main/res/drawable/keep_24px.xml b/app/src/main/res/drawable/keep_24px.xml new file mode 100644 index 00000000..767284d6 --- /dev/null +++ b/app/src/main/res/drawable/keep_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index e76b8c65..becfa5c8 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -11,25 +11,48 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_vertical" - android:orientation="horizontal"> + android:orientation="horizontal" + android:layout_marginTop="12dp"> - + android:orientation="vertical"> - + + + + + + + + + + android:layout_marginEnd="20dp" + android:layout_marginTop="10dp" + android:layout_marginBottom="20dp"/> + + + + - - - - + android:orientation="horizontal" + android:layout_marginTop="12dp" + android:layout_marginBottom="12dp" + android:layout_marginStart="20dp" + android:layout_marginEnd="20dp"> - - - - - - + android:gravity="center"> - + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_device.xml b/app/src/main/res/layout/list_device.xml index 534a9f2f..a4ae00f7 100644 --- a/app/src/main/res/layout/list_device.xml +++ b/app/src/main/res/layout/list_device.xml @@ -4,18 +4,34 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="35dp" - android:clickable="true"> + android:clickable="true" + android:id="@+id/layout_root"> - + app:layout_constraintBottom_toBottomOf="parent"> + + + + + - - - - - - - - - - - - - - - - - + android:layout_height="25dp" + android:contentDescription="@string/cd_image_loader" + app:srcCompat="@drawable/ic_pin" + android:layout_marginEnd="8dp" + android:scaleType="fitEnd" + android:paddingStart="10dp" /> IP Port Discovered Devices + Available devices Remembered Devices + Unable to see the device you\'re looking for? Try to add the device manually. There are no remembered devices Connected to Volume @@ -204,6 +206,7 @@ Previous Next Comment + Add manually Comment is not empty, close anyway? Import My Playlist Name From 9e2041521e7df097ab36ff3f93cbdb9d44100cd7 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 29 Apr 2025 15:27:24 +0200 Subject: [PATCH 070/128] Made the disconnect button easier to click on casting connected dialog. --- .../res/layout/dialog_casting_connect.xml | 2 +- .../res/layout/dialog_casting_connected.xml | 46 +++++++++++-------- app/src/main/res/values/strings.xml | 1 + 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/app/src/main/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index becfa5c8..2c0839b0 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -125,7 +125,7 @@ android:gravity="center_vertical" android:orientation="horizontal" android:layout_marginTop="12dp" - android:layout_marginBottom="12dp" + android:layout_marginBottom="20dp" android:layout_marginStart="20dp" android:layout_marginEnd="20dp"> diff --git a/app/src/main/res/layout/dialog_casting_connected.xml b/app/src/main/res/layout/dialog_casting_connected.xml index 027db929..dc9aaed8 100644 --- a/app/src/main/res/layout/dialog_casting_connected.xml +++ b/app/src/main/res/layout/dialog_casting_connected.xml @@ -97,27 +97,7 @@ app:layout_constraintTop_toBottomOf="@id/text_name" app:layout_constraintLeft_toRightOf="@id/image_device" /> - - - @@ -253,4 +233,30 @@ android:gravity="center_vertical" android:paddingBottom="15dp"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b31099f1..e39188bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,6 +135,7 @@ Not ready Connect Stop + Stop casting Start Storage Space Downloads From 697b3bc5f55bfacff39fbea79a4772bb277a50c7 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 30 Apr 2025 20:00:48 +0200 Subject: [PATCH 071/128] SLD domain checking fix, download notification if on metered, check for unstarted downloads on opening ui, minor fixes/imrpovements --- .../platformplayer/Extensions_Formatting.kt | 18 +++++++++++------- .../com/futo/platformplayer/UISlideOverlays.kt | 4 ++++ .../mainactivity/main/DownloadsFragment.kt | 13 +++++++++++++ .../futo/platformplayer/states/StateSync.kt | 5 ++++- .../main/res/layout/dialog_casting_connect.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../java/com/futo/platformplayer/MdnsTests.kt | 6 +++++- 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt index 4ddf37ad..b3c14a79 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -14,7 +14,6 @@ import java.text.DecimalFormat import java.time.OffsetDateTime import java.time.temporal.ChronoUnit import kotlin.math.abs -import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a fun String.matchesDomain(queryDomain: String): Boolean { if(queryDomain.startsWith(".")) { - - val parts = queryDomain.lowercase().split("."); - if(parts.size < 3) + val parts = this.lowercase().split("."); + val queryParts = queryDomain.lowercase().trimStart("."[0]).split("."); + if(queryParts.size < 2) throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")"); - if(parts.size >= 3){ - val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]); - if(isSLD && parts.size <= 3) + else { + val possibleDomain = "." + queryParts.joinToString("."); + if(slds.contains(possibleDomain)) throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); + /* + val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]); + if(isSLD && queryParts.size <= 3) + throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")"); + */ } //TODO: Should be safe, but double verify if can't be exploited diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt index 874ffd4f..61bdf97e 100644 --- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt +++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt @@ -684,6 +684,10 @@ class UISlideOverlays { } } } + if(!Settings.instance.downloads.shouldDownload()) { + UIDialogs.appToast("Download will start when you're back on wifi.\n" + + "(You can change this in settings)", true); + } } }; return menu.apply { show() }; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index 217165ae..536700d8 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -14,10 +14,14 @@ import androidx.core.widget.addTextChangedListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.R +import com.futo.platformplayer.Settings +import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.downloads.VideoDownload import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.services.DownloadService +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists @@ -54,6 +58,15 @@ class DownloadsFragment : MainFragment() { super.onResume() _view?.reloadUI(); + if(StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.QUEUED } && + !StateDownloads.instance.getDownloading().any { it.state == VideoDownload.State.DOWNLOADING } && + Settings.instance.downloads.shouldDownload()) { + Logger.w(TAG, "Detected queued download, while not downloading, attempt recreating service"); + StateApp.withContext { + DownloadService.getOrCreateService(it); + } + } + StateDownloads.instance.onDownloadsChanged.subscribe(this) { lifecycleScope.launch(Dispatchers.Main) { try { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 6bbd0c7d..75182f82 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -194,7 +194,10 @@ class StateSync { addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { service.hostAddresses.toTypedArray() } else { - arrayOf(service.host) + if(service.host != null) + arrayOf(service.host); + else + arrayOf(); }, service.port, service.attributes) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { diff --git a/app/src/main/res/layout/dialog_casting_connect.xml b/app/src/main/res/layout/dialog_casting_connect.xml index 2c0839b0..a6153053 100644 --- a/app/src/main/res/layout/dialog_casting_connect.xml +++ b/app/src/main/res/layout/dialog_casting_connect.xml @@ -5,7 +5,7 @@ android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/gray_1d"> + android:background="#101010"> Configure if background download should be used Configure the auto updater Configure when updates should be downloaded - Configure when videos should be downloaded + Configure when videos should be downloaded, if they should only be downloaded on unmetered networks (wifi/ethernet) Creates a zip file with your data which can be imported by opening it with Grayjay Default Audio Quality Default Playback Speed diff --git a/app/src/test/java/com/futo/platformplayer/MdnsTests.kt b/app/src/test/java/com/futo/platformplayer/MdnsTests.kt index 64a37d6e..95c46c1d 100644 --- a/app/src/test/java/com/futo/platformplayer/MdnsTests.kt +++ b/app/src/test/java/com/futo/platformplayer/MdnsTests.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer +/* import com.futo.platformplayer.mdns.DnsOpcode import com.futo.platformplayer.mdns.DnsPacket import com.futo.platformplayer.mdns.DnsPacketHeader @@ -12,6 +13,7 @@ import com.futo.platformplayer.mdns.QuestionClass import com.futo.platformplayer.mdns.QuestionType import com.futo.platformplayer.mdns.ResourceRecordClass import com.futo.platformplayer.mdns.ResourceRecordType +*/ import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue import java.io.ByteArrayOutputStream @@ -20,8 +22,9 @@ import kotlin.test.Test import kotlin.test.assertContentEquals +//TODO: Update tests. class MdnsTests { - +/* @Test fun `BasicOperation`() { val expectedData = byteArrayOf( @@ -391,4 +394,5 @@ class MdnsTests { assertContentEquals(data, writer.toByteArray()) } + */ } \ No newline at end of file From 8384f227bee00abb5c0e06a4d7f6bef1c9747567 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Wed, 30 Apr 2025 20:02:09 +0200 Subject: [PATCH 072/128] Plugin refs --- app/src/stable/assets/sources/apple-podcasts | 2 +- app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/bitchute | 2 +- app/src/stable/assets/sources/dailymotion | 2 +- app/src/stable/assets/sources/kick | 2 +- app/src/stable/assets/sources/nebula | 2 +- app/src/stable/assets/sources/peertube | 2 +- app/src/stable/assets/sources/rumble | 2 +- app/src/stable/assets/sources/spotify | 2 +- app/src/stable/assets/sources/twitch | 2 +- app/src/stable/assets/sources/youtube | 2 +- app/src/unstable/assets/sources/apple-podcasts | 2 +- app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/bitchute | 2 +- app/src/unstable/assets/sources/dailymotion | 2 +- app/src/unstable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/nebula | 2 +- app/src/unstable/assets/sources/peertube | 2 +- app/src/unstable/assets/sources/rumble | 2 +- app/src/unstable/assets/sources/spotify | 2 +- app/src/unstable/assets/sources/twitch | 2 +- app/src/unstable/assets/sources/youtube | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/stable/assets/sources/apple-podcasts b/app/src/stable/assets/sources/apple-podcasts index 07e39f9d..9aa31c5e 160000 --- a/app/src/stable/assets/sources/apple-podcasts +++ b/app/src/stable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 07e39f9df71b1937adf5bfb718a115fc232aa6f8 +Subproject commit 9aa31c5e87c7957a6e7ef07b6a8f38b775c88d9a diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index ce0571bd..0830668d 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb +Subproject commit 0830668d3bdac18fafae6bb49aa1ff97b717f3b5 diff --git a/app/src/stable/assets/sources/bitchute b/app/src/stable/assets/sources/bitchute index 3fbd872a..b31ced36 160000 --- a/app/src/stable/assets/sources/bitchute +++ b/app/src/stable/assets/sources/bitchute @@ -1 +1 @@ -Subproject commit 3fbd872ad8bd7df62c5fbec7437e1200d82b74e1 +Subproject commit b31ced36b9faaa535fb13a5873cdeb1c89d55859 diff --git a/app/src/stable/assets/sources/dailymotion b/app/src/stable/assets/sources/dailymotion index b34134ca..ffd40f20 160000 --- a/app/src/stable/assets/sources/dailymotion +++ b/app/src/stable/assets/sources/dailymotion @@ -1 +1 @@ -Subproject commit b34134ca2dbb1662b060b4a67f14e7c5d077889d +Subproject commit ffd40f2006b9048690944e55688951a849f5a13a diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index 3a0efd1f..edb526a9 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23 +Subproject commit edb526a9a0543517cd9e5e1fe0fe99673e173db2 diff --git a/app/src/stable/assets/sources/nebula b/app/src/stable/assets/sources/nebula index f30a3bfc..97a5ad5a 160000 --- a/app/src/stable/assets/sources/nebula +++ b/app/src/stable/assets/sources/nebula @@ -1 +1 @@ -Subproject commit f30a3bfc0f6a894d816ab7fa732b8f63eb54b84e +Subproject commit 97a5ad5a37c40ed68cccbab05ba16926a0aaee41 diff --git a/app/src/stable/assets/sources/peertube b/app/src/stable/assets/sources/peertube index 2bcab14d..6e7f943b 160000 --- a/app/src/stable/assets/sources/peertube +++ b/app/src/stable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit 2bcab14d01a564aa8ab9218de54042fc68b9ee76 +Subproject commit 6e7f943b0ba56181ee503e1f2cb8349db1351553 diff --git a/app/src/stable/assets/sources/rumble b/app/src/stable/assets/sources/rumble index a32dbb62..932fdf78 160000 --- a/app/src/stable/assets/sources/rumble +++ b/app/src/stable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit a32dbb626aacfc6264e505cd5c7f34dd8a60edfc +Subproject commit 932fdf78dec23a132bedc8838185af9911452af5 diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify index b61095ec..47e76a96 160000 --- a/app/src/stable/assets/sources/spotify +++ b/app/src/stable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed +Subproject commit 47e76a96e5edcb265b99e2e30f178ba6234a6d2f diff --git a/app/src/stable/assets/sources/twitch b/app/src/stable/assets/sources/twitch index a75e8460..08346f91 160000 --- a/app/src/stable/assets/sources/twitch +++ b/app/src/stable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit a75e846045a7882002dd7a6bfa83550f52d9dbab +Subproject commit 08346f917753694e14bc1caa784aa87066a2ab84 diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube index 6f1266a0..a297a0a7 160000 --- a/app/src/stable/assets/sources/youtube +++ b/app/src/stable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 +Subproject commit a297a0a7884ea2cf1aa4c9798d72ee11d0038dce diff --git a/app/src/unstable/assets/sources/apple-podcasts b/app/src/unstable/assets/sources/apple-podcasts index 07e39f9d..9aa31c5e 160000 --- a/app/src/unstable/assets/sources/apple-podcasts +++ b/app/src/unstable/assets/sources/apple-podcasts @@ -1 +1 @@ -Subproject commit 07e39f9df71b1937adf5bfb718a115fc232aa6f8 +Subproject commit 9aa31c5e87c7957a6e7ef07b6a8f38b775c88d9a diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index ce0571bd..0830668d 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit ce0571bdeaed4e341351ef477ef4b6599aa4d0fb +Subproject commit 0830668d3bdac18fafae6bb49aa1ff97b717f3b5 diff --git a/app/src/unstable/assets/sources/bitchute b/app/src/unstable/assets/sources/bitchute index 3fbd872a..b31ced36 160000 --- a/app/src/unstable/assets/sources/bitchute +++ b/app/src/unstable/assets/sources/bitchute @@ -1 +1 @@ -Subproject commit 3fbd872ad8bd7df62c5fbec7437e1200d82b74e1 +Subproject commit b31ced36b9faaa535fb13a5873cdeb1c89d55859 diff --git a/app/src/unstable/assets/sources/dailymotion b/app/src/unstable/assets/sources/dailymotion index b34134ca..ffd40f20 160000 --- a/app/src/unstable/assets/sources/dailymotion +++ b/app/src/unstable/assets/sources/dailymotion @@ -1 +1 @@ -Subproject commit b34134ca2dbb1662b060b4a67f14e7c5d077889d +Subproject commit ffd40f2006b9048690944e55688951a849f5a13a diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index 3a0efd1f..edb526a9 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit 3a0efd1fc4db63c15334a190ab69a8fb4498ae23 +Subproject commit edb526a9a0543517cd9e5e1fe0fe99673e173db2 diff --git a/app/src/unstable/assets/sources/nebula b/app/src/unstable/assets/sources/nebula index f30a3bfc..97a5ad5a 160000 --- a/app/src/unstable/assets/sources/nebula +++ b/app/src/unstable/assets/sources/nebula @@ -1 +1 @@ -Subproject commit f30a3bfc0f6a894d816ab7fa732b8f63eb54b84e +Subproject commit 97a5ad5a37c40ed68cccbab05ba16926a0aaee41 diff --git a/app/src/unstable/assets/sources/peertube b/app/src/unstable/assets/sources/peertube index 2bcab14d..6e7f943b 160000 --- a/app/src/unstable/assets/sources/peertube +++ b/app/src/unstable/assets/sources/peertube @@ -1 +1 @@ -Subproject commit 2bcab14d01a564aa8ab9218de54042fc68b9ee76 +Subproject commit 6e7f943b0ba56181ee503e1f2cb8349db1351553 diff --git a/app/src/unstable/assets/sources/rumble b/app/src/unstable/assets/sources/rumble index a32dbb62..932fdf78 160000 --- a/app/src/unstable/assets/sources/rumble +++ b/app/src/unstable/assets/sources/rumble @@ -1 +1 @@ -Subproject commit a32dbb626aacfc6264e505cd5c7f34dd8a60edfc +Subproject commit 932fdf78dec23a132bedc8838185af9911452af5 diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify index b61095ec..47e76a96 160000 --- a/app/src/unstable/assets/sources/spotify +++ b/app/src/unstable/assets/sources/spotify @@ -1 +1 @@ -Subproject commit b61095ec200284a686edb8f3b2a595599ad8b5ed +Subproject commit 47e76a96e5edcb265b99e2e30f178ba6234a6d2f diff --git a/app/src/unstable/assets/sources/twitch b/app/src/unstable/assets/sources/twitch index a75e8460..08346f91 160000 --- a/app/src/unstable/assets/sources/twitch +++ b/app/src/unstable/assets/sources/twitch @@ -1 +1 @@ -Subproject commit a75e846045a7882002dd7a6bfa83550f52d9dbab +Subproject commit 08346f917753694e14bc1caa784aa87066a2ab84 diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index 6f1266a0..a297a0a7 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit 6f1266a038d11998fef429ae0eac0798b3280d75 +Subproject commit a297a0a7884ea2cf1aa4c9798d72ee11d0038dce From 2dde04b979ee54f135802ad6a6dcac272c13f084 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 1 May 2025 09:55:33 +0200 Subject: [PATCH 073/128] Reduced padding on content types search. --- app/src/main/res/layout/fragment_suggestion_list.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/res/layout/fragment_suggestion_list.xml b/app/src/main/res/layout/fragment_suggestion_list.xml index 41d5d06d..ef241b7b 100644 --- a/app/src/main/res/layout/fragment_suggestion_list.xml +++ b/app/src/main/res/layout/fragment_suggestion_list.xml @@ -40,8 +40,6 @@ android:id="@+id/radio_group" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingTop="8dp" - android:paddingBottom="8dp" android:paddingStart="8dp" android:paddingEnd="8dp" /> From 5e3a25c18f08d03b3f2fb13c2536da22fac91422 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 1 May 2025 11:00:33 +0200 Subject: [PATCH 074/128] Added dialog with loader before QR code scanner shows. --- .../futo/platformplayer/activities/MainActivity.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 25febdb1..9aaa223d 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint +import android.app.AlertDialog import android.content.ComponentName import android.content.Context import android.content.Intent @@ -613,8 +614,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work"); }*/ + private var _qrCodeLoadingDialog: AlertDialog? = null + fun showUrlQrCodeScanner() { try { + _qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true, + "Launching QR scanner", + "Make sure your camera is enabled", null, -2, + UIDialogs.Action("Close", { + _qrCodeLoadingDialog?.dismiss() + _qrCodeLoadingDialog = null + })); + val integrator = IntentIntegrator(this) integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) integrator.setPrompt(getString(R.string.scan_a_qr_code)) @@ -640,6 +651,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { super.onPause(); Logger.v(TAG, "onPause") _isVisible = false; + + _qrCodeLoadingDialog?.dismiss() + _qrCodeLoadingDialog = null } override fun onStop() { From 89526efe7a91aa2352b7e313b2b5d83d7a26a0ca Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 1 May 2025 14:11:22 +0200 Subject: [PATCH 075/128] Updated submodules. --- app/src/stable/assets/sources/kick | 2 +- app/src/unstable/assets/sources/kick | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/stable/assets/sources/kick b/app/src/stable/assets/sources/kick index edb526a9..ffdf4cda 160000 --- a/app/src/stable/assets/sources/kick +++ b/app/src/stable/assets/sources/kick @@ -1 +1 @@ -Subproject commit edb526a9a0543517cd9e5e1fe0fe99673e173db2 +Subproject commit ffdf4cda380e5e4e9e370412f014e704bd14c09e diff --git a/app/src/unstable/assets/sources/kick b/app/src/unstable/assets/sources/kick index edb526a9..ffdf4cda 160000 --- a/app/src/unstable/assets/sources/kick +++ b/app/src/unstable/assets/sources/kick @@ -1 +1 @@ -Subproject commit edb526a9a0543517cd9e5e1fe0fe99673e173db2 +Subproject commit ffdf4cda380e5e4e9e370412f014e704bd14c09e From fdaf41b6054174578d567bad3ec4e582ecbd5ec6 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Thu, 1 May 2025 16:31:51 +0200 Subject: [PATCH 076/128] BuildPlatform property --- .../com/futo/platformplayer/engine/packages/PackageBridge.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index 1a77d82d..12ea05af 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -72,6 +72,10 @@ class PackageBridge : V8Package { fun buildSpecVersion(): Int { return JSClientConstants.PLUGIN_SPEC_VERSION; } + @V8Property + fun buildPlatform(): String { + return "android"; + } @V8Function fun dispose(value: V8Value) { From 9f78e9b7dd0dc77c4b08e8042ffd9f69520ad6c1 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 1 May 2025 21:53:48 +0200 Subject: [PATCH 077/128] Crashfix in nsdmanager. StateSync reconnects less often. Channels are closed when sending fails in sync. --- .../java/com/futo/platformplayer/casting/StateCasting.kt | 6 +++++- .../main/java/com/futo/platformplayer/states/StateSync.kt | 5 ++++- .../com/futo/platformplayer/sync/internal/SyncSession.kt | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index f25cd410..03145a78 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -176,7 +176,11 @@ class StateCasting { fun stopDiscovering() { _nsdManager?.apply { _discoveryListeners.forEach { - stopServiceDiscovery(it.value) + try { + stopServiceDiscovery(it.value) + } catch (e: Throwable) { + //Ignored + } } } } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 75182f82..6ab62f11 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -460,10 +460,10 @@ class StateSync { Log.i(TAG, "Started relay session.") } catch (e: Throwable) { Log.e(TAG, "Relay session failed.", e) - Thread.sleep(5000) } finally { _relaySession?.stop() _relaySession = null + Thread.sleep(5000) } } }.apply { start() } @@ -737,6 +737,9 @@ class StateSync { val json = String(dataBody, Charsets.UTF_8); val history = Serializer.json.decodeFromString>(json); Logger.i(TAG, "SyncHistory received ${history.size} videos from ${remotePublicKey}"); + if (history.size == 1) { + Logger.i(TAG, "SyncHistory received update video '${history[0].video.name}' (url: ${history[0].video.url}) at timestamp ${history[0].position}"); + } var lastHistory = OffsetDateTime.MIN; for(video in history){ diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 51a40902..10b24d89 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -218,7 +218,9 @@ class SyncSession : IAuthorizable { sent = true break } catch (e: Throwable) { - Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode)", e) + Logger.w(TAG, "Packet failed to send (opcode = $opcode, subOpcode = $subOpcode), closing channel", e) + channel.close() + removeChannel(channel) } } From c26e9c281fbba8d542b8cc8b1b4421057d5d1ae4 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Fri, 2 May 2025 12:10:47 +0200 Subject: [PATCH 078/128] Easier search type switching on results page, fix search result click channels --- .../main/ContentSearchResultsFragment.kt | 23 +++++++++++++++++++ .../views/adapters/ChannelView.kt | 11 +++++++-- .../views/others/RadioGroupView.kt | 11 +++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt index f5d518f8..4d517b99 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentSearchResultsFragment.kt @@ -2,9 +2,11 @@ package com.futo.platformplayer.fragment.mainactivity.main import android.annotation.SuppressLint import android.os.Bundle +import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.allViews import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs @@ -23,6 +25,8 @@ import com.futo.platformplayer.models.SearchType import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.views.FeedStyle +import com.futo.platformplayer.views.ToggleBar +import com.futo.platformplayer.views.others.RadioGroupView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -114,6 +118,25 @@ class ContentSearchResultsFragment : MainFragment() { } setPreviewsEnabled(Settings.instance.search.previewFeedItems); + + initializeToolbar(); + } + + fun initializeToolbar(){ + if(_toolbarContentView.allViews.any { it is RadioGroupView }) + _toolbarContentView.removeView(_toolbarContentView.allViews.find { it is RadioGroupView }); + + val radioGroup = RadioGroupView(context); + radioGroup.onSelectedChange.subscribe { + + if (it.size != 1) + setSearchType(SearchType.VIDEO); + else + setSearchType((it[0] ?: SearchType.VIDEO) as SearchType); + } + radioGroup?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) + + _toolbarContentView.addView(radioGroup); } override fun cleanup() { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt index 28225da9..1d467732 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/ChannelView.kt @@ -45,6 +45,10 @@ open class ChannelView : LinearLayout { _buttonSubscribe = findViewById(R.id.button_subscribe); _platformIndicator = findViewById(R.id.platform_indicator); + //_textName.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } + //_creatorThumbnail.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } + //_textMetadata.setOnClickListener { currentChannel?.let { onClick.emit(it) }; } + if (_tiny) { _buttonSubscribe.visibility = View.GONE; _textMetadata.visibility = View.GONE; @@ -66,8 +70,11 @@ open class ChannelView : LinearLayout { open fun bind(content: IPlatformContent) { isClickable = true; - if(content !is IPlatformChannelContent) - return + if(content !is IPlatformChannelContent) { + currentChannel = null; + return; + } + currentChannel = content; _creatorThumbnail.setThumbnail(content.thumbnail, false); _textName.text = content.name; diff --git a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt index 22a5d21f..8c26d1e1 100644 --- a/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/others/RadioGroupView.kt @@ -13,6 +13,17 @@ class RadioGroupView : FlexboxLayout { val selectedOptions = arrayListOf(); val onSelectedChange = Event1>(); + constructor(context: Context) : super(context) { + flexWrap = FlexWrap.WRAP; + _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); + + if (isInEditMode) { + setOptions(listOf("Example 1" to 1, "Example 2" to 2, "Example 3" to 3, "Example 4" to 4, "Example 5" to 5), listOf("Example 1", "Example 2"), + multiSelect = true, + atLeastOne = false + ); + } + } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { flexWrap = FlexWrap.WRAP; _padding_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, _padding_dp, context.resources.displayMetrics).toInt(); From f8edd6cf3d93c2c13ab5c117c5f47b9db2a10da8 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 11:21:57 +0200 Subject: [PATCH 079/128] Possibel performance improvements to sync under high lat conditions. --- .../java/com/futo/platformplayer/Settings.kt | 3 + .../futo/platformplayer/states/StateSync.kt | 53 ++++++-- .../platformplayer/sync/internal/Channel.kt | 6 + .../sync/internal/SyncSocketSession.kt | 125 ++++++++++++------ app/src/main/res/values/strings.xml | 2 + 5 files changed, 136 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index b9b81ec6..eb593c5b 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -945,6 +945,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3) var connectThroughRelay: Boolean = true; + + @FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3) + var connectLocalDirectThroughRelay: Boolean = true; } @FormField(R.string.info, FieldForm.GROUP, -1, 21) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 6ab62f11..14e3f510 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -361,8 +361,7 @@ class StateSync { _relaySession = SyncSocketSession( (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, - LittleEndianDataInputStream(socket.getInputStream()), - LittleEndianDataOutputStream(socket.getOutputStream()), + socket, isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, onNewChannel = { _, c -> val remotePublicKey = c.remotePublicKey @@ -407,12 +406,14 @@ class StateSync { relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, Settings.instance.synchronization.discoverThroughRelay, false, false, Settings.instance.synchronization.discoverThroughRelay && Settings.instance.synchronization.connectThroughRelay) + Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } + Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") for ((targetKey, connectionInfo) in connectionInfos) { val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses) .filter { it != connectionInfo.remoteIp } - if (connectionInfo.allowLocalDirect) { + if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { Thread { try { Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") @@ -433,10 +434,10 @@ class StateSync { if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { try { - Log.v(TAG, "Attempting relayed connection with '$targetKey'.") + Logger.v(TAG, "Attempting relayed connection with '$targetKey'.") runBlocking { relaySession.startRelayedChannel(targetKey, APP_ID, null) } } catch (e: Throwable) { - Log.e(TAG, "Failed to start relayed channel with $targetKey.", e) + Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e) } } } @@ -444,7 +445,7 @@ class StateSync { Thread.sleep(15000) } } catch (e: Throwable) { - Log.e(TAG, "Unhandled exception in relay session.", e) + Logger.e(TAG, "Unhandled exception in relay session.", e) relaySession.stop() } }.start() @@ -585,16 +586,33 @@ class StateSync { Logger.i(TAG, "Received SyncSessionData from $remotePublicKey"); + val subscriptionPackageString = StateSubscriptions.instance.getSyncSubscriptionsPackageString() + Logger.i(TAG, "syncStateExchange syncSubscriptions b (size: ${subscriptionPackageString.length})") + session.sendData(GJSyncOpcodes.syncSubscriptions, subscriptionPackageString); + Logger.i(TAG, "syncStateExchange syncSubscriptions (size: ${subscriptionPackageString.length})") - session.sendData(GJSyncOpcodes.syncSubscriptions, StateSubscriptions.instance.getSyncSubscriptionsPackageString()); - session.sendData(GJSyncOpcodes.syncSubscriptionGroups, StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString()); - session.sendData(GJSyncOpcodes.syncPlaylists, StatePlaylists.instance.getSyncPlaylistsPackageString()) + val subscriptionGroupPackageString = StateSubscriptionGroups.instance.getSyncSubscriptionGroupsPackageString() + Logger.i(TAG, "syncStateExchange syncSubscriptionGroups b (size: ${subscriptionGroupPackageString.length})") + session.sendData(GJSyncOpcodes.syncSubscriptionGroups, subscriptionGroupPackageString); + Logger.i(TAG, "syncStateExchange syncSubscriptionGroups (size: ${subscriptionGroupPackageString.length})") - session.sendData(GJSyncOpcodes.syncWatchLater, Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false))); + val syncPlaylistPackageString = StatePlaylists.instance.getSyncPlaylistsPackageString() + Logger.i(TAG, "syncStateExchange syncPlaylists b (size: ${syncPlaylistPackageString.length})") + session.sendData(GJSyncOpcodes.syncPlaylists, syncPlaylistPackageString) + Logger.i(TAG, "syncStateExchange syncPlaylists (size: ${syncPlaylistPackageString.length})") + + val watchLaterPackageString = Json.encodeToString(StatePlaylists.instance.getWatchLaterSyncPacket(false)) + Logger.i(TAG, "syncStateExchange syncWatchLater b (size: ${watchLaterPackageString.length})") + session.sendData(GJSyncOpcodes.syncWatchLater, watchLaterPackageString); + Logger.i(TAG, "syncStateExchange syncWatchLater (size: ${watchLaterPackageString.length})") val recentHistory = StateHistory.instance.getRecentHistory(syncSessionData.lastHistory); + + Logger.i(TAG, "syncStateExchange syncHistory b (size: ${recentHistory.size})") if(recentHistory.isNotEmpty()) session.sendJsonData(GJSyncOpcodes.syncHistory, recentHistory); + + Logger.i(TAG, "syncStateExchange syncHistory (size: ${recentHistory.size})") } GJSyncOpcodes.syncExport -> { @@ -825,7 +843,17 @@ class StateSync { } }, dataHandler = { it, opcode, subOpcode, data -> - handleData(it, opcode, subOpcode, data) + val dataCopy = ByteArray(data.remaining()) + data.get(dataCopy) + + StateApp.instance.scopeOrNull?.launch { + try { + handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy)) + } catch (e: Throwable) { + Logger.e(TAG, "Exception occurred while handling data, closing session", e) + it.close() + } + } }, remoteDeviceName ) @@ -860,8 +888,7 @@ class StateSync { return SyncSocketSession( (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, keyPair!!, - LittleEndianDataInputStream(socket.getInputStream()), - LittleEndianDataOutputStream(socket.getOutputStream()), + socket, onClose = { s -> if (channelSocket != null) session?.removeChannel(channelSocket!!) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 84c1445c..aa9036f1 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -192,6 +192,8 @@ class ChannelRelayed( val HEADER_SIZE = 6 val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 + Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data?.remaining()})") + if (actualCount > MAX_DATA_PER_PACKET && data != null) { val streamId = session.generateStreamId() val totalSize = actualCount @@ -333,4 +335,8 @@ class ChannelRelayed( completeHandshake(remoteVersion, transport) } } + + companion object { + private val TAG = "Channel" + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index e66da337..4f74c5ff 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -10,25 +10,31 @@ import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync import kotlinx.coroutines.CompletableDeferred +import java.io.InputStream +import java.io.OutputStream import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress import java.net.NetworkInterface +import java.net.Socket import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Base64 import java.util.Locale import java.util.concurrent.ConcurrentHashMap import kotlin.math.min +import kotlin.system.measureTimeMillis +import kotlin.time.measureTime class SyncSocketSession { - private val _inputStream: LittleEndianDataInputStream - private val _outputStream: LittleEndianDataOutputStream + private val _socket: Socket + private val _inputStream: InputStream + private val _outputStream: OutputStream private val _sendLockObject = Object() private val _buffer = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _bufferDecrypted = ByteArray(MAXIMUM_PACKET_SIZE) private val _sendBuffer = ByteArray(MAXIMUM_PACKET_SIZE) - private val _sendBufferEncrypted = ByteArray(MAXIMUM_PACKET_SIZE_ENCRYPTED) + private val _sendBufferEncrypted = ByteArray(4 + MAXIMUM_PACKET_SIZE_ENCRYPTED) private val _syncStreams = hashMapOf() private var _streamIdGenerator = 0 private val _streamIdGeneratorLock = Object() @@ -81,8 +87,7 @@ class SyncSocketSession { constructor( remoteAddress: String, localKeyPair: DHState, - inputStream: LittleEndianDataInputStream, - outputStream: LittleEndianDataOutputStream, + socket: Socket, onClose: ((session: SyncSocketSession) -> Unit)? = null, onHandshakeComplete: ((session: SyncSocketSession) -> Unit)? = null, onData: ((session: SyncSocketSession, opcode: UByte, subOpcode: UByte, data: ByteBuffer) -> Unit)? = null, @@ -90,8 +95,12 @@ class SyncSocketSession { onChannelEstablished: ((session: SyncSocketSession, channel: ChannelRelayed, isResponder: Boolean) -> Unit)? = null, isHandshakeAllowed: ((linkType: LinkType, session: SyncSocketSession, remotePublicKey: String, pairingCode: String?, appId: UInt) -> Boolean)? = null ) { - _inputStream = inputStream - _outputStream = outputStream + _socket = socket + _socket.receiveBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED + _socket.sendBufferSize = MAXIMUM_PACKET_SIZE_ENCRYPTED + _socket.tcpNoDelay = true + _inputStream = _socket.getInputStream() + _outputStream = _socket.getOutputStream() _onClose = onClose _onHandshakeComplete = onHandshakeComplete _localKeyPair = localKeyPair @@ -150,30 +159,45 @@ class SyncSocketSession { }.apply { start() } } + private fun readExact(buffer: ByteArray, offset: Int, size: Int) { + var totalBytesReceived: Int = 0 + while (totalBytesReceived < size) { + val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived) + if (bytesReceived == 0) + throw Exception("Socket disconnected") + totalBytesReceived += bytesReceived + } + } + private fun receiveLoop() { while (_started) { try { - val messageSize = _inputStream.readInt() + //Logger.v(TAG, "Waiting for message size...") + + readExact(_buffer, 0, 4) + val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + + //Logger.v(TAG, "Read message size ${messageSize}.") + if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") } //Logger.i(TAG, "Receiving message (size = ${messageSize})") - var bytesRead = 0 - while (bytesRead < messageSize) { - val read = _inputStream.read(_buffer, bytesRead, messageSize - bytesRead) - if (read == -1) - throw Exception("Stream closed") - bytesRead += read - } + readExact(_buffer, 0, messageSize) + //Logger.v(TAG, "Read ${messageSize}.") + //Logger.v(TAG, "Decrypting ${messageSize} bytes.") val plen: Int = _cipherStatePair!!.receiver.decryptWithAd(null, _buffer, 0, _bufferDecrypted, 0, messageSize) //Logger.i(TAG, "Decrypted message (size = ${plen})") + //Logger.v(TAG, "Decrypted ${messageSize} bytes.") handleData(_bufferDecrypted, plen, null) + //Logger.v(TAG, "Handled data ${messageSize} bytes.") } catch (e: Throwable) { - Logger.e(TAG, "Exception while receiving data", e) + Logger.e(TAG, "Exception while receiving data, closing socket session", e) + stop() break } } @@ -203,8 +227,7 @@ class SyncSocketSession { _channels.values.forEach { it.close() } _channels.clear() _onClose?.invoke(this) - _inputStream.close() - _outputStream.close() + _socket.close() _thread = null _cipherStatePair?.sender?.destroy() _cipherStatePair?.receiver?.destroy() @@ -237,18 +260,25 @@ class SyncSocketSession { val mainBuffer = ByteArray(512) val mainLength = initiator.writeMessage(mainBuffer, 0, null, 0, 0) - val messageData = ByteBuffer.allocate(4 + 4 + pairingMessageLength + mainLength).order(ByteOrder.LITTLE_ENDIAN) + val messageSize = 4 + 4 + pairingMessageLength + mainLength + val messageData = ByteBuffer.allocate(4 + messageSize).order(ByteOrder.LITTLE_ENDIAN) + messageData.putInt(messageSize) messageData.putInt(appId.toInt()) messageData.putInt(pairingMessageLength) if (pairingMessageLength > 0) messageData.put(pairingMessage) messageData.put(mainBuffer, 0, mainLength) val messageDataArray = messageData.array() - _outputStream.writeInt(messageDataArray.size) - _outputStream.write(messageDataArray) + _outputStream.write(messageDataArray, 0, 4 + messageSize) + + readExact(_buffer, 0, 4) + val responseSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + if (responseSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { + throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") + } - val responseSize = _inputStream.readInt() val responseMessage = ByteArray(responseSize) - _inputStream.readFully(responseMessage) + readExact(responseMessage, 0, responseSize) + val plaintext = ByteArray(512) // Buffer for any payload (none expected here) initiator.readMessage(responseMessage, 0, responseSize, plaintext, 0) @@ -265,11 +295,16 @@ class SyncSocketSession { responder.localKeyPair.copyFrom(_localKeyPair) responder.start() - val messageSize = _inputStream.readInt() - val message = ByteArray(messageSize) - _inputStream.readFully(message) - val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) + readExact(_buffer, 0, 4) + val messageSize = ByteBuffer.wrap(_buffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int + if (messageSize > MAXIMUM_PACKET_SIZE_ENCRYPTED) { + throw Exception("Message size (${messageSize}) cannot exceed MAXIMUM_PACKET_SIZE ($MAXIMUM_PACKET_SIZE_ENCRYPTED)") + } + val message = ByteArray(messageSize) + readExact(message, 0, messageSize) + + val messageBuffer = ByteBuffer.wrap(message).order(ByteOrder.LITTLE_ENDIAN) val appId = messageBuffer.int.toUInt() val pairingMessageLength = messageBuffer.int val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() @@ -298,10 +333,10 @@ class SyncSocketSession { return false } - val responseBuffer = ByteArray(512) - val responseLength = responder.writeMessage(responseBuffer, 0, null, 0, 0) - _outputStream.writeInt(responseLength) - _outputStream.write(responseBuffer, 0, responseLength) + val responseBuffer = ByteArray(4 + 512) + val responseLength = responder.writeMessage(responseBuffer, 4, null, 0, 0) + ByteBuffer.wrap(responseBuffer).order(ByteOrder.LITTLE_ENDIAN).putInt(responseLength) + _outputStream.write(responseBuffer, 0, 4 + responseLength) _cipherStatePair = responder.split() _remotePublicKey = remotePublicKey @@ -311,8 +346,13 @@ class SyncSocketSession { private fun performVersionCheck() { val CURRENT_VERSION = 4 val MINIMUM_VERSION = 4 - _outputStream.writeInt(CURRENT_VERSION) - remoteVersion = _inputStream.readInt() + + val versionBytes = ByteArray(4) + ByteBuffer.wrap(versionBytes).order(ByteOrder.LITTLE_ENDIAN).putInt(CURRENT_VERSION) + _outputStream.write(versionBytes, 0, 4) + + readExact(versionBytes, 0, 4) + remoteVersion = ByteBuffer.wrap(versionBytes, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int Logger.i(TAG, "performVersionCheck (version = $remoteVersion)") if (remoteVersion < MINIMUM_VERSION) throw Exception("Invalid version") @@ -324,6 +364,8 @@ class SyncSocketSession { fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { ensureNotMainThread() + Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})") + if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentData = ByteArray(segmentSize) @@ -368,11 +410,12 @@ class SyncSocketSession { put(data.array(), data.position(), data.remaining()) } - //Logger.i(TAG, "Encrypting message (size = ${data.size + HEADER_SIZE})") - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, data.remaining() + HEADER_SIZE) - //Logger.i(TAG, "Sending encrypted message (size = ${len})") - _outputStream.writeInt(len) - _outputStream.write(_sendBufferEncrypted, 0, len) + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, data.remaining() + HEADER_SIZE) + val sendDuration = measureTimeMillis { + ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) + _outputStream.write(_sendBufferEncrypted, 0, 4 + len) + } + Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()}, sendDuration: ${sendDuration})") } } } @@ -391,8 +434,8 @@ class SyncSocketSession { val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE) //Logger.i(TAG, "Sending encrypted message (size = ${len})") - _outputStream.writeInt(len) - _outputStream.write(_sendBufferEncrypted, 0, len) + ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) + _outputStream.write(_sendBufferEncrypted, 0, 4 + len) } } @@ -411,6 +454,8 @@ class SyncSocketSession { val opcode = data.get().toUByte() val subOpcode = data.get().toUByte() + + Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})") handlePacket(opcode, subOpcode, data, sourceChannel) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3edff39b..328f1be1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -382,6 +382,8 @@ Allow devices to be paired through the relay Connection through relay Allow devices to be connected to through the relay + Connect direct through relay + Allow devices to be directly locally connected to through information discovered from the relay Gesture controls Volume slider Enable slide gesture to change volume From dabcfd965f6fda0b471aace18a1ea0c2a6d85604 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 12:11:08 +0200 Subject: [PATCH 080/128] Fixed send on wrong thread. --- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 14e3f510..98c7f8e9 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -846,7 +846,7 @@ class StateSync { val dataCopy = ByteArray(data.remaining()) data.get(dataCopy) - StateApp.instance.scopeOrNull?.launch { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy)) } catch (e: Throwable) { From 156eb4d15ecc2f7d49194041bbfa88d877244120 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 15:00:17 +0200 Subject: [PATCH 081/128] Implemented sync protocol gzip. --- .../platformplayer/sync/internal/Channel.kt | 53 +++++++++------ .../sync/internal/ContentEncoding.kt | 6 ++ .../sync/internal/SyncSession.kt | 8 +-- .../sync/internal/SyncSocketSession.kt | 67 +++++++++++++------ .../sync/internal/SyncStream.kt | 2 +- 5 files changed, 91 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index aa9036f1..70b758ad 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -5,9 +5,11 @@ import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync +import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Base64 +import java.util.zip.GZIPOutputStream interface IChannel : AutoCloseable { val remotePublicKey: String? @@ -15,7 +17,7 @@ interface IChannel : AutoCloseable { var authorizable: IAuthorizable? var syncSession: SyncSession? fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) - fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null) + fun send(opcode: UByte, subOpcode: UByte = 0u, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) fun setCloseHandler(onClose: ((IChannel) -> Unit)?) val linkType: LinkType } @@ -49,9 +51,9 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel { onData?.invoke(session, this, opcode, subOpcode, data) } - override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) { if (data != null) { - session.send(opcode, subOpcode, data) + session.send(opcode, subOpcode, data, contentEncoding) } else { session.send(opcode, subOpcode) } @@ -183,53 +185,63 @@ class ChannelRelayed( } } - override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?) { + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) { throwIfDisposed() - val actualCount = data?.remaining() ?: 0 + var processedData = data + if (data != null && contentEncoding == ContentEncoding.Gzip) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() + } + processedData = ByteBuffer.wrap(compressedStream.toByteArray()) + } + val ENCRYPTION_OVERHEAD = 16 val CONNECTION_ID_SIZE = 8 - val HEADER_SIZE = 6 + val HEADER_SIZE = 7 val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - HEADER_SIZE - CONNECTION_ID_SIZE - ENCRYPTION_OVERHEAD - 16 - Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data?.remaining()})") + Logger.v(TAG, "Send (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.size: ${processedData?.remaining()})") - if (actualCount > MAX_DATA_PER_PACKET && data != null) { + if (processedData != null && processedData.remaining() > MAX_DATA_PER_PACKET) { val streamId = session.generateStreamId() - val totalSize = actualCount var sendOffset = 0 - while (sendOffset < totalSize) { - val bytesRemaining = totalSize - sendOffset - val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - 2, bytesRemaining) + while (sendOffset < processedData.remaining()) { + val bytesRemaining = processedData.remaining() - sendOffset + val bytesToSend = minOf(MAX_DATA_PER_PACKET - 8 - HEADER_SIZE + 4, bytesRemaining) val streamData: ByteArray val streamOpcode: StreamOpcode if (sendOffset == 0) { streamOpcode = StreamOpcode.START - streamData = ByteArray(4 + 4 + 1 + 1 + bytesToSend) + streamData = ByteArray(4 + HEADER_SIZE + bytesToSend) ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(streamId) - putInt(totalSize) + putInt(processedData.remaining()) put(opcode.toByte()) put(subOpcode.toByte()) - put(data.array(), data.position() + sendOffset, bytesToSend) + put(contentEncoding?.value?.toByte() ?: 0.toByte()) + put(processedData.array(), processedData.position() + sendOffset, bytesToSend) } } else { streamData = ByteArray(4 + 4 + bytesToSend) ByteBuffer.wrap(streamData).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(streamId) putInt(sendOffset) - put(data.array(), data.position() + sendOffset, bytesToSend) + put(processedData.array(), processedData.position() + sendOffset, bytesToSend) } streamOpcode = if (bytesToSend < bytesRemaining) StreamOpcode.DATA else StreamOpcode.END } val fullPacket = ByteArray(HEADER_SIZE + streamData.size) ByteBuffer.wrap(fullPacket).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(streamData.size + 2) + putInt(streamData.size + HEADER_SIZE - 4) put(Opcode.STREAM.value.toByte()) put(streamOpcode.value.toByte()) + put(ContentEncoding.Raw.value.toByte()) put(streamData) } @@ -237,12 +249,13 @@ class ChannelRelayed( sendOffset += bytesToSend } } else { - val packet = ByteArray(HEADER_SIZE + actualCount) + val packet = ByteArray(HEADER_SIZE + (processedData?.remaining() ?: 0)) ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(actualCount + 2) + putInt((processedData?.remaining() ?: 0) + HEADER_SIZE - 4) put(opcode.toByte()) put(subOpcode.toByte()) - if (actualCount > 0 && data != null) put(data.array(), data.position(), actualCount) + put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) + if (processedData != null && processedData.remaining() > 0) put(processedData.array(), processedData.position(), processedData.remaining()) } sendPacket(packet) } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt new file mode 100644 index 00000000..ab9ed6a9 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/ContentEncoding.kt @@ -0,0 +1,6 @@ +package com.futo.platformplayer.sync.internal + +enum class ContentEncoding(val value: UByte) { + Raw(0u), + Gzip(1u) +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 10b24d89..da9c321d 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -196,14 +196,14 @@ class SyncSession : IAuthorizable { } fun sendData(subOpcode: UByte, data: String) { - send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))) + send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) } fun send(opcode: UByte, subOpcode: UByte, data: String) { - send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8))) + send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) } - fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null) { + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) { val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() } if (channels.isEmpty()) { //TODO: Should this throw? @@ -214,7 +214,7 @@ class SyncSession : IAuthorizable { var sent = false for (channel in channels) { try { - channel.send(opcode, subOpcode, data) + channel.send(opcode, subOpcode, data, contentEncoding) sent = true break } catch (e: Throwable) { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 4f74c5ff..8492faf3 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -10,6 +10,7 @@ import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync import kotlinx.coroutines.CompletableDeferred +import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.OutputStream import java.net.Inet4Address @@ -22,6 +23,7 @@ import java.nio.ByteOrder import java.util.Base64 import java.util.Locale import java.util.concurrent.ConcurrentHashMap +import java.util.zip.GZIPOutputStream import kotlin.math.min import kotlin.system.measureTimeMillis import kotlin.time.measureTime @@ -361,27 +363,37 @@ class SyncSocketSession { fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } - fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, contentEncoding: ContentEncoding? = null) { ensureNotMainThread() Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})") - if (data.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { + var processedData = data + if (contentEncoding == ContentEncoding.Gzip) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() + } + processedData = ByteBuffer.wrap(compressedStream.toByteArray()) + } + + if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { val segmentSize = MAXIMUM_PACKET_SIZE - HEADER_SIZE val segmentData = ByteArray(segmentSize) var sendOffset = 0 val id = generateStreamId() - while (sendOffset < data.remaining()) { - val bytesRemaining = data.remaining() - sendOffset + while (sendOffset < processedData.remaining()) { + val bytesRemaining = processedData.remaining() - sendOffset var bytesToSend: Int var segmentPacketSize: Int val streamOp: StreamOpcode if (sendOffset == 0) { streamOp = StreamOpcode.START - bytesToSend = segmentSize - 4 - 4 - 1 - 1 - segmentPacketSize = bytesToSend + 4 + 4 + 1 + 1 + bytesToSend = segmentSize - 4 - HEADER_SIZE + segmentPacketSize = bytesToSend + 4 + HEADER_SIZE } else { bytesToSend = minOf(segmentSize - 4 - 4, bytesRemaining) streamOp = if (bytesToSend >= bytesRemaining) StreamOpcode.END else StreamOpcode.DATA @@ -390,12 +402,13 @@ class SyncSocketSession { ByteBuffer.wrap(segmentData).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(id) - putInt(if (streamOp == StreamOpcode.START) data.remaining() else sendOffset) + putInt(if (streamOp == StreamOpcode.START) processedData.remaining() else sendOffset) if (streamOp == StreamOpcode.START) { put(opcode.toByte()) put(subOpcode.toByte()) + put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) } - put(data.array(), data.position() + sendOffset, bytesToSend) + put(processedData.array(), processedData.position() + sendOffset, bytesToSend) } send(Opcode.STREAM.value, streamOp.value, ByteBuffer.wrap(segmentData, 0, segmentPacketSize)) @@ -404,18 +417,19 @@ class SyncSocketSession { } else { synchronized(_sendLockObject) { ByteBuffer.wrap(_sendBuffer).order(ByteOrder.LITTLE_ENDIAN).apply { - putInt(data.remaining() + 2) + putInt(processedData.remaining() + HEADER_SIZE - 4) put(opcode.toByte()) put(subOpcode.toByte()) - put(data.array(), data.position(), data.remaining()) + put(contentEncoding?.value?.toByte() ?: ContentEncoding.Raw.value.toByte()) + put(processedData.array(), processedData.position(), processedData.remaining()) } - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, data.remaining() + HEADER_SIZE) + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, processedData.remaining() + HEADER_SIZE) val sendDuration = measureTimeMillis { ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len) } - Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()}, sendDuration: ${sendDuration})") + Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") } } } @@ -428,6 +442,7 @@ class SyncSocketSession { ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2) _sendBuffer.asUByteArray()[4] = opcode _sendBuffer.asUByteArray()[5] = subOpcode + _sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value //Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})") @@ -446,7 +461,7 @@ class SyncSocketSession { private fun handleData(data: ByteBuffer, sourceChannel: ChannelRelayed?) { val length = data.remaining() if (length < HEADER_SIZE) - throw Exception("Packet must be at least 6 bytes (header size)") + throw Exception("Packet must be at least ${HEADER_SIZE} bytes (header size)") val size = data.int if (size != length - 4) @@ -454,9 +469,10 @@ class SyncSocketSession { val opcode = data.get().toUByte() val subOpcode = data.get().toUByte() + val contentEncoding = data.get().toUByte() Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})") - handlePacket(opcode, subOpcode, data, sourceChannel) + handlePacket(opcode, subOpcode, data, contentEncoding, sourceChannel) } private fun handleRequest(subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { @@ -804,9 +820,19 @@ class SyncSocketSession { } } - private fun handlePacket(opcode: UByte, subOpcode: UByte, data: ByteBuffer, sourceChannel: ChannelRelayed?) { + private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) { Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})") + var data = d + if (contentEncoding == ContentEncoding.Gzip.value) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() + } + data = ByteBuffer.wrap(compressedStream.toByteArray()) + } + when (opcode) { Opcode.PING.value -> { if (sourceChannel != null) @@ -844,8 +870,9 @@ class SyncSocketSession { val expectedSize = data.int val op = data.get().toUByte() val subOp = data.get().toUByte() + val ce = data.get().toUByte() - val syncStream = SyncStream(expectedSize, op, subOp) + val syncStream = SyncStream(expectedSize, op, subOp, ce) if (data.remaining() > 0) { syncStream.add(data.array(), data.position(), data.remaining()) } @@ -890,7 +917,7 @@ class SyncSocketSession { throw Exception("After sync stream end, the stream must be complete") } - handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, sourceChannel) + handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, contentEncoding, sourceChannel) } } Opcode.DATA.value -> { @@ -1070,7 +1097,7 @@ class SyncSocketSession { send(Opcode.NOTIFY.value, NotifyOpcode.CONNECTION_INFO.value, publishBytes) } - suspend fun publishRecords(consumerPublicKeys: List, key: String, data: ByteArray): Boolean { + suspend fun publishRecords(consumerPublicKeys: List, key: String, data: ByteArray, contentEncoding: ContentEncoding? = null): Boolean { val keyBytes = key.toByteArray(Charsets.UTF_8) if (key.isEmpty() || keyBytes.size > 32) throw IllegalArgumentException("Key must be 1-32 bytes") if (consumerPublicKeys.isEmpty()) throw IllegalArgumentException("At least one consumer required") @@ -1125,7 +1152,7 @@ class SyncSocketSession { } } packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet) + send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, contentEncoding = contentEncoding) } catch (e: Exception) { _pendingPublishRequests.remove(requestId)?.completeExceptionally(e) throw e @@ -1245,6 +1272,6 @@ class SyncSocketSession { private const val TAG = "SyncSocketSession" const val MAXIMUM_PACKET_SIZE = 65535 - 16 const val MAXIMUM_PACKET_SIZE_ENCRYPTED = MAXIMUM_PACKET_SIZE + 16 - const val HEADER_SIZE = 6 + const val HEADER_SIZE = 7 } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt index d558feef..b7ed0626 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncStream.kt @@ -1,6 +1,6 @@ package com.futo.platformplayer.sync.internal -class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte) { +class SyncStream(expectedSize: Int, val opcode: UByte, val subOpcode: UByte, val contentEncoding: UByte) { companion object { const val MAXIMUM_SIZE = 10_000_000 } From a100785ad7c569886bf9af095f44f1a0f496de95 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 17:09:34 +0200 Subject: [PATCH 082/128] Gzip only for data packets. --- .../platformplayer/sync/internal/Channel.kt | 19 ++++++++---- .../sync/internal/SyncSocketSession.kt | 30 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 70b758ad..0b3b710d 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -185,17 +185,24 @@ class ChannelRelayed( } } - override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) { + override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) { throwIfDisposed() + var contentEncoding: ContentEncoding? = ce var processedData = data if (data != null && contentEncoding == ContentEncoding.Gzip) { - val compressedStream = ByteArrayOutputStream() - GZIPOutputStream(compressedStream).use { gzipStream -> - gzipStream.write(data.array(), data.position(), data.remaining()) - gzipStream.finish() + val isGzipSupported = opcode == Opcode.DATA.value + if (isGzipSupported) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() + } + processedData = ByteBuffer.wrap(compressedStream.toByteArray()) + } else { + Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.") + contentEncoding = ContentEncoding.Raw } - processedData = ByteBuffer.wrap(compressedStream.toByteArray()) } val ENCRYPTION_OVERHEAD = 16 diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 8492faf3..5d7db083 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -9,6 +9,7 @@ import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync +import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion import kotlinx.coroutines.CompletableDeferred import java.io.ByteArrayOutputStream import java.io.InputStream @@ -363,19 +364,26 @@ class SyncSocketSession { fun generateStreamId(): Int = synchronized(_streamIdGeneratorLock) { _streamIdGenerator++ } private fun generateRequestId(): Int = synchronized(_requestIdGeneratorLock) { _requestIdGenerator++ } - fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, contentEncoding: ContentEncoding? = null) { + fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer, ce: ContentEncoding? = null) { ensureNotMainThread() Logger.v(TAG, "send (opcode: ${opcode}, subOpcode: ${subOpcode}, data.remaining(): ${data.remaining()})") + var contentEncoding: ContentEncoding? = ce var processedData = data if (contentEncoding == ContentEncoding.Gzip) { - val compressedStream = ByteArrayOutputStream() - GZIPOutputStream(compressedStream).use { gzipStream -> - gzipStream.write(data.array(), data.position(), data.remaining()) - gzipStream.finish() + val isGzipSupported = opcode == Opcode.DATA.value + if (isGzipSupported) { + val compressedStream = ByteArrayOutputStream() + GZIPOutputStream(compressedStream).use { gzipStream -> + gzipStream.write(data.array(), data.position(), data.remaining()) + gzipStream.finish() + } + processedData = ByteBuffer.wrap(compressedStream.toByteArray()) + } else { + Logger.w(TAG, "Gzip requested but not supported on this (opcode = ${opcode}, subOpcode = ${subOpcode}), falling back.") + contentEncoding = ContentEncoding.Raw } - processedData = ByteBuffer.wrap(compressedStream.toByteArray()) } if (processedData.remaining() + HEADER_SIZE > MAXIMUM_PACKET_SIZE) { @@ -429,7 +437,7 @@ class SyncSocketSession { ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len) } - Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") + //Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") } } } @@ -471,7 +479,7 @@ class SyncSocketSession { val subOpcode = data.get().toUByte() val contentEncoding = data.get().toUByte() - Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})") + //Logger.v(TAG, "handleData (opcode: ${opcode}, subOpcode: ${subOpcode}, data.size: ${data.remaining()}, sourceChannel.connectionId: ${sourceChannel?.connectionId})") handlePacket(opcode, subOpcode, data, contentEncoding, sourceChannel) } @@ -825,6 +833,10 @@ class SyncSocketSession { var data = d if (contentEncoding == ContentEncoding.Gzip.value) { + val isGzipSupported = opcode == Opcode.DATA.value + if (!isGzipSupported) + throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).") + val compressedStream = ByteArrayOutputStream() GZIPOutputStream(compressedStream).use { gzipStream -> gzipStream.write(data.array(), data.position(), data.remaining()) @@ -1152,7 +1164,7 @@ class SyncSocketSession { } } packet.rewind() - send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, contentEncoding = contentEncoding) + send(Opcode.REQUEST.value, RequestOpcode.BULK_PUBLISH_RECORD.value, packet, ce = contentEncoding) } catch (e: Exception) { _pendingPublishRequests.remove(requestId)?.completeExceptionally(e) throw e From e047ab5684bae4cd81149121dd6b4b80216df726 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 17:35:17 +0200 Subject: [PATCH 083/128] Crashfixes. --- app/src/main/java/com/futo/platformplayer/Utility.kt | 2 +- .../com/futo/platformplayer/sync/internal/SyncSession.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Utility.kt b/app/src/main/java/com/futo/platformplayer/Utility.kt index c1c7d3e8..8abc2372 100644 --- a/app/src/main/java/com/futo/platformplayer/Utility.kt +++ b/app/src/main/java/com/futo/platformplayer/Utility.kt @@ -279,7 +279,7 @@ fun findNewIndex(originalArr: List, newArr: List, item: T): Int{ } } if(newIndex < 0) - return originalArr.size; + return newArr.size; else return newIndex; } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index da9c321d..4597f94d 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -129,9 +129,9 @@ class SyncSession : IAuthorizable { fun close() { synchronized(_channels) { - _channels.forEach { it.close() } - _channels.clear() - } + _channels.toTypedArray() + }.forEach { it.close() } + _onClose(this) } From 99860785829b6162eb0da2661e1cae93853c1745 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Sat, 3 May 2025 19:30:47 +0200 Subject: [PATCH 084/128] Fix sync crash and responsiveness for subs sync --- .../com/futo/platformplayer/states/StateSync.kt | 14 ++++++++------ .../views/adapters/SubscriptionAdapter.kt | 5 +++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 98c7f8e9..845359b7 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -548,7 +548,7 @@ class StateSync { added.map { it.channel.name }.joinToString("\n")); - if(pack.subscriptions.isNotEmpty()) { + if(pack.subscriptionRemovals.isNotEmpty()) { for (subRemoved in pack.subscriptionRemovals) { val removed = StateSubscriptions.instance.applySubscriptionRemovals(pack.subscriptionRemovals); if(removed.size > 3) { @@ -645,12 +645,14 @@ class StateSync { val subPackage = Serializer.json.decodeFromString(json); handleSyncSubscriptionPackage(session, subPackage); - val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; + if(subPackage.subscriptions.size > 0) { + val newestSub = subPackage.subscriptions.maxOf { it.creationTime }; - val sesData = getSyncSessionData(remotePublicKey); - if(newestSub > sesData.lastSubscription) { - sesData.lastSubscription = newestSub; - saveSyncSessionData(sesData); + val sesData = getSyncSessionData(remotePublicKey); + if (newestSub > sesData.lastSubscription) { + sesData.lastSubscription = newestSub; + saveSyncSessionData(sesData); + } } } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt index ef3f7cb0..33783e67 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/SubscriptionAdapter.kt @@ -37,9 +37,10 @@ class SubscriptionAdapter : RecyclerView.Adapter { _onDatasetChanged = onDatasetChanged; StateSubscriptions.instance.onSubscriptionsChanged.subscribe { _, _ -> if(Looper.myLooper() != Looper.getMainLooper()) - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { updateDataset() } + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { updateDataset() } else - updateDataset(); } + updateDataset(); + } updateDataset(); } From 766f57dc9d893b4ca92c912310e6c9a329ae8606 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 21:04:39 +0200 Subject: [PATCH 085/128] Crashfix. --- .../futo/platformplayer/casting/StateCasting.kt | 14 ++++++++++++-- .../com/futo/platformplayer/states/StateSync.kt | 12 ++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 03145a78..27159131 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -45,6 +45,8 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateSync +import com.futo.platformplayer.states.StateSync.Companion import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.toUrlAddress @@ -228,12 +230,20 @@ class StateCasting { override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") - _nsdManager?.stopServiceDiscovery(this) + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } } override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") - _nsdManager?.stopServiceDiscovery(this) + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } } override fun onServiceFound(service: NsdServiceInfo) { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 98c7f8e9..d2ce807d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -148,12 +148,20 @@ class StateSync { override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") - _nsdManager?.stopServiceDiscovery(this) + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } } override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") - _nsdManager?.stopServiceDiscovery(this) + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } } fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { From 5c96262c75ef1519abb677e7d8a67fdbc38ca905 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 21:05:12 +0200 Subject: [PATCH 086/128] Crashfix. --- .../main/java/com/futo/platformplayer/casting/StateCasting.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 27159131..ff997d3d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -181,7 +181,7 @@ class StateCasting { try { stopServiceDiscovery(it.value) } catch (e: Throwable) { - //Ignored + Logger.w(TAG, "Failed to stop service discovery", e) } } } From 158a27cbae61583f272a10efa1ae63cb19b42435 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 3 May 2025 21:20:19 +0200 Subject: [PATCH 087/128] Casting fixes. --- app/src/main/java/com/futo/platformplayer/Settings.kt | 2 +- .../com/futo/platformplayer/casting/AirPlayCastingDevice.kt | 1 + .../com/futo/platformplayer/casting/ChomecastCastingDevice.kt | 1 + .../java/com/futo/platformplayer/casting/FCastCastingDevice.kt | 2 ++ 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index eb593c5b..81a2b791 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -590,7 +590,7 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4) @Serializable(with = FlexibleBooleanSerializer::class) - var allowIpv6: Boolean = false; + var allowIpv6: Boolean = true; /*TODO: Should we have a different casting quality? @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 2bf6c1ce..0cc1bebc 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e) + delay(1000); } } diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 0b763675..32bee98d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -322,6 +322,7 @@ class ChromecastCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e) + Thread.sleep(1000); } } diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 74440194..dcfaf63d 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.toInetAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -289,6 +290,7 @@ class FCastCastingDevice : CastingDevice { break; } catch (e: Throwable) { Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e) + Thread.sleep(1000); } } From 5b8905c1d2b78cdeea4480b2b9c323a78be92e84 Mon Sep 17 00:00:00 2001 From: Koen J Date: Sun, 4 May 2025 00:50:12 +0200 Subject: [PATCH 088/128] Made ipv6 casting URL fix. --- .../com/futo/platformplayer/Extensions_Syntax.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 442304d4..63f4bd31 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -33,10 +33,18 @@ fun Boolean?.toYesNo(): String { fun InetAddress?.toUrlAddress(): String { return when (this) { is Inet6Address -> { - "[${hostAddress}]" + val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null") + val index = hostAddr.indexOf('%') + if (index != -1) { + val addrPart = hostAddr.substring(0, index) + val scopeId = hostAddr.substring(index + 1) + "[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%' + } else { + "[$hostAddr]" + } } is Inet4Address -> { - hostAddress + this.hostAddress ?: throw Exception("Invalid address: hostAddress is null") } else -> { throw Exception("Invalid address type") From 07e78e0d12410fd850870757cdb0c554a2b6637a Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 5 May 2025 10:41:21 +0200 Subject: [PATCH 089/128] Fixed sending packets without data in sync protocol. --- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 2 -- .../futo/platformplayer/sync/internal/SyncSocketSession.kt | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 5641cf8a..811d89d8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -477,8 +477,6 @@ class StateSync { } }.apply { start() } } - - } private fun getDeviceName(): String { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 5d7db083..910957b0 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -447,14 +447,14 @@ class SyncSocketSession { ensureNotMainThread() synchronized(_sendLockObject) { - ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(2) + ByteBuffer.wrap(_sendBuffer, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(HEADER_SIZE - 4) _sendBuffer.asUByteArray()[4] = opcode _sendBuffer.asUByteArray()[5] = subOpcode _sendBuffer.asUByteArray()[6] = ContentEncoding.Raw.value //Logger.i(TAG, "Encrypting message (opcode = ${opcode}, subOpcode = ${subOpcode}, size = ${HEADER_SIZE})") - val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 0, HEADER_SIZE) + val len = _cipherStatePair!!.sender.encryptWithAd(null, _sendBuffer, 0, _sendBufferEncrypted, 4, HEADER_SIZE) //Logger.i(TAG, "Sending encrypted message (size = ${len})") ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) From 35dc186395ddb16c6f063ab9f317bbe494820fce Mon Sep 17 00:00:00 2001 From: Kelvin Date: Mon, 5 May 2025 11:18:46 +0200 Subject: [PATCH 090/128] Login edgecase fix --- .../com/futo/platformplayer/Extensions_Formatting.kt | 10 ++++++---- .../futo/platformplayer/others/LoginWebViewClient.kt | 2 +- .../others/WebViewRequirementExtractor.kt | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt index b3c14a79..42210c60 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -399,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean { fun String.getSubdomainWildcardQuery(): String { val domainParts = this.split("."); - val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase(); - if(slds.contains(sldParts)) - return "." + domainParts.drop(domainParts.size - 3).joinToString("."); + var wildcardDomain = if(domainParts.size > 2) + "." + domainParts.drop(1).joinToString(".") else - return "." + domainParts.drop(domainParts.size - 2).joinToString("."); + "." + domainParts.joinToString("."); + if(slds.contains(wildcardDomain.lowercase())) + "." + domainParts.joinToString("."); + return wildcardDomain; } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt index 29229d6d..1b31a5fc 100644 --- a/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt +++ b/app/src/main/java/com/futo/platformplayer/others/LoginWebViewClient.kt @@ -113,7 +113,7 @@ class LoginWebViewClient : WebViewClient { //val domainParts = domain!!.split("."); //val cookieDomain = "." + domainParts.drop(domainParts.size - 2).joinToString("."); val cookieDomain = domain!!.getSubdomainWildcardQuery(); - if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) + if(_pluginConfig == null || _pluginConfig.allowUrls.any { it == "everywhere" || domain.matchesDomain(it) }) _authConfig.cookiesToFind?.let { cookiesToFind -> val cookies = cookieString.split(";"); for(cookieStr in cookies) { diff --git a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt index 6b22d1a7..f9edf9e7 100644 --- a/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt +++ b/app/src/main/java/com/futo/platformplayer/others/WebViewRequirementExtractor.kt @@ -67,7 +67,7 @@ class WebViewRequirementExtractor { if(cookieString != null) { //val domainParts = domain!!.split("."); val cookieDomain = domain!!.getSubdomainWildcardQuery()//"." + domainParts.drop(domainParts.size - 2).joinToString("."); - if(allowedUrls.any { it == "everywhere" || it.lowercase().matchesDomain(cookieDomain) }) + if(allowedUrls.any { it == "everywhere" || domain.matchesDomain(it) }) cookiesToFind?.let { cookiesToFind -> val cookies = cookieString.split(";"); for(cookieStr in cookies) { From e12b500144709be1e64ef6be893f1d85e69c9fe8 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 5 May 2025 12:00:04 +0200 Subject: [PATCH 091/128] Sync disabled by default. --- .../java/com/futo/platformplayer/Settings.kt | 2 +- .../activities/SyncHomeActivity.kt | 8 + .../futo/platformplayer/states/StateApp.kt | 24 +- .../futo/platformplayer/states/StateSync.kt | 289 ++++++++++-------- 4 files changed, 200 insertions(+), 123 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 81a2b791..3c52c2c2 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -926,7 +926,7 @@ class Settings : FragmentedStorageFileJson() { @Serializable class Synchronization { @FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1) - var enabled: Boolean = true; + var enabled: Boolean = false; @FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1) var broadcast: Boolean = false; diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index 2b7e3a72..859e6d4e 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -89,6 +89,14 @@ class SyncHomeActivity : AppCompatActivity() { updateEmptyVisibility() } } + + StateSync.instance.confirmStarted(this, { + StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) + }, { + finish() + }, { + StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) + }) } override fun onDestroy() { 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 d15a604a..cdfb308d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -29,6 +29,7 @@ import com.futo.platformplayer.activities.CaptchaActivity import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SettingsActivity +import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed import com.futo.platformplayer.api.media.platforms.js.DevJSClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.background.BackgroundWorker @@ -411,7 +412,27 @@ class StateApp { } if (Settings.instance.synchronization.enabled) { - StateSync.instance.start(context) + StateSync.instance.start(context, { + try { + UIDialogs.toast("Failed to start sync, port in use") + } catch (e: Throwable) { + //Ignored + } + }) + } + + settingsActivityClosed.subscribe { + if (Settings.instance.synchronization.enabled) { + StateSync.instance.start(context, { + try { + UIDialogs.toast("Failed to start sync, port in use") + } catch (e: Throwable) { + //Ignored + } + }) + } else { + StateSync.instance.stop() + } } Logger.onLogSubmitted.subscribe { @@ -707,6 +728,7 @@ class StateApp { StatePlayer.instance.closeMediaSession(); StateCasting.instance.stop(); + StateSync.instance.stop(); StatePlayer.dispose(); Companion.dispose(); _fileLogConsumer?.close(); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 811d89d8..f4c3ecf8 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -7,6 +7,7 @@ import android.os.Build import android.util.Log import com.futo.platformplayer.LittleEndianDataInputStream import com.futo.platformplayer.LittleEndianDataOutputStream +import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity @@ -77,10 +78,11 @@ class StateSync { private var _serverSocket: ServerSocket? = null private var _thread: Thread? = null private var _connectThread: Thread? = null - private var _started = false + @Volatile private var _started = false private val _sessions: MutableMap = mutableMapOf() private val _lastConnectTimesMdns: MutableMap = mutableMapOf() private val _lastConnectTimesIp: MutableMap = mutableMapOf() + private var _serverStarted = false //TODO: Should sync mdns and casting mdns be merged? //TODO: Decrease interval that devices are updated //TODO: Send less data @@ -91,6 +93,117 @@ class StateSync { private var _threadRelay: Thread? = null private val _remotePendingStatusUpdate = mutableMapOf Unit>() private var _nsdManager: NsdManager? = null + private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { + if (!Settings.instance.synchronization.connectDiscovered) { + return + } + + val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return + val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) + val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) + val authorized = isAuthorized(pkey) + + if (authorized && !isConnected(pkey)) { + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] = now + } + + Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") + + try { + connect(syncDeviceInfo) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to connect to $pkey", e) + } + } + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + if(service.host != null) + arrayOf(service.host); + else + arrayOf(); + }, service.port, service.attributes) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) + } + }) + } + } + } + private val _registrationListener = object : NsdManager.RegistrationListener { override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}") @@ -122,7 +235,7 @@ class StateSync { } } - fun start(context: Context) { + fun start(context: Context, onServerBindFail: () -> Unit) { if (_started) { Logger.i(TAG, "Already started.") return @@ -132,116 +245,7 @@ class StateSync { if (Settings.instance.synchronization.connectDiscovered) { _nsdManager?.apply { - discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(regType: String) { - Log.d(TAG, "Service discovery started for $regType") - } - - override fun onDiscoveryStopped(serviceType: String) { - Log.i(TAG, "Discovery stopped: $serviceType") - } - - override fun onServiceLost(service: NsdServiceInfo) { - Log.e(TAG, "service lost: $service") - // TODO: Handle service lost, e.g., remove device - } - - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { - if (!Settings.instance.synchronization.connectDiscovered) { - return - } - - val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return - val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) - val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) - val authorized = isAuthorized(pkey) - - if (authorized && !isConnected(pkey)) { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] = now - } - - Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") - - try { - connect(syncDeviceInfo) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to $pkey", e) - } - } - } - } - - override fun onServiceFound(service: NsdServiceInfo) { - Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") - addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - service.hostAddresses.toTypedArray() - } else { - if(service.host != null) - arrayOf(service.host); - else - arrayOf(); - }, service.port, service.attributes) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceUpdated: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) - } - - override fun onServiceLost() { - Log.v(TAG, "onServiceLost: $service") - // TODO: Handle service lost - } - - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") - } - - override fun onServiceInfoCallbackUnregistered() { - Log.v(TAG, "onServiceInfoCallbackUnregistered") - } - }) - } else { - _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "Resolve failed: $errorCode") - } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "Resolve Succeeded: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) - } - }) - } - } - }) + discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, _discoveryListener) } } @@ -292,6 +296,7 @@ class StateSync { Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") + _serverStarted = true _thread = Thread { try { val serverSocket = ServerSocket(PORT) @@ -305,8 +310,13 @@ class StateSync { session.startAsResponder() } } catch (e: Throwable) { + _serverStarted = false Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e) - UIDialogs.toast("Failed to start sync, port in use") + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + onServerBindFail.invoke() + } + } finally { + _serverStarted = false } }.apply { start() } @@ -479,6 +489,31 @@ class StateSync { } } + fun showFailedToBindDialogIfNecessary(context: Context) { + if (!_serverStarted) { + try { + UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use") + } catch (e: Throwable) { + //Ignored + } + } + } + + fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) { + if (!_started) { + UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { + Settings.instance.synchronization.enabled = true + StateSync.instance.start(context, onServerBindFail) + Settings.instance.save() + onStarted.invoke() + }, { + onNotStarted.invoke() + }) + } else { + onStarted.invoke() + } + } + private fun getDeviceName(): String { val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } val model = Build.MODEL @@ -1034,19 +1069,31 @@ class StateSync { fun stop() { _started = false - _nsdManager?.unregisterService(_registrationListener) + try { + _nsdManager?.stopServiceDiscovery(_discoveryListener) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to stop discovery listener", e) + } + + try { + _nsdManager?.unregisterService(_registrationListener) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to unregister service", e) + } + + _relaySession?.stop() _serverSocket?.close() _serverSocket = null - _thread?.interrupt() - _thread = null - _connectThread?.interrupt() - _connectThread = null - _threadRelay?.interrupt() - _threadRelay = null + synchronized(_sessions) { + _sessions.values.forEach { it.close() } + _sessions.clear() + } - _relaySession?.stop() + _thread = null + _connectThread = null + _threadRelay = null _relaySession = null } From 1e790d1aa9bcf767e9e0495e9e900efc3cfc765b Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 5 May 2025 13:34:52 +0200 Subject: [PATCH 092/128] Added toggle to be able to disable local functionality for sync. Sync now automatically closes when pairing is successful. Pairing in progress layouts now properly show again. --- .../java/com/futo/platformplayer/Settings.kt | 3 ++ .../activities/SyncPairActivity.kt | 15 ++++--- .../futo/platformplayer/states/StateSync.kt | 44 ++++++++++--------- app/src/main/res/layout/view_sync.xml | 6 +-- app/src/main/res/values/strings.xml | 2 + 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 3c52c2c2..e105d119 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -948,6 +948,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3) var connectLocalDirectThroughRelay: Boolean = true; + + @FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3) + var localConnections: Boolean = true; } @FormField(R.string.info, FieldForm.GROUP, -1, 21) diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt index 5e808977..c34e8362 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt @@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() { _layoutPairingSuccess.setOnClickListener { _layoutPairingSuccess.visibility = View.GONE + finish() } _layoutPairingError.setOnClickListener { _layoutPairingError.visibility = View.GONE @@ -111,9 +112,15 @@ class SyncPairActivity : AppCompatActivity() { try { StateSync.instance.connect(deviceInfo) { complete, message -> lifecycleScope.launch(Dispatchers.Main) { - if (complete != null && complete) { - _layoutPairingSuccess.visibility = View.VISIBLE - _layoutPairing.visibility = View.GONE + if (complete != null) { + if (complete) { + _layoutPairingSuccess.visibility = View.VISIBLE + _layoutPairing.visibility = View.GONE + } else { + _textError.text = message + _layoutPairingError.visibility = View.VISIBLE + _layoutPairing.visibility = View.GONE + } } else { _textPairingStatus.text = message } @@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() { _textError.text = e.message _layoutPairing.visibility = View.GONE Logger.e(TAG, "Failed to pair", e) - } finally { - _layoutPairing.visibility = View.GONE } } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index f4c3ecf8..2b7559c9 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -296,29 +296,31 @@ class StateSync { Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") - _serverStarted = true - _thread = Thread { - try { - val serverSocket = ServerSocket(PORT) - _serverSocket = serverSocket + if (Settings.instance.synchronization.localConnections) { + _serverStarted = true + _thread = Thread { + try { + val serverSocket = ServerSocket(PORT) + _serverSocket = serverSocket - Log.i(TAG, "Running on port ${PORT} (TCP)") + Log.i(TAG, "Running on port ${PORT} (TCP)") - while (_started) { - val socket = serverSocket.accept() - val session = createSocketSession(socket, true) - session.startAsResponder() + while (_started) { + val socket = serverSocket.accept() + val session = createSocketSession(socket, true) + session.startAsResponder() + } + } catch (e: Throwable) { + _serverStarted = false + Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e) + StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { + onServerBindFail.invoke() + } + } finally { + _serverStarted = false } - } catch (e: Throwable) { - _serverStarted = false - Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e) - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - onServerBindFail.invoke() - } - } finally { - _serverStarted = false - } - }.apply { start() } + }.apply { start() } + } if (Settings.instance.synchronization.connectLast) { _connectThread = Thread { @@ -490,7 +492,7 @@ class StateSync { } fun showFailedToBindDialogIfNecessary(context: Context) { - if (!_serverStarted) { + if (!_serverStarted && Settings.instance.synchronization.localConnections) { try { UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use") } catch (e: Throwable) { diff --git a/app/src/main/res/layout/view_sync.xml b/app/src/main/res/layout/view_sync.xml index 461b8189..cdba0146 100644 --- a/app/src/main/res/layout/view_sync.xml +++ b/app/src/main/res/layout/view_sync.xml @@ -57,15 +57,15 @@ + android:padding="12dp" /> Allow devices to be connected to through the relay Connect direct through relay Allow devices to be directly locally connected to through information discovered from the relay + Local connections + Allow device to be directly locally connected Gesture controls Volume slider Enable slide gesture to change volume From a1ce5eda4343242cd84a3cb62497963fcc896f92 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 6 May 2025 11:53:30 +0200 Subject: [PATCH 093/128] Fix synced ImageVariables showing black images --- .../main/java/com/futo/platformplayer/models/ImageVariable.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt index 97fe6408..83e2b45f 100644 --- a/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt +++ b/app/src/main/java/com/futo/platformplayer/models/ImageVariable.kt @@ -29,7 +29,7 @@ data class ImageVariable( Glide.with(imageView) .load(bitmap) .into(imageView) - } else if(resId != null) { + } else if(resId != null && resId > 0) { Glide.with(imageView) .load(resId) .into(imageView) From d686fa327bbed1215de9dbd1945008caa4e37fa4 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 6 May 2025 12:40:24 +0200 Subject: [PATCH 094/128] Incorrect gzip compression --- .../sync/internal/SyncSocketSession.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 910957b0..ab0a0da6 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -3,6 +3,7 @@ package com.futo.platformplayer.sync.internal import android.os.Build import com.futo.platformplayer.LittleEndianDataInputStream import com.futo.platformplayer.LittleEndianDataOutputStream +import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.noise.protocol.CipherStatePair @@ -11,6 +12,7 @@ import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion import kotlinx.coroutines.CompletableDeferred +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.OutputStream @@ -24,6 +26,7 @@ import java.nio.ByteOrder import java.util.Base64 import java.util.Locale import java.util.concurrent.ConcurrentHashMap +import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream import kotlin.math.min import kotlin.system.measureTimeMillis @@ -837,12 +840,13 @@ class SyncSocketSession { if (!isGzipSupported) throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).") - val compressedStream = ByteArrayOutputStream() - GZIPOutputStream(compressedStream).use { gzipStream -> - gzipStream.write(data.array(), data.position(), data.remaining()) - gzipStream.finish() + val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining()); + var outputStream = ByteArrayOutputStream(); + GZIPInputStream(compressedStream).use { gzipStream -> + gzipStream.copyToOutputStream(outputStream); + gzipStream.close(); } - data = ByteBuffer.wrap(compressedStream.toByteArray()) + data = ByteBuffer.wrap(outputStream.toByteArray()) } when (opcode) { From b419e033f372340eb2997e7a19b6108f52bbaf23 Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 6 May 2025 13:04:42 +0200 Subject: [PATCH 095/128] Casting device more clearly communicates when not ready. Implemented backoffs for SyncServer. --- .../com/futo/platformplayer/states/StateSync.kt | 8 +++++++- .../views/adapters/DeviceViewHolder.kt | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 2b7559c9..70bdc8bc 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -66,6 +66,7 @@ import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.Base64 import java.util.Locale +import kotlin.math.min import kotlin.system.measureTimeMillis class StateSync { @@ -372,6 +373,9 @@ class StateSync { if (Settings.instance.synchronization.discoverThroughRelay) { _threadRelay = Thread { + var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) + var backoffIndex = 0; + while (_started) { try { Log.i(TAG, "Starting relay session...") @@ -417,6 +421,8 @@ class StateSync { }, onClose = { socketClosed = true }, onHandshakeComplete = { relaySession -> + backoffIndex = 0 + Thread { try { while (_started && !socketClosed) { @@ -484,7 +490,7 @@ class StateSync { } finally { _relaySession?.stop() _relaySession = null - Thread.sleep(5000) + Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) } } }.apply { start() } diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt index d5060fe5..133dd26b 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/DeviceViewHolder.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import androidx.core.view.isVisible +import com.futo.platformplayer.UIDialogs class DeviceViewHolder : ViewHolder { private val _layoutDevice: FrameLayout; @@ -55,9 +56,17 @@ class DeviceViewHolder : ViewHolder { val connect = { device?.let { dev -> - StateCasting.instance.activeDevice?.stopCasting(); - StateCasting.instance.connectDevice(dev); - onConnect.emit(dev); + if (dev.isReady) { + StateCasting.instance.activeDevice?.stopCasting() + StateCasting.instance.connectDevice(dev) + onConnect.emit(dev) + } else { + try { + view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") } + } catch (e: Throwable) { + //Ignored + } + } } } @@ -84,7 +93,7 @@ class DeviceViewHolder : ViewHolder { } _textName.text = d.name; - _imageOnline.visibility = if (isOnlineDevice) View.VISIBLE else View.GONE + _imageOnline.visibility = if (isOnlineDevice && d.isReady) View.VISIBLE else View.GONE if (!d.isReady) { _imageLoader.visibility = View.GONE; From 3cf8abd409f4b89981d0a2c57a5820362ae80c6f Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 6 May 2025 13:23:00 +0200 Subject: [PATCH 096/128] Fix racecondition watchlater adds --- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 2b7559c9..ae72b57e 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -772,8 +772,8 @@ class StateSync { for(video in pack.videos) { val existing = allExisting.firstOrNull { it.url == video.url }; val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN; - - if(existing == null) { + val removalTime = StatePlaylists.instance.getWatchLaterRemovalTime(video.url) ?: OffsetDateTime.MIN; + if(existing == null && time > removalTime) { StatePlaylists.instance.addToWatchLater(video, false); if(time > OffsetDateTime.MIN) StatePlaylists.instance.setWatchLaterAddTime(video.url, time); From ac3a8da002e55e879fa69fd0b286798819fd2e5e Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 6 May 2025 16:54:58 +0200 Subject: [PATCH 097/128] Various fixes for android to android pairing. --- .../futo/platformplayer/Extensions_Syntax.kt | 19 +++++++++ .../platformplayer/states/StatePlaylists.kt | 3 +- .../futo/platformplayer/states/StateSync.kt | 41 ++++++++----------- .../sync/internal/SyncSocketSession.kt | 23 +++++++---- 4 files changed, 53 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt index 63f4bd31..f1e63366 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Syntax.kt @@ -7,6 +7,9 @@ import java.net.InetAddress import java.net.URI import java.net.URISyntaxException import java.net.URLEncoder +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset //Syntax sugaring inline fun Any.assume(): T?{ @@ -50,4 +53,20 @@ fun InetAddress?.toUrlAddress(): String { throw Exception("Invalid address type") } } +} + +fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime { + if (this == null || this < 0) + return OffsetDateTime.MIN + if(this > 4070912400) + return OffsetDateTime.MAX; + return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC) +} + +fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime { + if (this == null || this < 0) + return OffsetDateTime.MIN + if(this > 4070912400) + return OffsetDateTime.MAX; + return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC) } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt index e2054c90..fe194b2d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -19,6 +19,7 @@ import com.futo.platformplayer.exceptions.ReconstructionException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.Playlist +import com.futo.platformplayer.sToOffsetDateTimeUTC import com.futo.platformplayer.smartMerge import com.futo.platformplayer.states.StateSubscriptionGroups.Companion import com.futo.platformplayer.stores.FragmentedStorage @@ -85,7 +86,7 @@ class StatePlaylists { if(value.isEmpty()) return OffsetDateTime.MIN; val tryParse = value.toLongOrNull() ?: 0; - return OffsetDateTime.ofInstant(Instant.ofEpochSecond(tryParse), ZoneOffset.UTC); + return tryParse.sToOffsetDateTimeUTC(); } private fun setWatchLaterReorderTime() { val now = OffsetDateTime.now(ZoneOffset.UTC); diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 70bdc8bc..dbd3660a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -25,6 +25,7 @@ import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.sToOffsetDateTimeUTC import com.futo.platformplayer.smartMerge import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.StringStringMapStorage @@ -731,7 +732,7 @@ class StateSync { } for(removal in pack.groupRemovals) { val creation = StateSubscriptionGroups.instance.getSubscriptionGroup(removal.key); - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + val removalTime = removal.value.sToOffsetDateTimeUTC(); if(creation != null && creation.creationTime < removalTime) StateSubscriptionGroups.instance.deleteSubscriptionGroup(removal.key, false); } @@ -759,7 +760,7 @@ class StateSync { } for(removal in pack.playlistRemovals) { val creation = StatePlaylists.instance.getPlaylist(removal.key); - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value, 0), ZoneOffset.UTC); + val removalTime = removal.value.sToOffsetDateTimeUTC(); if(creation != null && creation.dateCreation < removalTime) StatePlaylists.instance.removePlaylist(creation, false); @@ -777,7 +778,7 @@ class StateSync { val allExisting = StatePlaylists.instance.getWatchLater(); for(video in pack.videos) { val existing = allExisting.firstOrNull { it.url == video.url }; - val time = if(pack.videoAdds != null && pack.videoAdds.containsKey(video.url)) OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.videoAdds[video.url] ?: 0), ZoneOffset.UTC) else OffsetDateTime.MIN; + val time = if(pack.videoAdds.containsKey(video.url)) (pack.videoAdds[video.url] ?: 0).sToOffsetDateTimeUTC() else OffsetDateTime.MIN; if(existing == null) { StatePlaylists.instance.addToWatchLater(video, false); @@ -788,12 +789,12 @@ class StateSync { for(removal in pack.videoRemovals) { val watchLater = allExisting.firstOrNull { it.url == removal.key } ?: continue; val creation = StatePlaylists.instance.getWatchLaterRemovalTime(watchLater.url) ?: OffsetDateTime.MIN; - val removalTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(removal.value), ZoneOffset.UTC); + val removalTime = removal.value.sToOffsetDateTimeUTC() if(creation < removalTime) StatePlaylists.instance.removeFromWatchLater(watchLater, false, removalTime); } - val packReorderTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(pack.reorderTime), ZoneOffset.UTC); + val packReorderTime = pack.reorderTime.sToOffsetDateTimeUTC() val localReorderTime = StatePlaylists.instance.getWatchLaterLastReorderTime(); if(localReorderTime < packReorderTime && pack.ordering != null) { StatePlaylists.instance.updateWatchLaterOrdering(smartMerge(pack.ordering!!, StatePlaylists.instance.getWatchLaterOrdering()), true); @@ -830,22 +831,15 @@ class StateSync { } } - private fun onAuthorized(remotePublicKey: String) { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") - } - } - - private fun onUnuthorized(remotePublicKey: String) { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") - } - } - - private fun createNewSyncSession(remotePublicKey: String, remoteDeviceName: String?): SyncSession { + private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession { + val remotePublicKey = rpk.base64ToByteArray().toBase64() return SyncSession( remotePublicKey, onAuthorized = { it, isNewlyAuthorized, isNewSession -> + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") + } + if (!isNewSession) { return@SyncSession } @@ -857,7 +851,6 @@ class StateSync { } Logger.i(TAG, "$remotePublicKey authorized (name: ${it.displayName})") - onAuthorized(remotePublicKey) _authorizedDevices.addDistinct(remotePublicKey) _authorizedDevices.save() deviceUpdatedOrAdded.emit(it.remotePublicKey, it) @@ -865,10 +858,12 @@ class StateSync { checkForSync(it); }, onUnauthorized = { - unauthorize(remotePublicKey) + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") + } + unauthorize(remotePublicKey) Logger.i(TAG, "$remotePublicKey unauthorized (name: ${it.displayName})") - onUnuthorized(remotePublicKey) synchronized(_sessions) { it.close() @@ -1117,7 +1112,7 @@ class StateSync { runBlocking { if (onStatusUpdate != null) { synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate[deviceInfo.publicKey] = onStatusUpdate + _remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate } } relaySession.startRelayedChannel(deviceInfo.publicKey, APP_ID, deviceInfo.pairingCode) @@ -1136,7 +1131,7 @@ class StateSync { val session = createSocketSession(socket, false) if (onStatusUpdate != null) { synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate[publicKey] = onStatusUpdate + _remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index ab0a0da6..1f3f9fcf 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -11,6 +11,8 @@ import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 import kotlinx.coroutines.CompletableDeferred import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -169,7 +171,7 @@ class SyncSocketSession { var totalBytesReceived: Int = 0 while (totalBytesReceived < size) { val bytesReceived = _inputStream.read(buffer, offset + totalBytesReceived, size - totalBytesReceived) - if (bytesReceived == 0) + if (bytesReceived <= 0) throw Exception("Socket disconnected") totalBytesReceived += bytesReceived } @@ -291,7 +293,7 @@ class SyncSocketSession { _cipherStatePair = initiator.split() val remoteKeyBytes = ByteArray(initiator.remotePublicKey.publicKeyLength) initiator.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + _remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes).base64ToByteArray().toBase64() } private fun handshakeAsResponder(): Boolean { @@ -345,7 +347,7 @@ class SyncSocketSession { _outputStream.write(responseBuffer, 0, 4 + responseLength) _cipherStatePair = responder.split() - _remotePublicKey = remotePublicKey + _remotePublicKey = remotePublicKey.base64ToByteArray().toBase64() return true } @@ -440,7 +442,7 @@ class SyncSocketSession { ByteBuffer.wrap(_sendBufferEncrypted, 0, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(len) _outputStream.write(_sendBufferEncrypted, 0, 4 + len) } - //Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") + Logger.v(TAG, "_outputStream.write (opcode: ${opcode}, subOpcode: ${subOpcode}, processedData.remaining(): ${processedData.remaining()}, sendDuration: ${sendDuration})") } } } @@ -840,11 +842,14 @@ class SyncSocketSession { if (!isGzipSupported) throw Exception("Failed to handle packet, gzip is not supported for this opcode (opcode = ${opcode}, subOpcode = ${subOpcode}, data.length = ${data.remaining()}).") - val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining()); - var outputStream = ByteArrayOutputStream(); + val compressedStream = ByteArrayInputStream(data.array(), data.position(), data.remaining()) + val outputStream = ByteArrayOutputStream() GZIPInputStream(compressedStream).use { gzipStream -> - gzipStream.copyToOutputStream(outputStream); - gzipStream.close(); + val buffer = ByteArray(8192) // 8KB buffer + var bytesRead: Int + while (gzipStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } } data = ByteBuffer.wrap(outputStream.toByteArray()) } @@ -933,7 +938,7 @@ class SyncSocketSession { throw Exception("After sync stream end, the stream must be complete") } - handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, contentEncoding, sourceChannel) + handlePacket(syncStream.opcode, syncStream.subOpcode, syncStream.getBytes().let { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN) }, syncStream.contentEncoding, sourceChannel) } } Opcode.DATA.value -> { From f9caab48c416cd483fc6ecd1391d9348db7fcb1e Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 10:12:12 +0200 Subject: [PATCH 098/128] Added insensitivity to base64 formats. --- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 2 +- .../java/com/futo/platformplayer/sync/internal/Channel.kt | 4 +++- .../futo/platformplayer/sync/internal/SyncSocketSession.kt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index c080170f..57c9f93e 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -1115,7 +1115,7 @@ class StateSync { _remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate } } - relaySession.startRelayedChannel(deviceInfo.publicKey, APP_ID, deviceInfo.pairingCode) + relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), APP_ID, deviceInfo.pairingCode) } } else { throw e diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 0b3b710d..1303d741 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -5,6 +5,8 @@ import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateSync +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder @@ -82,7 +84,7 @@ class ChannelRelayed( override var authorizable: IAuthorizable? = null val isAuthorized: Boolean get() = authorizable?.isAuthorized ?: false var connectionId: Long = 0L - override var remotePublicKey: String? = publicKey + override var remotePublicKey: String? = publicKey.base64ToByteArray().toBase64() private set override var remoteVersion: Int? = null private set diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 1f3f9fcf..bd6f70db 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -998,7 +998,7 @@ class SyncSocketSession { suspend fun startRelayedChannel(publicKey: String, appId: UInt = 0u, pairingCode: String? = null): ChannelRelayed? { val requestId = generateRequestId() val deferred = CompletableDeferred() - val channel = ChannelRelayed(this, _localKeyPair, publicKey, true) + val channel = ChannelRelayed(this, _localKeyPair, publicKey.base64ToByteArray().toBase64(), true) _onNewChannel?.invoke(this, channel) _pendingChannels[requestId] = channel to deferred try { From 254df7211c2a5345b77fca963985f3d17f435689 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 10:36:32 +0200 Subject: [PATCH 099/128] Fixed datetime checking related to playlists on android. --- .../platformplayer/serializers/OffsetDateTimeSerializer.kt | 3 ++- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index faee4e3b..9e9d112b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.serializers +import com.futo.platformplayer.sToOffsetDateTimeUTC import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -37,7 +38,7 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MAX; else if(epochSecond < -9999999999) return OffsetDateTime.MIN; - return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); + return epochSecond.sToOffsetDateTimeUTC() } } class OffsetDateTimeStringSerializer : KSerializer { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 57c9f93e..7e65055a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -749,7 +749,7 @@ class StateSync { if(existing == null) StatePlaylists.instance.createOrUpdatePlaylist(playlist, false); - else if(existing.dateUpdate.toLocalDateTime() < playlist.dateUpdate.toLocalDateTime()) { + else if(existing.dateUpdate < playlist.dateUpdate) { existing.dateUpdate = playlist.dateUpdate; existing.name = playlist.name; existing.videos = playlist.videos; From 77bae98d77f65ffe6509ebf6ca4cbd1f6f3c9b62 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 10:57:39 +0200 Subject: [PATCH 100/128] Private mode visibility no longer overlays pip/minimized video. --- .../platformplayer/activities/MainActivity.kt | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 9aaa223d..43907037 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -186,6 +186,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private var _isVisible = true; private var _wasStopped = false; + private var _privateModeEnabled = false + private var _pictureInPictureEnabled = false + private var _isFullscreen = false + private var _isMinimized = false private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) @@ -363,14 +367,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _buttonIncognito.alpha = 0f; StateApp.instance.privateModeChanged.subscribe { //Messing with visibility causes some issues with layout ordering? - if (it) { - _buttonIncognito.elevation = 99f; - _buttonIncognito.alpha = 1f; - } else { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } + _privateModeEnabled = it + updatePrivateModeVisibility() } + _buttonIncognito.setOnClickListener { if (!StateApp.instance.privateMode) return@setOnClickListener; @@ -387,19 +387,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { }; _fragVideoDetail.onFullscreenChanged.subscribe { Logger.i(TAG, "onFullscreenChanged ${it}"); + _isFullscreen = it + updatePrivateModeVisibility() + } - if (it) { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } else { - if (StateApp.instance.privateMode) { - _buttonIncognito.elevation = 99f; - _buttonIncognito.alpha = 1f; - } else { - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; - } - } + _fragVideoDetail.onMinimize.subscribe { + _isMinimized = true + updatePrivateModeVisibility() + } + + _fragVideoDetail.onMaximized.subscribe { + _isMinimized = false + updatePrivateModeVisibility() } StatePlayer.instance.also { @@ -641,6 +640,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } + fun updatePrivateModeVisibility() { + if (_privateModeEnabled && !_pictureInPictureEnabled && !_isFullscreen && !_isMinimized) { + _buttonIncognito.elevation = 99f; + _buttonIncognito.alpha = 1f; + _buttonIncognito.layoutParams = _buttonIncognito.layoutParams.apply { + + } + } else { + _buttonIncognito.elevation = -99f; + _buttonIncognito.alpha = 0f; + } + } + override fun onResume() { super.onResume(); Logger.v(TAG, "onResume") @@ -1064,6 +1076,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop") _fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig); Logger.v(TAG, "onPictureInPictureModeChanged Ready"); + + _pictureInPictureEnabled = isInPictureInPictureMode + updatePrivateModeVisibility() } override fun onDestroy() { From a49db10ade71a6097d63086bb39d620a72d5da8d Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 11:01:25 +0200 Subject: [PATCH 101/128] Fixed issue 2163. --- .../fragment/mainactivity/main/VideoDetailView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7176d125..d15013f9 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 @@ -571,7 +571,7 @@ class VideoDetailView : ConstraintLayout { _player.setIsReplay(true); val searchVideo = StatePlayer.instance.getCurrentQueueItem(); - if (searchVideo is SerializedPlatformVideo?) { + if (searchVideo is SerializedPlatformVideo? && Settings.instance.playback.deleteFromWatchLaterAuto) { searchVideo?.let { StatePlaylists.instance.removeFromWatchLater(it) }; } From c07fcdd489449a9a498242d6a0d1a45e3d23bde7 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 11:22:54 +0200 Subject: [PATCH 102/128] Prevent going into picture in picture when clicking add sources. --- .../fragment/mainactivity/main/SourcesFragment.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt index 39c87028..08169bed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourcesFragment.kt @@ -18,6 +18,7 @@ import com.futo.platformplayer.activities.AddSourceOptionsActivity import com.futo.platformplayer.api.media.IPlatformClient import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.stores.FragmentedStorage @@ -44,6 +45,7 @@ class SourcesFragment : MainFragment() { if(topBar is AddTopBarFragment) { (topBar as AddTopBarFragment).onAdd.clear(); (topBar as AddTopBarFragment).onAdd.subscribe { + StateApp.instance.preventPictureInPicture.emit(); startActivity(Intent(requireContext(), AddSourceOptionsActivity::class.java)); }; } @@ -93,6 +95,7 @@ class SourcesFragment : MainFragment() { findViewById(R.id.plugin_disclaimer).isVisible = false; } findViewById(R.id.button_add_sources).onClick.subscribe { + StateApp.instance.preventPictureInPicture.emit(); fragment.startActivity(Intent(context, AddSourceOptionsActivity::class.java)); }; From fc59b841d674734d1ff30903e275213ba3a64128 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 12:49:39 +0200 Subject: [PATCH 103/128] History now filters out videos of plugins that are not enabled. History now has a list of filters to filter specific plugins. History now shows an icon of which platform a specific history video is on. --- .../mainactivity/main/HistoryFragment.kt | 52 +++++++++++++++++-- .../platformplayer/states/StatePlatform.kt | 13 +++++ .../views/adapters/HistoryListViewHolder.kt | 4 ++ .../views/platform/PlatformIndicator.kt | 11 ++++ app/src/main/res/layout/fragment_history.xml | 19 +++++++ app/src/main/res/layout/list_history.xml | 9 ++++ 6 files changed, 103 insertions(+), 5 deletions(-) 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 2da46620..87de37b9 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 @@ -15,6 +15,8 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.futo.platformplayer.* +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.IAsyncPager import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.constructs.TaskHandler @@ -22,10 +24,14 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.states.StateHistory +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer +import com.futo.platformplayer.states.StatePlugins +import com.futo.platformplayer.views.ToggleBar import com.futo.platformplayer.views.adapters.HistoryListViewHolder import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.others.TagsView +import com.futo.platformplayer.views.others.Toggle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -68,6 +74,8 @@ class HistoryFragment : MainFragment() { private var _pager: IPager? = null; private val _results = arrayListOf(); private var _loading = false; + private val _toggleBar: ToggleBar + private var _togglePluginsDisabled = hashSetOf() private var _automaticNextPageCounter = 0; @@ -79,6 +87,7 @@ class HistoryFragment : MainFragment() { _clearSearch = findViewById(R.id.button_clear_search); _editSearch = findViewById(R.id.edit_search); _tagsView = findViewById(R.id.tags_text); + _toggleBar = findViewById(R.id.toggle_bar) _tagsView.setPairs(listOf( Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_24_hours), 24L * 60L), @@ -88,6 +97,22 @@ class HistoryFragment : MainFragment() { Pair(context.getString(R.string.all_time), -1L) )); + val toggles = StatePlatform.instance.getEnabledClients() + .filter { it is JSClient } + .map { plugin -> + val pluginName = plugin.name.lowercase() + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(pluginName), { view, active -> + if (active) { + _togglePluginsDisabled.remove(pluginName) + } else { + _togglePluginsDisabled.add(pluginName) + } + + filtersChanged() + }).withTag("plugins") + }.toTypedArray() + _toggleBar.setToggles(*toggles) + _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), { _results.size }, { view, _ -> @@ -162,14 +187,15 @@ class HistoryFragment : MainFragment() { else it.nextPage(); - return@TaskHandler it.getResults(); + return@TaskHandler filterResults(it.getResults()); }).success { setLoading(false); val posBefore = _results.size; - _results.addAll(it); - _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), it.size); - ensureEnoughContentVisible(it) + val res = filterResults(it) + _results.addAll(res); + _adapter.notifyItemRangeInserted(_adapter.childToParentPosition(posBefore), res.size); + ensureEnoughContentVisible(res) }.exception { Logger.w(TAG, "Failed to load next page.", it); UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { @@ -178,6 +204,10 @@ class HistoryFragment : MainFragment() { }; } + private fun filtersChanged() { + updatePager() + } + private fun updatePager() { val query = _editSearch.text.toString(); if (_editSearch.text.isNotEmpty()) { @@ -246,11 +276,23 @@ class HistoryFragment : MainFragment() { _adapter.setLoading(loading); } + private fun filterResults(a: List): List { + //TODO: Not an ideal way to do this, plugin id would be better but it is null for HistoryVideo ? + val enabledPluginNames = StatePlatform.instance.getEnabledClients().map { it.name.lowercase() }.toHashSet() + val disabledPluginNames = _togglePluginsDisabled.toHashSet() + return a.filter { + val pluginName = it.video.id.platform.lowercase() + if (!enabledPluginNames.contains(pluginName)) + return@filter false + return@filter !disabledPluginNames.contains(pluginName) + }; + } + private fun loadPagerInternal(pager: IPager) { Logger.i(TAG, "Setting new internal pager on feed"); _results.clear(); - val toAdd = pager.getResults(); + val toAdd = filterResults(pager.getResults()) _results.addAll(toAdd); _adapter.notifyDataSetChanged(); ensureEnoughContentVisible(toAdd) 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 389d8a5e..be364967 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -97,6 +97,7 @@ class StatePlatform { private val _icons : HashMap = HashMap(); + private val _iconsByName : HashMap = HashMap(); val hasClients: Boolean get() = _availableClients.size > 0; @@ -192,6 +193,7 @@ class StatePlatform { _availableClients.clear(); _icons.clear(); + _iconsByName.clear() _icons[StateDeveloper.DEV_ID] = ImageVariable(null, R.drawable.ic_security_red); StatePlugins.instance.updateEmbeddedPlugins(context); @@ -200,6 +202,8 @@ class StatePlatform { for (plugin in StatePlugins.instance.getPlugins()) { _icons[plugin.config.id] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: ImageVariable(plugin.config.absoluteIconUrl, null); + _iconsByName[plugin.config.name.lowercase()] = StatePlugins.instance.getPluginIconOrNull(plugin.config.id) ?: + ImageVariable(plugin.config.absoluteIconUrl, null); val client = JSClient(context, plugin); client.onCaptchaException.subscribe { c, ex -> @@ -299,6 +303,15 @@ class StatePlatform { return null; } + fun getPlatformIconByName(name: String?) : ImageVariable? { + if(name == null) + return null; + val nameLower = name.lowercase() + if(_iconsByName.containsKey(nameLower)) + return _iconsByName[nameLower]; + return null; + } + fun setPlatformOrder(platformOrder: List) { _platformOrderPersistent.values.clear(); _platformOrderPersistent.values.addAll(platformOrder); diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt index 2f018c9d..0072492f 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt @@ -17,6 +17,7 @@ import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.others.ProgressBar +import com.futo.platformplayer.views.platform.PlatformIndicator class HistoryListViewHolder : ViewHolder { private val _root: ConstraintLayout; @@ -30,6 +31,7 @@ class HistoryListViewHolder : ViewHolder { private val _imageRemove: ImageButton; private val _textHeader: TextView; private val _timeBar: ProgressBar; + private val _thumbnailPlatform: PlatformIndicator var video: HistoryVideo? = null private set; @@ -47,6 +49,7 @@ class HistoryListViewHolder : ViewHolder { _textVideoDuration = itemView.findViewById(R.id.thumbnail_duration); _containerDuration = itemView.findViewById(R.id.thumbnail_duration_container); _containerLive = itemView.findViewById(R.id.thumbnail_live_container); + _thumbnailPlatform = itemView.findViewById(R.id.thumbnail_platform) _imageRemove = itemView.findViewById(R.id.image_trash); _textHeader = itemView.findViewById(R.id.text_header); _timeBar = itemView.findViewById(R.id.time_bar); @@ -72,6 +75,7 @@ class HistoryListViewHolder : ViewHolder { _textName.text = v.video.name; _textAuthor.text = v.video.author.name; _textVideoDuration.text = v.video.duration.toHumanTime(false); + _thumbnailPlatform.setPlatformFromClientName(v.video.id.platform) if(v.video.isLive) { _containerDuration.visibility = View.GONE; diff --git a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt index 8ef8520c..b8c1f0eb 100644 --- a/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt +++ b/app/src/main/java/com/futo/platformplayer/views/platform/PlatformIndicator.kt @@ -22,4 +22,15 @@ class PlatformIndicator : androidx.appcompat.widget.AppCompatImageView { setImageResource(0); } } + fun setPlatformFromClientName(name: String?) { + if(name == null) + setImageResource(0); + else { + val result = StatePlatform.instance.getPlatformIconByName(name); + if (result != null) + result.setImageView(this); + else + setImageResource(0); + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml index 019bf08c..ec5bbd5f 100644 --- a/app/src/main/res/layout/fragment_history.xml +++ b/app/src/main/res/layout/fragment_history.xml @@ -94,6 +94,25 @@ android:id="@+id/tags_text" android:layout_width="match_parent" android:layout_height="wrap_content" /> + + + + diff --git a/app/src/main/res/layout/list_history.xml b/app/src/main/res/layout/list_history.xml index 88a10b81..e3ccd053 100644 --- a/app/src/main/res/layout/list_history.xml +++ b/app/src/main/res/layout/list_history.xml @@ -117,6 +117,15 @@ app:radiusBottomRight="4dp" app:radiusTopLeft="0dp" app:radiusTopRight="0dp" /> + + Date: Wed, 7 May 2025 12:57:44 +0200 Subject: [PATCH 104/128] Confirming sync now brings you back to the device list. --- app/src/main/java/com/futo/platformplayer/states/StateSync.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 7e65055a..f0ac4456 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -997,6 +997,8 @@ class StateSync { try { syncSession.authorize() Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") + + activity.finish() } catch (e: Throwable) { Logger.e(TAG, "Failed to send authorize", e) } From 879aab0d9908b21fb3e81fa44821726f9d84bb9e Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 13:08:13 +0200 Subject: [PATCH 105/128] Added progress bar to playlist items. --- .../adapters/VideoListEditorViewHolder.kt | 7 +++++++ app/src/main/res/layout/list_playlist.xml | 19 ++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt index 77df0665..42cef197 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/VideoListEditorViewHolder.kt @@ -17,9 +17,11 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.states.StateDownloads +import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.toHumanNowDiffString import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime +import com.futo.platformplayer.views.others.ProgressBar import com.futo.platformplayer.views.platform.PlatformIndicator class VideoListEditorViewHolder : ViewHolder { @@ -36,6 +38,7 @@ class VideoListEditorViewHolder : ViewHolder { private val _imageDragDrop: ImageButton; private val _platformIndicator: PlatformIndicator; private val _layoutDownloaded: FrameLayout; + private val _timeBar: ProgressBar var video: IPlatformVideo? = null private set; @@ -59,6 +62,7 @@ class VideoListEditorViewHolder : ViewHolder { _imageOptions = view.findViewById(R.id.image_settings); _imageDragDrop = view.findViewById(R.id.image_drag_drop); _platformIndicator = view.findViewById(R.id.thumbnail_platform); + _timeBar = view.findViewById(R.id.time_bar); _layoutDownloaded = view.findViewById(R.id.layout_downloaded); _imageDragDrop.setOnTouchListener { _, event -> @@ -93,6 +97,9 @@ class VideoListEditorViewHolder : ViewHolder { _textAuthor.text = v.author.name; _textVideoDuration.text = v.duration.toHumanTime(false); + val historyPosition = StateHistory.instance.getHistoryPosition(v.url) + _timeBar.progress = historyPosition.toFloat() / v.duration.toFloat(); + if(v.isLive) { _containerDuration.visibility = View.GONE; _containerLive.visibility = View.VISIBLE; diff --git a/app/src/main/res/layout/list_playlist.xml b/app/src/main/res/layout/list_playlist.xml index c9ea9927..a7784197 100644 --- a/app/src/main/res/layout/list_playlist.xml +++ b/app/src/main/res/layout/list_playlist.xml @@ -41,6 +41,19 @@ app:srcCompat="@drawable/placeholder_video_thumbnail" android:background="@drawable/video_thumbnail_outline" /> + + + android:layout_marginBottom="6dp" /> Date: Wed, 7 May 2025 13:18:22 +0200 Subject: [PATCH 106/128] Reverted platform filters on history page. --- .../fragment/mainactivity/main/HistoryFragment.kt | 13 ++++++++----- app/src/main/res/layout/fragment_history.xml | 6 ++++-- 2 files changed, 12 insertions(+), 7 deletions(-) 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 87de37b9..41053c64 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 @@ -74,7 +74,7 @@ class HistoryFragment : MainFragment() { private var _pager: IPager? = null; private val _results = arrayListOf(); private var _loading = false; - private val _toggleBar: ToggleBar + //private val _toggleBar: ToggleBar private var _togglePluginsDisabled = hashSetOf() private var _automaticNextPageCounter = 0; @@ -87,7 +87,7 @@ class HistoryFragment : MainFragment() { _clearSearch = findViewById(R.id.button_clear_search); _editSearch = findViewById(R.id.edit_search); _tagsView = findViewById(R.id.tags_text); - _toggleBar = findViewById(R.id.toggle_bar) + //_toggleBar = findViewById(R.id.toggle_bar) _tagsView.setPairs(listOf( Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_24_hours), 24L * 60L), @@ -97,7 +97,7 @@ class HistoryFragment : MainFragment() { Pair(context.getString(R.string.all_time), -1L) )); - val toggles = StatePlatform.instance.getEnabledClients() + /*val toggles = StatePlatform.instance.getEnabledClients() .filter { it is JSClient } .map { plugin -> val pluginName = plugin.name.lowercase() @@ -111,7 +111,7 @@ class HistoryFragment : MainFragment() { filtersChanged() }).withTag("plugins") }.toTypedArray() - _toggleBar.setToggles(*toggles) + _toggleBar.setToggles(*toggles)*/ _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), { _results.size }, @@ -277,6 +277,9 @@ class HistoryFragment : MainFragment() { } private fun filterResults(a: List): List { + return a + + /* //TODO: Not an ideal way to do this, plugin id would be better but it is null for HistoryVideo ? val enabledPluginNames = StatePlatform.instance.getEnabledClients().map { it.name.lowercase() }.toHashSet() val disabledPluginNames = _togglePluginsDisabled.toHashSet() @@ -285,7 +288,7 @@ class HistoryFragment : MainFragment() { if (!enabledPluginNames.contains(pluginName)) return@filter false return@filter !disabledPluginNames.contains(pluginName) - }; + };*/ } private fun loadPagerInternal(pager: IPager) { diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml index ec5bbd5f..2994a382 100644 --- a/app/src/main/res/layout/fragment_history.xml +++ b/app/src/main/res/layout/fragment_history.xml @@ -107,12 +107,14 @@ android:textColor="@color/white" android:paddingStart="5dp" android:paddingTop="15dp" - android:paddingBottom="8dp" /> + android:paddingBottom="8dp" + android:visibility="gone" /> + android:layout_height="wrap_content" + android:visibility="gone" /> From 18102a2a73ac1989268b14e2da51edd626468d55 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 13:26:31 +0200 Subject: [PATCH 107/128] Try a more heavy handed approach to get plugin ids for history changes. --- .../mainactivity/main/HistoryFragment.kt | 30 ++++++++----------- .../views/adapters/HistoryListViewHolder.kt | 5 +++- app/src/main/res/layout/fragment_history.xml | 6 ++-- 3 files changed, 19 insertions(+), 22 deletions(-) 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 41053c64..1cbf2df1 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 @@ -74,7 +74,7 @@ class HistoryFragment : MainFragment() { private var _pager: IPager? = null; private val _results = arrayListOf(); private var _loading = false; - //private val _toggleBar: ToggleBar + private val _toggleBar: ToggleBar private var _togglePluginsDisabled = hashSetOf() private var _automaticNextPageCounter = 0; @@ -87,7 +87,7 @@ class HistoryFragment : MainFragment() { _clearSearch = findViewById(R.id.button_clear_search); _editSearch = findViewById(R.id.edit_search); _tagsView = findViewById(R.id.tags_text); - //_toggleBar = findViewById(R.id.toggle_bar) + _toggleBar = findViewById(R.id.toggle_bar) _tagsView.setPairs(listOf( Pair(context.getString(R.string.last_hour), 60L), Pair(context.getString(R.string.last_24_hours), 24L * 60L), @@ -97,21 +97,21 @@ class HistoryFragment : MainFragment() { Pair(context.getString(R.string.all_time), -1L) )); - /*val toggles = StatePlatform.instance.getEnabledClients() + val toggles = StatePlatform.instance.getEnabledClients() .filter { it is JSClient } .map { plugin -> val pluginName = plugin.name.lowercase() - ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(pluginName), { view, active -> + ToggleBar.Toggle(if(Settings.instance.home.showHomeFiltersPluginNames) pluginName else "", plugin.icon, !_togglePluginsDisabled.contains(plugin.id), { view, active -> if (active) { - _togglePluginsDisabled.remove(pluginName) + _togglePluginsDisabled.remove(plugin.id) } else { - _togglePluginsDisabled.add(pluginName) + _togglePluginsDisabled.add(plugin.id) } filtersChanged() }).withTag("plugins") }.toTypedArray() - _toggleBar.setToggles(*toggles)*/ + _toggleBar.setToggles(*toggles) _adapter = InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(), { _results.size }, @@ -277,18 +277,14 @@ class HistoryFragment : MainFragment() { } private fun filterResults(a: List): List { - return a - - /* - //TODO: Not an ideal way to do this, plugin id would be better but it is null for HistoryVideo ? - val enabledPluginNames = StatePlatform.instance.getEnabledClients().map { it.name.lowercase() }.toHashSet() - val disabledPluginNames = _togglePluginsDisabled.toHashSet() + val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet() + val disabledPluginIds = _togglePluginsDisabled.toHashSet() return a.filter { - val pluginName = it.video.id.platform.lowercase() - if (!enabledPluginNames.contains(pluginName)) + val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter true + if (!enabledPluginIds.contains(pluginId)) return@filter false - return@filter !disabledPluginNames.contains(pluginName) - };*/ + return@filter !disabledPluginIds.contains(pluginId) + }; } private fun loadPagerInternal(pager: IPager) { diff --git a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt index 0072492f..2fb9dd32 100644 --- a/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt +++ b/app/src/main/java/com/futo/platformplayer/views/adapters/HistoryListViewHolder.kt @@ -14,6 +14,7 @@ import com.futo.platformplayer.R import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.models.HistoryVideo +import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.others.ProgressBar @@ -75,7 +76,9 @@ class HistoryListViewHolder : ViewHolder { _textName.text = v.video.name; _textAuthor.text = v.video.author.name; _textVideoDuration.text = v.video.duration.toHumanTime(false); - _thumbnailPlatform.setPlatformFromClientName(v.video.id.platform) + + val pluginId = v.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(v.video.url)?.id + _thumbnailPlatform.setPlatformFromClientID(pluginId) if(v.video.isLive) { _containerDuration.visibility = View.GONE; diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml index 2994a382..ec5bbd5f 100644 --- a/app/src/main/res/layout/fragment_history.xml +++ b/app/src/main/res/layout/fragment_history.xml @@ -107,14 +107,12 @@ android:textColor="@color/white" android:paddingStart="5dp" android:paddingTop="15dp" - android:paddingBottom="8dp" - android:visibility="gone" /> + android:paddingBottom="8dp" /> + android:layout_height="wrap_content" /> From bf7001b5788280ff1f366fd264c31d99aae71961 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 14:17:29 +0200 Subject: [PATCH 108/128] Implemented to background button in pip. --- .../mainactivity/main/VideoDetailView.kt | 20 ++++++++++++++++++- .../receivers/MediaControlReceiver.kt | 6 ++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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 d15013f9..0e0bb19d 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 @@ -46,6 +46,8 @@ import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -688,6 +690,20 @@ class VideoDetailView : ConstraintLayout { Logger.i(TAG, "MediaControlReceiver.onCloseReceived") onClose.emit() }; + MediaControlReceiver.onBackgroundReceived.subscribe(this) { + Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived") + _player.switchToAudioMode(); + allowBackground = true; + StateApp.instance.contextOrNull?.let { + try { + if (it is MainActivity) { + it.moveTaskToBack(true) + } + } catch (e: Throwable) { + Logger.i(TAG, "Failed to move task to back", e) + } + } + }; MediaControlReceiver.onSeekToReceived.subscribe(this) { handleSeek(it); }; _container_content_description.onClose.subscribe { switchContentView(_container_content_main); }; @@ -1141,6 +1157,7 @@ class VideoDetailView : ConstraintLayout { MediaControlReceiver.onNextReceived.remove(this); MediaControlReceiver.onPreviousReceived.remove(this); MediaControlReceiver.onCloseReceived.remove(this); + MediaControlReceiver.onBackgroundReceived.remove(this); MediaControlReceiver.onSeekToReceived.remove(this); val job = _jobHideResume; @@ -2725,10 +2742,11 @@ class VideoDetailView : ConstraintLayout { else RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6)); + val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7)); return PictureInPictureParams.Builder() .setAspectRatio(Rational(videoSourceWidth, videoSourceHeight)) .setSourceRectHint(r) - .setActions(listOf(playpauseAction)) + .setActions(listOf(toBackgroundAction, playpauseAction)) .build(); } diff --git a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt index 5540406a..5251fa75 100644 --- a/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/receivers/MediaControlReceiver.kt @@ -21,6 +21,7 @@ class MediaControlReceiver : BroadcastReceiver() { EVENT_NEXT -> onNextReceived.emit(); EVENT_PREV -> onPreviousReceived.emit(); EVENT_CLOSE -> onCloseReceived.emit(); + EVENT_BACKGROUND -> onBackgroundReceived.emit(); } } catch(ex: Throwable) { @@ -38,6 +39,7 @@ class MediaControlReceiver : BroadcastReceiver() { const val EVENT_NEXT = "Next"; const val EVENT_PREV = "Prev"; const val EVENT_CLOSE = "Close"; + const val EVENT_BACKGROUND = "Background"; val onPlayReceived = Event0(); val onPauseReceived = Event0(); @@ -48,6 +50,7 @@ class MediaControlReceiver : BroadcastReceiver() { val onLowerVolumeReceived = Event0(); val onCloseReceived = Event0() + val onBackgroundReceived = Event0() fun getPlayIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { this.putExtra(EXTRA_MEDIA_ACTION, EVENT_PLAY); @@ -64,5 +67,8 @@ class MediaControlReceiver : BroadcastReceiver() { fun getCloseIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { this.putExtra(EXTRA_MEDIA_ACTION, EVENT_CLOSE); },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); + fun getToBackgroundIntent(context: Context, code: Int = 0) : PendingIntent = PendingIntent.getBroadcast(context, code, Intent(context, MediaControlReceiver::class.java).apply { + this.putExtra(EXTRA_MEDIA_ACTION, EVENT_BACKGROUND); + },PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT); } } \ No newline at end of file From c813fb4fadafc9985d5da94dcf377f759102d6eb Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 14:34:57 +0200 Subject: [PATCH 109/128] Do not show history entries where it could not retrieve a plugin id. --- .../fragment/mainactivity/main/HistoryFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1cbf2df1..80bd08cf 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 @@ -280,7 +280,7 @@ class HistoryFragment : MainFragment() { val enabledPluginIds = StatePlatform.instance.getEnabledClients().map { it.id }.toHashSet() val disabledPluginIds = _togglePluginsDisabled.toHashSet() return a.filter { - val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter true + val pluginId = it.video.id.pluginId ?: StatePlatform.instance.getContentClientOrNull(it.video.url)?.id ?: return@filter false if (!enabledPluginIds.contains(pluginId)) return@filter false return@filter !disabledPluginIds.contains(pluginId) From 191a6e2460c907e13ad6955b2a9660b69a02a1bc Mon Sep 17 00:00:00 2001 From: Stefan <84-stefan@users.noreply.gitlab.futo.org> Date: Wed, 7 May 2025 13:03:35 +0000 Subject: [PATCH 110/128] add Curiosity Stream plugin --- .gitmodules | 6 ++++++ app/src/stable/assets/sources/curiositystream | 1 + app/src/stable/res/raw/plugin_config.json | 3 ++- app/src/unstable/assets/sources/curiositystream | 1 + app/src/unstable/res/raw/plugin_config.json | 3 ++- 5 files changed, 12 insertions(+), 2 deletions(-) create mode 160000 app/src/stable/assets/sources/curiositystream create mode 160000 app/src/unstable/assets/sources/curiositystream diff --git a/.gitmodules b/.gitmodules index c906834c..3ba3f3f6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -94,3 +94,9 @@ [submodule "app/src/unstable/assets/sources/tedtalks"] path = app/src/unstable/assets/sources/tedtalks url = ../plugins/tedtalks.git +[submodule "app/src/stable/assets/sources/curiositystream"] + path = app/src/stable/assets/sources/curiositystream + url = ../plugins/curiositystream.git +[submodule "app/src/unstable/assets/sources/curiositystream"] + path = app/src/unstable/assets/sources/curiositystream + url = ../plugins/curiositystream.git diff --git a/app/src/stable/assets/sources/curiositystream b/app/src/stable/assets/sources/curiositystream new file mode 160000 index 00000000..33a81ba3 --- /dev/null +++ b/app/src/stable/assets/sources/curiositystream @@ -0,0 +1 @@ +Subproject commit 33a81ba30c86d1cb825d3af73c9f93338d23316a diff --git a/app/src/stable/res/raw/plugin_config.json b/app/src/stable/res/raw/plugin_config.json index 2b851fa5..4dea0f2e 100644 --- a/app/src/stable/res/raw/plugin_config.json +++ b/app/src/stable/res/raw/plugin_config.json @@ -14,7 +14,8 @@ "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", - "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json" + "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", + "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" diff --git a/app/src/unstable/assets/sources/curiositystream b/app/src/unstable/assets/sources/curiositystream new file mode 160000 index 00000000..33a81ba3 --- /dev/null +++ b/app/src/unstable/assets/sources/curiositystream @@ -0,0 +1 @@ +Subproject commit 33a81ba30c86d1cb825d3af73c9f93338d23316a diff --git a/app/src/unstable/res/raw/plugin_config.json b/app/src/unstable/res/raw/plugin_config.json index bdea11d0..40b10e11 100644 --- a/app/src/unstable/res/raw/plugin_config.json +++ b/app/src/unstable/res/raw/plugin_config.json @@ -14,7 +14,8 @@ "9c87e8db-e75d-48f4-afe5-2d203d4b95c5": "sources/dailymotion/build/DailymotionConfig.json", "e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json", "89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json", - "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json" + "8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json", + "273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json" }, "SOURCES_EMBEDDED_DEFAULT": [ "35ae969a-a7db-11ed-afa1-0242ac120002" From 5be92052bb349687ea1408b956e9fbe3895130af Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 15:43:28 +0200 Subject: [PATCH 111/128] Fixed device vanishing while still being there in the case the device is blacklisted from starting a relay. --- .../futo/platformplayer/activities/SyncHomeActivity.kt | 4 ++-- .../com/futo/platformplayer/stores/FragmentedStorage.kt | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index 859e6d4e..ed79832c 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -54,7 +54,6 @@ class SyncHomeActivity : AppCompatActivity() { val view = _viewMap[publicKey] if (!session.isAuthorized) { if (view != null) { - _layoutDevices.removeView(view) _viewMap.remove(publicKey) } return@launch @@ -108,11 +107,12 @@ class SyncHomeActivity : AppCompatActivity() { private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView { val connected = session?.connected ?: false + val authorized = session?.isAuthorized ?: false syncDeviceView.setLinkType(session?.linkType ?: LinkType.None) .setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey) //TODO: also display public key? - .setStatus(if (connected) "Connected" else "Disconnected") + .setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized") return syncDeviceView } 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 7a5b7cf2..b943d01c 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.stores 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.ManagedStore import com.futo.platformplayer.stores.v2.StoreSerializer @@ -124,7 +125,12 @@ class FragmentedStorage { } inline fun load(fileName: String): T where T : FragmentedStorageFile { if (_filesDir == null) { - throw Exception("Files dir should be initialized before loading a file.") + if(StateApp.instance.contextOrNull == null) + StateApp.instance.initializeFiles(); + + if (_filesDir == null) { + throw Exception("Files dir should be initialized before loading a file.") + } } val storageFile = File(_filesDir, "${fileName}.json"); From 5052bad82455103a99f236a543dbff83409e6dc3 Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 15:46:07 +0200 Subject: [PATCH 112/128] Updated submodules. --- app/src/stable/assets/sources/bilibili | 2 +- app/src/stable/assets/sources/curiositystream | 2 +- app/src/stable/assets/sources/tedtalks | 2 +- app/src/unstable/assets/sources/bilibili | 2 +- app/src/unstable/assets/sources/curiositystream | 2 +- app/src/unstable/assets/sources/tedtalks | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/stable/assets/sources/bilibili b/app/src/stable/assets/sources/bilibili index 0830668d..12226380 160000 --- a/app/src/stable/assets/sources/bilibili +++ b/app/src/stable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 0830668d3bdac18fafae6bb49aa1ff97b717f3b5 +Subproject commit 12226380428664a1de75abd2886ae12e00ec691f diff --git a/app/src/stable/assets/sources/curiositystream b/app/src/stable/assets/sources/curiositystream index 33a81ba3..f6eb2463 160000 --- a/app/src/stable/assets/sources/curiositystream +++ b/app/src/stable/assets/sources/curiositystream @@ -1 +1 @@ -Subproject commit 33a81ba30c86d1cb825d3af73c9f93338d23316a +Subproject commit f6eb2463f5de4d0dc4e5f921967babf2b5bd806f diff --git a/app/src/stable/assets/sources/tedtalks b/app/src/stable/assets/sources/tedtalks index 4e490737..b9528e44 160000 --- a/app/src/stable/assets/sources/tedtalks +++ b/app/src/stable/assets/sources/tedtalks @@ -1 +1 @@ -Subproject commit 4e490737a02491b52611af321582af8bead7d506 +Subproject commit b9528e44c5e353c55de362f484effdeb6327b6a4 diff --git a/app/src/unstable/assets/sources/bilibili b/app/src/unstable/assets/sources/bilibili index 0830668d..12226380 160000 --- a/app/src/unstable/assets/sources/bilibili +++ b/app/src/unstable/assets/sources/bilibili @@ -1 +1 @@ -Subproject commit 0830668d3bdac18fafae6bb49aa1ff97b717f3b5 +Subproject commit 12226380428664a1de75abd2886ae12e00ec691f diff --git a/app/src/unstable/assets/sources/curiositystream b/app/src/unstable/assets/sources/curiositystream index 33a81ba3..f6eb2463 160000 --- a/app/src/unstable/assets/sources/curiositystream +++ b/app/src/unstable/assets/sources/curiositystream @@ -1 +1 @@ -Subproject commit 33a81ba30c86d1cb825d3af73c9f93338d23316a +Subproject commit f6eb2463f5de4d0dc4e5f921967babf2b5bd806f diff --git a/app/src/unstable/assets/sources/tedtalks b/app/src/unstable/assets/sources/tedtalks index 4e490737..b9528e44 160000 --- a/app/src/unstable/assets/sources/tedtalks +++ b/app/src/unstable/assets/sources/tedtalks @@ -1 +1 @@ -Subproject commit 4e490737a02491b52611af321582af8bead7d506 +Subproject commit b9528e44c5e353c55de362f484effdeb6327b6a4 From 48a67e51a676a16d04169b45b19c04a693b35f4c Mon Sep 17 00:00:00 2001 From: Koen J Date: Wed, 7 May 2025 20:57:55 +0200 Subject: [PATCH 113/128] Various fixes. --- .../activities/SyncHomeActivity.kt | 11 ++++++ .../futo/platformplayer/states/StateApp.kt | 39 ++++++++++++++----- .../platformplayer/states/StatePlaylists.kt | 13 ++++--- .../futo/platformplayer/states/StateSync.kt | 3 +- .../stores/FragmentedStorage.kt | 7 +--- .../sync/internal/SyncSocketSession.kt | 4 +- 6 files changed, 53 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt index ed79832c..e8e95f66 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncHomeActivity.kt @@ -9,6 +9,7 @@ import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSync @@ -29,6 +30,16 @@ class SyncHomeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (StateApp.instance.contextOrNull == null) { + Logger.w(TAG, "No main activity, restarting main.") + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + finish() + return + } + setContentView(R.layout.activity_sync_home) setNavigationBarColorAndIcons() 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 cdfb308d..2988b217 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -530,10 +530,17 @@ class StateApp { //Migration Logger.i(TAG, "MainApp Started: Check [Migrations]"); - migrateStores(context, listOf( - StateSubscriptions.instance.toMigrateCheck(), - StatePlaylists.instance.toMigrateCheck() - ).flatten(), 0); + + scopeOrNull?.launch(Dispatchers.IO) { + try { + migrateStores(context, listOf( + StateSubscriptions.instance.toMigrateCheck(), + StatePlaylists.instance.toMigrateCheck() + ).flatten(), 0) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to migrate stores") + } + } if(Settings.instance.subscriptions.fetchOnAppBoot) { scope.launch(Dispatchers.IO) { @@ -700,15 +707,27 @@ class StateApp { } - private fun migrateStores(context: Context, managedStores: List>, index: Int) { + private suspend fun migrateStores(context: Context, managedStores: List>, index: Int) { if(managedStores.size <= index) return; val store = managedStores[index]; - if(store.hasMissingReconstructions()) - UIDialogs.showMigrateDialog(context, store) { - migrateStores(context, managedStores, index + 1); - }; - else + if(store.hasMissingReconstructions()) { + withContext(Dispatchers.Main) { + try { + UIDialogs.showMigrateDialog(context, store) { + scopeOrNull?.launch(Dispatchers.IO) { + try { + migrateStores(context, managedStores, index + 1); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to migrate store", e) + } + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to migrate stores", e) + } + } + } else migrateStores(context, managedStores, index + 1); } 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 fe194b2d..c20375f2 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlaylists.kt @@ -401,12 +401,15 @@ class StatePlaylists { companion object { val TAG = "StatePlaylists"; private var _instance : StatePlaylists? = null; + private var _lockObject = Object() val instance : StatePlaylists - get(){ - if(_instance == null) - _instance = StatePlaylists(); - return _instance!!; - }; + get() { + synchronized(_lockObject) { + if (_instance == null) + _instance = StatePlaylists(); + return _instance!!; + } + } fun finish() { _instance?.let { diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index f0ac4456..10aaa24d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -57,6 +57,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream +import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket @@ -438,7 +439,7 @@ class StateSync { Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") for ((targetKey, connectionInfo) in connectionInfos) { - val potentialLocalAddresses = connectionInfo.ipv4Addresses.union(connectionInfo.ipv6Addresses) + val potentialLocalAddresses = connectionInfo.ipv4Addresses .filter { it != connectionInfo.remoteIp } if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { Thread { 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 b943d01c..c5ff802a 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -125,12 +125,7 @@ class FragmentedStorage { } inline fun load(fileName: String): T where T : FragmentedStorageFile { if (_filesDir == null) { - if(StateApp.instance.contextOrNull == null) - StateApp.instance.initializeFiles(); - - if (_filesDir == null) { - throw Exception("Files dir should be initialized before loading a file.") - } + throw Exception("Files dir should be initialized before loading a file.") } val storageFile = File(_filesDir, "${fileName}.json"); diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index bd6f70db..5c3bdb9d 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -316,7 +316,7 @@ class SyncSocketSession { val appId = messageBuffer.int.toUInt() val pairingMessageLength = messageBuffer.int val pairingMessage = if (pairingMessageLength > 0) ByteArray(pairingMessageLength).also { messageBuffer.get(it) } else byteArrayOf() - val mainLength = messageSize - 4 - 4 - pairingMessageLength + val mainLength = messageBuffer.remaining() val mainMessage = ByteArray(mainLength).also { messageBuffer.get(it) } var pairingCode: String? = null @@ -333,7 +333,7 @@ class SyncSocketSession { responder.readMessage(mainMessage, 0, mainLength, plaintext, 0) val remoteKeyBytes = ByteArray(responder.remotePublicKey.publicKeyLength) responder.remotePublicKey.getPublicKey(remoteKeyBytes, 0) - val remotePublicKey = Base64.getEncoder().encodeToString(remoteKeyBytes) + val remotePublicKey = remoteKeyBytes.toBase64() val isAllowedToConnect = remotePublicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Direct, this, remotePublicKey, pairingCode, appId) ?: true) if (!isAllowedToConnect) { From e7e67b957207f77c78e62c1d71bc6b6e21466dba Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 9 May 2025 10:17:15 -0500 Subject: [PATCH 114/128] organize imports Changelog: changed --- .../java/com/futo/platformplayer/activities/MainActivity.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index cff39fa3..bc8b9d87 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -1,15 +1,13 @@ package com.futo.platformplayer.activities import android.annotation.SuppressLint -import android.app.UiModeManager import android.app.AlertDialog -import android.content.ComponentName +import android.app.UiModeManager import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager import android.content.res.Configuration -import android.media.AudioManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -77,7 +75,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.ImportCache import com.futo.platformplayer.models.UrlVideoWithTime -import com.futo.platformplayer.receivers.MediaButtonReceiver import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateBackup From 1d7429ad865cf018ce3b340d56f89e2d80d46126 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 12 May 2025 08:43:02 +0200 Subject: [PATCH 115/128] Fixed issue where private mode would not re-appear after closing the video. --- .../java/com/futo/platformplayer/Extensions_Network.kt | 2 ++ .../com/futo/platformplayer/activities/MainActivity.kt | 6 +++++- .../com/futo/platformplayer/api/http/ManagedHttpClient.kt | 4 +++- .../java/com/futo/platformplayer/sync/internal/Channel.kt | 7 +++++++ .../com/futo/platformplayer/sync/internal/SyncSession.kt | 5 +++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt index 00f47885..9fe21ec8 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Network.kt @@ -217,6 +217,8 @@ private fun ByteArray.toInetAddress(): InetAddress { } fun getConnectedSocket(attemptAddresses: List, port: Int): Socket? { + ensureNotMainThread() + val timeout = 2000 diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 43907037..ce570754 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -22,6 +22,7 @@ import android.widget.ImageView import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.motion.widget.MotionLayout import androidx.core.app.ActivityCompat @@ -66,6 +67,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment +import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment @@ -359,6 +361,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _fragMainSubscriptionsFeed.setPreviewsEnabled(true); _fragContainerVideoDetail.visibility = View.INVISIBLE; updateSegmentPaddings(); + updatePrivateModeVisibility() }; @@ -640,8 +643,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } } + @OptIn(UnstableApi::class) fun updatePrivateModeVisibility() { - if (_privateModeEnabled && !_pictureInPictureEnabled && !_isFullscreen && !_isMinimized) { + if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen && !_isMinimized)) { _buttonIncognito.elevation = 99f; _buttonIncognito.alpha = 1f; _buttonIncognito.layoutParams = _buttonIncognito.layoutParams.apply { diff --git a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt index 641dbed2..089c8106 100644 --- a/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/http/ManagedHttpClient.kt @@ -90,6 +90,7 @@ open class ManagedHttpClient { } fun tryHead(url: String): Map? { + ensureNotMainThread() try { val result = head(url); if(result.isOk) @@ -104,7 +105,7 @@ open class ManagedHttpClient { } fun socket(url: String, headers: MutableMap = HashMap(), listener: SocketListener): Socket { - + ensureNotMainThread() val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .url(url); if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" }) @@ -300,6 +301,7 @@ open class ManagedHttpClient { } fun send(msg: String) { + ensureNotMainThread() socket.send(msg); } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 1303d741..38408c25 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.sync.internal +import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState @@ -54,6 +55,7 @@ class ChannelSocket(private val session: SyncSocketSession) : IChannel { } override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, contentEncoding: ContentEncoding?) { + ensureNotMainThread() if (data != null) { session.send(opcode, subOpcode, data, contentEncoding) } else { @@ -152,6 +154,7 @@ class ChannelRelayed( private fun sendPacket(packet: ByteArray) { throwIfDisposed() + ensureNotMainThread() synchronized(sendLock) { val encryptedPayload = ByteArray(packet.size + 16) @@ -169,6 +172,7 @@ class ChannelRelayed( fun sendError(errorCode: SyncErrorCode) { throwIfDisposed() + ensureNotMainThread() synchronized(sendLock) { val packet = ByteArray(4) @@ -189,6 +193,7 @@ class ChannelRelayed( override fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer?, ce: ContentEncoding?) { throwIfDisposed() + ensureNotMainThread() var contentEncoding: ContentEncoding? = ce var processedData = data @@ -272,6 +277,7 @@ class ChannelRelayed( fun sendRequestTransport(requestId: Int, publicKey: String, appId: UInt, pairingCode: String? = null) { throwIfDisposed() + ensureNotMainThread() synchronized(sendLock) { val channelMessage = ByteArray(1024) @@ -312,6 +318,7 @@ class ChannelRelayed( fun sendResponseTransport(remoteVersion: Int, requestId: Int, handshakeMessage: ByteArray) { throwIfDisposed() + ensureNotMainThread() synchronized(sendLock) { val message = ByteArray(1024) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 4597f94d..f7754710 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -1,6 +1,7 @@ package com.futo.platformplayer.sync.internal import com.futo.platformplayer.UIDialogs +import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateSubscriptions @@ -192,18 +193,22 @@ class SyncSession : IAuthorizable { } inline fun sendJsonData(subOpcode: UByte, data: T) { + ensureNotMainThread() send(Opcode.DATA.value, subOpcode, Json.encodeToString(data)) } fun sendData(subOpcode: UByte, data: String) { + ensureNotMainThread() send(Opcode.DATA.value, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) } fun send(opcode: UByte, subOpcode: UByte, data: String) { + ensureNotMainThread() send(opcode, subOpcode, ByteBuffer.wrap(data.toByteArray(Charsets.UTF_8)), ContentEncoding.Gzip) } fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) { + ensureNotMainThread() val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() } if (channels.isEmpty()) { //TODO: Should this throw? From 1eb62b31d2028a780610b875b36b6593d3af8c6d Mon Sep 17 00:00:00 2001 From: Koen J Date: Tue, 13 May 2025 10:31:30 +0200 Subject: [PATCH 116/128] Added client sided ping loops to prevent inactive channels. --- .../platformplayer/sync/internal/Channel.kt | 33 ++++++++++++++ .../sync/internal/SyncSocketSession.kt | 43 ++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 38408c25..89e4e3a7 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -96,11 +96,39 @@ class ChannelRelayed( private var onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)? = null private var onClose: ((IChannel) -> Unit)? = null private var disposed = false + private var _lastPongTime: Long = 0 + private val _pingInterval: Long = 5000 // 5 seconds in milliseconds + private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds init { handshakeState?.start() } + private fun startPingLoop() { + if (remoteVersion!! < 5) { + return + } + + _lastPongTime = System.currentTimeMillis() + + Thread { + try { + while (!disposed) { + Thread.sleep(_pingInterval) + if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) { + Logger.e("ChannelRelayed", "Channel timed out waiting for PONG; closing.") + close() + break + } + send(Opcode.PING.value, 0u) + } + } catch (e: Exception) { + Logger.e("ChannelRelayed", "Ping loop failed", e) + close() + } + }.start() + } + override fun setDataHandler(onData: ((SyncSocketSession, IChannel, UByte, UByte, ByteBuffer) -> Unit)?) { this.onData = onData } @@ -136,6 +164,10 @@ class ChannelRelayed( } fun invokeDataHandler(opcode: UByte, subOpcode: UByte, data: ByteBuffer) { + if (opcode == Opcode.PONG.value) { + _lastPongTime = System.currentTimeMillis() + return + } onData?.invoke(session, this, opcode, subOpcode, data) } @@ -150,6 +182,7 @@ class ChannelRelayed( handshakeState = null this.transport = transport Logger.i("ChannelRelayed", "Completed handshake for connectionId $connectionId") + startPingLoop() } private fun sendPacket(packet: ByteArray) { diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 5c3bdb9d..3dc81334 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -9,11 +9,15 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.noise.protocol.CipherStatePair import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState +import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSync import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion import com.futo.polycentric.core.base64ToByteArray import com.futo.polycentric.core.toBase64 import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream @@ -80,6 +84,11 @@ class SyncSocketSession { private val _pendingBulkGetRecordRequests = ConcurrentHashMap>>>() private val _pendingBulkConnectionInfoRequests = ConcurrentHashMap>>() + @Volatile + private var _lastPongTime: Long = System.currentTimeMillis() + private val _pingInterval: Long = 5000 // 5 seconds in milliseconds + private val _disconnectTimeout: Long = 30000 // 30 seconds in milliseconds + data class ConnectionInfo( val port: UShort, val name: String, @@ -129,6 +138,7 @@ class SyncSocketSession { try { handshakeAsInitiator(remotePublicKey, appId, pairingCode) _onHandshakeComplete?.invoke(this) + startPingLoop() receiveLoop() } catch (e: Throwable) { Logger.e(TAG, "Failed to run as initiator", e) @@ -143,6 +153,7 @@ class SyncSocketSession { try { handshakeAsInitiator(remotePublicKey, appId, pairingCode) _onHandshakeComplete?.invoke(this) + startPingLoop() receiveLoop() } catch (e: Throwable) { Logger.e(TAG, "Failed to run as initiator", e) @@ -157,6 +168,7 @@ class SyncSocketSession { try { if (handshakeAsResponder()) { _onHandshakeComplete?.invoke(this) + startPingLoop() receiveLoop() } } catch (e: Throwable) { @@ -352,7 +364,7 @@ class SyncSocketSession { } private fun performVersionCheck() { - val CURRENT_VERSION = 4 + val CURRENT_VERSION = 5 val MINIMUM_VERSION = 4 val versionBytes = ByteArray(4) @@ -833,6 +845,30 @@ class SyncSocketSession { } } + private fun startPingLoop() { + if (remoteVersion < 5) return + + _lastPongTime = System.currentTimeMillis() + + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + while (_started) { + delay(_pingInterval) + + if (System.currentTimeMillis() - _lastPongTime > _disconnectTimeout) { + Logger.e(TAG, "Session timed out waiting for PONG; closing.") + stop() + break + } + send(Opcode.PING.value) + } + } catch (e: Exception) { + Logger.e(TAG, "Ping loop failed", e) + stop() + } + } + } + private fun handlePacket(opcode: UByte, subOpcode: UByte, d: ByteBuffer, contentEncoding: UByte, sourceChannel: ChannelRelayed?) { Logger.i(TAG, "Handle packet (opcode = ${opcode}, subOpcode = ${subOpcode})") @@ -864,6 +900,11 @@ class SyncSocketSession { return } Opcode.PONG.value -> { + if (sourceChannel != null) { + sourceChannel.invokeDataHandler(opcode, subOpcode, data) + } else { + _lastPongTime = System.currentTimeMillis() + } Logger.v(TAG, "Received pong") return } From 705eb6a3fa3d3151d505c8cd06dc2fda6df8b32f Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 15 May 2025 19:57:31 +0200 Subject: [PATCH 117/128] Migrated service to SyncService. --- .../activities/SyncPairActivity.kt | 2 +- .../activities/SyncShowPairingCodeActivity.kt | 17 +- .../platformplayer/casting/StateCasting.kt | 2 - .../platformplayer/downloads/VideoDownload.kt | 2 - .../mainactivity/main/VideoDetailView.kt | 2 - .../futo/platformplayer/states/StateSync.kt | 897 +++--------------- .../platformplayer/sync/internal/Channel.kt | 4 +- .../sync/internal/SyncService.kt | 721 ++++++++++++++ .../sync/internal/SyncSocketSession.kt | 10 +- 9 files changed, 869 insertions(+), 788 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt index c34e8362..b0ca616a 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncPairActivity.kt @@ -110,7 +110,7 @@ class SyncPairActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - StateSync.instance.connect(deviceInfo) { complete, message -> + StateSync.instance.syncService?.connect(deviceInfo) { complete, message -> lifecycleScope.launch(Dispatchers.Main) { if (complete != null) { if (complete) { diff --git a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt index b9f3d437..848c320c 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/SyncShowPairingCodeActivity.kt @@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() { } val ips = getIPs() - val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT, StateSync.instance.pairingCode) - val json = Json.encodeToString(selfDeviceInfo) - val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - val url = "grayjay://sync/${base64}" - setCode(url) + val publicKey = StateSync.instance.syncService?.publicKey + val pairingCode = StateSync.instance.syncService?.pairingCode + if (publicKey == null || pairingCode == null) { + setCode("Public key or pairing code was not known, is sync enabled?") + } else { + val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode) + val json = Json.encodeToString(selfDeviceInfo) + val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val url = "grayjay://sync/${base64}" + setCode(url) + } + } fun setCode(code: String?) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index ff997d3d..647aaae6 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -45,8 +45,6 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StateSync -import com.futo.platformplayer.states.StateSync.Companion import com.futo.platformplayer.stores.CastingDeviceInfoStorage import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.toUrlAddress diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 1937e939..da969386 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -58,7 +58,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Contextual -import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.Transient import java.io.File import java.io.FileOutputStream @@ -73,7 +72,6 @@ import java.util.concurrent.ThreadLocalRandom import kotlin.coroutines.resumeWithException import kotlin.time.times -@InternalSerializationApi @kotlinx.serialization.Serializable class VideoDownload { var state: State = State.QUEUED; 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 bc2c683b..21f20403 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 @@ -47,7 +47,6 @@ import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UISlideOverlays import com.futo.platformplayer.activities.MainActivity -import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.media.IPluginSourced import com.futo.platformplayer.api.media.LiveChatManager import com.futo.platformplayer.api.media.PlatformID @@ -150,7 +149,6 @@ import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButtonGroup import com.futo.platformplayer.views.platform.PlatformIndicator -import com.futo.platformplayer.views.segments.ChaptersList import com.futo.platformplayer.views.segments.CommentsList import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.video.FutoVideoPlayer diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 10aaa24d..0e5cda56 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -1,506 +1,159 @@ package com.futo.platformplayer.states import android.content.Context -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.os.Build -import android.util.Log -import com.futo.platformplayer.LittleEndianDataInputStream -import com.futo.platformplayer.LittleEndianDataOutputStream import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.SyncShowPairingCodeActivity import com.futo.platformplayer.api.media.Serializer -import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.casting.StateCasting.Companion import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.encryption.GEncryptionProvider -import com.futo.platformplayer.generateReadablePassword -import com.futo.platformplayer.getConnectedSocket import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.Subscription -import com.futo.platformplayer.noise.protocol.DHState -import com.futo.platformplayer.noise.protocol.Noise import com.futo.platformplayer.sToOffsetDateTimeUTC import com.futo.platformplayer.smartMerge import com.futo.platformplayer.stores.FragmentedStorage -import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringStorage +import com.futo.platformplayer.stores.StringStringMapStorage import com.futo.platformplayer.stores.StringTMapStorage import com.futo.platformplayer.sync.SyncSessionData -import com.futo.platformplayer.sync.internal.ChannelSocket import com.futo.platformplayer.sync.internal.GJSyncOpcodes -import com.futo.platformplayer.sync.internal.IAuthorizable -import com.futo.platformplayer.sync.internal.IChannel -import com.futo.platformplayer.sync.internal.LinkType +import com.futo.platformplayer.sync.internal.ISyncDatabaseProvider import com.futo.platformplayer.sync.internal.Opcode -import com.futo.platformplayer.sync.internal.SyncDeviceInfo import com.futo.platformplayer.sync.internal.SyncKeyPair +import com.futo.platformplayer.sync.internal.SyncService +import com.futo.platformplayer.sync.internal.SyncServiceSettings import com.futo.platformplayer.sync.internal.SyncSession -import com.futo.platformplayer.sync.internal.SyncSocketSession import com.futo.platformplayer.sync.models.SendToDevicePackage import com.futo.platformplayer.sync.models.SyncPlaylistsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionGroupsPackage import com.futo.platformplayer.sync.models.SyncSubscriptionsPackage import com.futo.platformplayer.sync.models.SyncWatchLaterPackage -import com.futo.polycentric.core.base64ToByteArray -import com.futo.polycentric.core.toBase64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream -import java.net.Inet4Address -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket import java.nio.ByteBuffer -import java.time.Instant import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.Base64 -import java.util.Locale -import kotlin.math.min import kotlin.system.measureTimeMillis class StateSync { - private val _authorizedDevices = FragmentedStorage.get("authorized_devices") - private val _nameStorage = FragmentedStorage.get("sync_remembered_name_storage") - private val _syncKeyPair = FragmentedStorage.get("sync_key_pair") - private val _lastAddressStorage = FragmentedStorage.get("sync_last_address_storage") private val _syncSessionData = FragmentedStorage.get>("syncSessionData") - private var _serverSocket: ServerSocket? = null - private var _thread: Thread? = null - private var _connectThread: Thread? = null - @Volatile private var _started = false - private val _sessions: MutableMap = mutableMapOf() - private val _lastConnectTimesMdns: MutableMap = mutableMapOf() - private val _lastConnectTimesIp: MutableMap = mutableMapOf() - private var _serverStarted = false - //TODO: Should sync mdns and casting mdns be merged? - //TODO: Decrease interval that devices are updated - //TODO: Send less data - - private val _pairingCode: String? = generateReadablePassword(8) - val pairingCode: String? get() = _pairingCode - private var _relaySession: SyncSocketSession? = null - private var _threadRelay: Thread? = null - private val _remotePendingStatusUpdate = mutableMapOf Unit>() - private var _nsdManager: NsdManager? = null - private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener { - override fun onDiscoveryStarted(regType: String) { - Log.d(TAG, "Service discovery started for $regType") - } - - override fun onDiscoveryStopped(serviceType: String) { - Log.i(TAG, "Discovery stopped: $serviceType") - } - - override fun onServiceLost(service: NsdServiceInfo) { - Log.e(TAG, "service lost: $service") - // TODO: Handle service lost, e.g., remove device - } - - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") - try { - _nsdManager?.stopServiceDiscovery(this) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to stop service discovery", e) - } - } - - fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { - if (!Settings.instance.synchronization.connectDiscovered) { - return - } - - val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return - val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) - val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) - val authorized = isAuthorized(pkey) - - if (authorized && !isConnected(pkey)) { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] = now - } - - Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") - - try { - connect(syncDeviceInfo) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to $pkey", e) - } - } - } - } - - override fun onServiceFound(service: NsdServiceInfo) { - Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") - addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - service.hostAddresses.toTypedArray() - } else { - if(service.host != null) - arrayOf(service.host); - else - arrayOf(); - }, service.port, service.attributes) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceUpdated: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) - } - - override fun onServiceLost() { - Log.v(TAG, "onServiceLost: $service") - // TODO: Handle service lost - } - - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") - } - - override fun onServiceInfoCallbackUnregistered() { - Log.v(TAG, "onServiceInfoCallbackUnregistered") - } - }) - } else { - _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "Resolve failed: $errorCode") - } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "Resolve Succeeded: $serviceInfo") - addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) - } - }) - } - } - } - - private val _registrationListener = object : NsdManager.RegistrationListener { - override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}") - } - - override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") - } - - override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { - Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}") - } - - override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") - } - } - - var keyPair: DHState? = null - var publicKey: String? = null + var syncService: SyncService? = null + private set val deviceRemoved: Event1 = Event1() val deviceUpdatedOrAdded: Event2 = Event2() - //TODO: Should authorize acknowledge be implemented? - - fun hasAuthorizedDevice(): Boolean { - synchronized(_sessions) { - return _sessions.any{ it.value.connected && it.value.isAuthorized }; - } - } - fun start(context: Context, onServerBindFail: () -> Unit) { - if (_started) { + if (syncService != null) { Logger.i(TAG, "Already started.") return } - _started = true - _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager - if (Settings.instance.synchronization.connectDiscovered) { - _nsdManager?.apply { - discoverServices("_gsync._tcp", NsdManager.PROTOCOL_DNS_SD, _discoveryListener) + syncService = SyncService( + SERVICE_NAME, + RELAY_SERVER, + RELAY_PUBLIC_KEY, + APP_ID, + StoreBasedSyncDatabaseProvider(), + SyncServiceSettings( + mdnsBroadcast = Settings.instance.synchronization.broadcast, + mdnsConnectDiscovered = Settings.instance.synchronization.connectDiscovered, + bindListener = Settings.instance.synchronization.localConnections, + relayHandshakeAllowed = Settings.instance.synchronization.connectThroughRelay, + relayPairAllowed = Settings.instance.synchronization.pairThroughRelay, + relayEnabled = Settings.instance.synchronization.discoverThroughRelay, + relayConnectDirect = Settings.instance.synchronization.connectLocalDirectThroughRelay, + relayConnectRelayed = Settings.instance.synchronization.connectThroughRelay + ) + ).apply { + syncService?.onAuthorized = { sess, isNewlyAuthorized, isNewSession -> + if (isNewSession) { + deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) + StateApp.instance.scope.launch { checkForSync(sess) } + } } - } - try { - val syncKeyPair = Json.decodeFromString(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) - val p = Noise.createDH(dh) - p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) - p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) - keyPair = p - } catch (e: Throwable) { - //Sync key pair non-existing, invalid or lost - val p = Noise.createDH(dh) - p.generateKeyPair() - - val publicKey = ByteArray(p.publicKeyLength) - p.getPublicKey(publicKey, 0) - val privateKey = ByteArray(p.privateKeyLength) - p.getPrivateKey(privateKey, 0) - - val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) - _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(syncKeyPair))) - - Logger.e(TAG, "Failed to load existing key pair", e) - keyPair = p - } - - publicKey = keyPair?.let { - val pkey = ByteArray(it.publicKeyLength) - it.getPublicKey(pkey, 0) - return@let pkey.toBase64() - } - - if (Settings.instance.synchronization.broadcast) { - val pk = publicKey - val nsdManager = _nsdManager - - if (pk != null && nsdManager != null) { - val serviceInfo = NsdServiceInfo().apply { - serviceName = getDeviceName() - serviceType = "_gsync._tcp" - port = PORT - setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", "")) + syncService?.onUnauthorized = { sess -> + StateApp.instance.scope.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog( + context, + "Device Unauthorized: ${sess.displayName}", + action = { + Logger.i(TAG, "${sess.remotePublicKey} unauthorized received") + syncService?.removeAuthorizedDevice(sess.remotePublicKey) + deviceRemoved.emit(sess.remotePublicKey) + }, + cancelAction = {} + ) } - - nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener) } - } - Logger.i(TAG, "Sync key pair initialized (public key = ${publicKey})") + syncService?.onConnectedChanged = { sess, _ -> deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) } + syncService?.onClose = { sess -> deviceRemoved.emit(sess.remotePublicKey) } + syncService?.onData = { it, opcode, subOpcode, data -> + val dataCopy = ByteArray(data.remaining()) + data.get(dataCopy) - if (Settings.instance.synchronization.localConnections) { - _serverStarted = true - _thread = Thread { - try { - val serverSocket = ServerSocket(PORT) - _serverSocket = serverSocket - - Log.i(TAG, "Running on port ${PORT} (TCP)") - - while (_started) { - val socket = serverSocket.accept() - val session = createSocketSession(socket, true) - session.startAsResponder() - } - } catch (e: Throwable) { - _serverStarted = false - Logger.e(TAG, "Failed to bind server socket to port ${PORT}", e) - StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { - onServerBindFail.invoke() - } - } finally { - _serverStarted = false - } - }.apply { start() } - } - - if (Settings.instance.synchronization.connectLast) { - _connectThread = Thread { - Log.i(TAG, "Running auto reconnector") - - while (_started) { - val authorizedDevices = synchronized(_authorizedDevices) { - return@synchronized _authorizedDevices.values - } - - val lastKnownMap = synchronized(_lastAddressStorage) { - return@synchronized _lastAddressStorage.map.toMap() - } - - val addressesToConnect = authorizedDevices.mapNotNull { - val connected = isConnected(it) - if (connected) { - return@mapNotNull null - } - - val lastKnownAddress = lastKnownMap[it] ?: return@mapNotNull null - return@mapNotNull Pair(it, lastKnownAddress) - } - - for (connectPair in addressesToConnect) { - try { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesIp) { - _lastConnectTimesIp[connectPair.first] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesIp) { - _lastConnectTimesIp[connectPair.first] = now - } - - Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") - connect(arrayOf(connectPair.second), PORT, connectPair.first, null) - } - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to " + connectPair.first, e) - } - } - Thread.sleep(5000) - } - }.apply { start() } - } - - if (Settings.instance.synchronization.discoverThroughRelay) { - _threadRelay = Thread { - var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) - var backoffIndex = 0; - - while (_started) { + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { try { - Log.i(TAG, "Starting relay session...") + handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy)) + } catch (e: Throwable) { + Logger.e(TAG, "Exception occurred while handling data, closing session", e) + it.close() + } + } + } + syncService?.authorizePrompt = { remotePublicKey, callback -> + val scope = StateApp.instance.scopeOrNull + val activity = SyncShowPairingCodeActivity.activity - var socketClosed = false; - val socket = Socket(RELAY_SERVER, 9000) - _relaySession = SyncSocketSession( - (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, - keyPair!!, - socket, - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, - onNewChannel = { _, c -> - val remotePublicKey = c.remotePublicKey - if (remotePublicKey == null) { - Log.e(TAG, "Remote public key should never be null in onNewChannel.") - return@SyncSocketSession - } - - Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") - - var session: SyncSession? - synchronized(_sessions) { - session = _sessions[remotePublicKey] - if (session == null) { - val remoteDeviceName = synchronized(_nameStorage) { - _nameStorage.get(remotePublicKey) - } - session = createNewSyncSession(remotePublicKey, remoteDeviceName) - _sessions[remotePublicKey] = session!! - } - session!!.addChannel(c) - } - - c.setDataHandler { _, channel, opcode, subOpcode, data -> - session?.handlePacket(opcode, subOpcode, data) - } - c.setCloseHandler { channel -> - session?.removeChannel(channel) - } - }, - onChannelEstablished = { _, channel, isResponder -> - handleAuthorization(channel, isResponder) - }, - onClose = { socketClosed = true }, - onHandshakeComplete = { relaySession -> - backoffIndex = 0 - - Thread { + if (scope != null && activity != null) { + scope.launch(Dispatchers.Main) { + UIDialogs.showConfirmationDialog(activity, "Allow connection from $remotePublicKey?", + action = { + scope.launch(Dispatchers.IO) { try { - while (_started && !socketClosed) { - val unconnectedAuthorizedDevices = synchronized(_authorizedDevices) { - _authorizedDevices.values.filter { !isConnected(it) }.toTypedArray() - } + callback(true) + Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") - relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, PORT, Settings.instance.synchronization.discoverThroughRelay, false, false, Settings.instance.synchronization.discoverThroughRelay && Settings.instance.synchronization.connectThroughRelay) - - Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") - val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } - Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") - - for ((targetKey, connectionInfo) in connectionInfos) { - val potentialLocalAddresses = connectionInfo.ipv4Addresses - .filter { it != connectionInfo.remoteIp } - if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { - Thread { - try { - Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") - connect(potentialLocalAddresses.map { it }.toTypedArray(), PORT, targetKey, null) - } catch (e: Throwable) { - Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) - } - }.start() - } - - if (connectionInfo.allowRemoteDirect) { - // TODO: Implement direct remote connection if needed - } - - if (connectionInfo.allowRemoteHolePunched) { - // TODO: Implement hole punching if needed - } - - if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { - try { - Logger.v(TAG, "Attempting relayed connection with '$targetKey'.") - runBlocking { relaySession.startRelayedChannel(targetKey, APP_ID, null) } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e) - } - } - } - - Thread.sleep(15000) - } + activity.finish() } catch (e: Throwable) { - Logger.e(TAG, "Unhandled exception in relay session.", e) - relaySession.stop() + Logger.e(TAG, "Failed to send authorize", e) } - }.start() + } + }, + cancelAction = { + scope.launch(Dispatchers.IO) { + try { + callback(false) + Logger.i(TAG, "$remotePublicKey unauthorized received") + } catch (e: Throwable) { + Logger.w(TAG, "Failed to send unauthorize", e) + } + } } ) - - _relaySession!!.authorizable = object : IAuthorizable { - override val isAuthorized: Boolean get() = true - } - - _relaySession!!.runAsInitiator(RELAY_PUBLIC_KEY, APP_ID, null) - - Log.i(TAG, "Started relay session.") - } catch (e: Throwable) { - Log.e(TAG, "Relay session failed.", e) - } finally { - _relaySession?.stop() - _relaySession = null - Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) } + } else { + callback(false) + Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask") } - }.apply { start() } + } } + + syncService?.start(context, onServerBindFail) } fun showFailedToBindDialogIfNecessary(context: Context) { - if (!_serverStarted && Settings.instance.synchronization.localConnections) { + if (syncService?.serverSocketFailedToStart == true && Settings.instance.synchronization.localConnections) { try { UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use") } catch (e: Throwable) { @@ -510,10 +163,10 @@ class StateSync { } fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) { - if (!_started) { + if (syncService == null) { UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { Settings.instance.synchronization.enabled = true - StateSync.instance.start(context, onServerBindFail) + start(context, onServerBindFail) Settings.instance.save() onStarted.invoke() }, { @@ -524,43 +177,20 @@ class StateSync { } } - private fun getDeviceName(): String { - val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - val model = Build.MODEL - - return if (model.startsWith(manufacturer, ignoreCase = true)) { - model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } else { - "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } - } - - fun isConnected(publicKey: String): Boolean { - return synchronized(_sessions) { - _sessions[publicKey]?.connected ?: false - } + fun hasAuthorizedDevice(): Boolean { + return (syncService?.getAuthorizedDeviceCount() ?: 0) > 0 } fun isAuthorized(publicKey: String): Boolean { - return synchronized(_authorizedDevices) { - _authorizedDevices.values.contains(publicKey) - } + return syncService?.isAuthorized(publicKey) ?: false } fun getSession(publicKey: String): SyncSession? { - return synchronized(_sessions) { - _sessions[publicKey] - } - } - fun getSessions(): List { - synchronized(_sessions) { - return _sessions.values.toList() - } + return syncService?.getSession(publicKey) } + fun getAuthorizedSessions(): List { - synchronized(_sessions) { - return _sessions.values.filter { it.isAuthorized }.toList() - } + return syncService?.getSessions()?.filter { it.isAuthorized }?.toList() ?: listOf() } fun getSyncSessionData(key: String): SyncSessionData { @@ -573,14 +203,6 @@ class StateSync { _syncSessionData.setAndSave(data.publicKey, data); } - private fun unauthorize(remotePublicKey: String) { - Logger.i(TAG, "${remotePublicKey} unauthorized received") - _authorizedDevices.remove(remotePublicKey) - _authorizedDevices.save() - deviceRemoved.emit(remotePublicKey) - } - - private fun handleSyncSubscriptionPackage(origin: SyncSession, pack: SyncSubscriptionsPackage) { val added = mutableListOf() for(sub in pack.subscriptions) { @@ -832,218 +454,6 @@ class StateSync { } } - private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession { - val remotePublicKey = rpk.base64ToByteArray().toBase64() - return SyncSession( - remotePublicKey, - onAuthorized = { it, isNewlyAuthorized, isNewSession -> - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") - } - - if (!isNewSession) { - return@SyncSession - } - - it.remoteDeviceName?.let { remoteDeviceName -> - synchronized(_nameStorage) { - _nameStorage.setAndSave(remotePublicKey, remoteDeviceName) - } - } - - Logger.i(TAG, "$remotePublicKey authorized (name: ${it.displayName})") - _authorizedDevices.addDistinct(remotePublicKey) - _authorizedDevices.save() - deviceUpdatedOrAdded.emit(it.remotePublicKey, it) - - checkForSync(it); - }, - onUnauthorized = { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") - } - - unauthorize(remotePublicKey) - Logger.i(TAG, "$remotePublicKey unauthorized (name: ${it.displayName})") - - synchronized(_sessions) { - it.close() - _sessions.remove(remotePublicKey) - } - }, - onConnectedChanged = { it, connected -> - Logger.i(TAG, "$remotePublicKey connected: $connected") - deviceUpdatedOrAdded.emit(it.remotePublicKey, it) - }, - onClose = { - Logger.i(TAG, "$remotePublicKey closed") - - synchronized(_sessions) - { - _sessions.remove(it.remotePublicKey) - } - - deviceRemoved.emit(it.remotePublicKey) - - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed") - } - }, - dataHandler = { it, opcode, subOpcode, data -> - val dataCopy = ByteArray(data.remaining()) - data.get(dataCopy) - - StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { - try { - handleData(it, opcode, subOpcode, ByteBuffer.wrap(dataCopy)) - } catch (e: Throwable) { - Logger.e(TAG, "Exception occurred while handling data, closing session", e) - it.close() - } - } - }, - remoteDeviceName - ) - } - - private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean { - Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).") - if (publicKey == RELAY_PUBLIC_KEY) - return true - - synchronized(_authorizedDevices) { - if (_authorizedDevices.values.contains(publicKey)) { - if (linkType == LinkType.Relayed && !Settings.instance.synchronization.connectThroughRelay) - return false - return true - } - } - - Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).") - if (_pairingCode == null || pairingCode.isNullOrEmpty()) - return false - - if (linkType == LinkType.Relayed && !Settings.instance.synchronization.pairThroughRelay) - return false - - return _pairingCode == pairingCode - } - - private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession { - var session: SyncSession? = null - var channelSocket: ChannelSocket? = null - return SyncSocketSession( - (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, - keyPair!!, - socket, - onClose = { s -> - if (channelSocket != null) - session?.removeChannel(channelSocket!!) - }, - isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, - onHandshakeComplete = { s -> - val remotePublicKey = s.remotePublicKey - if (remotePublicKey == null) { - s.stop() - return@SyncSocketSession - } - - Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") - - channelSocket = ChannelSocket(s) - - synchronized(_sessions) { - session = _sessions[s.remotePublicKey] - if (session == null) { - val remoteDeviceName = synchronized(_nameStorage) { - _nameStorage.get(remotePublicKey) - } - - synchronized(_lastAddressStorage) { - _lastAddressStorage.setAndSave(remotePublicKey, s.remoteAddress) - } - - session = createNewSyncSession(remotePublicKey, remoteDeviceName) - _sessions[remotePublicKey] = session!! - } - session!!.addChannel(channelSocket!!) - } - - handleAuthorization(channelSocket!!, isResponder) - }, - onData = { s, opcode, subOpcode, data -> - session?.handlePacket(opcode, subOpcode, data) - } - ) - } - - private fun handleAuthorization(channel: IChannel, isResponder: Boolean) { - val syncSession = channel.syncSession!! - val remotePublicKey = channel.remotePublicKey!! - - if (isResponder) { - val isAuthorized = synchronized(_authorizedDevices) { - _authorizedDevices.values.contains(remotePublicKey) - } - - if (!isAuthorized) { - val scope = StateApp.instance.scopeOrNull - val activity = SyncShowPairingCodeActivity.activity - - if (scope != null && activity != null) { - scope.launch(Dispatchers.Main) { - UIDialogs.showConfirmationDialog(activity, "Allow connection from ${remotePublicKey}?", - action = { - scope.launch(Dispatchers.IO) { - try { - syncSession.authorize() - Logger.i(TAG, "Connection authorized for $remotePublicKey by confirmation") - - activity.finish() - } catch (e: Throwable) { - Logger.e(TAG, "Failed to send authorize", e) - } - } - }, - cancelAction = { - scope.launch(Dispatchers.IO) { - try { - unauthorize(remotePublicKey) - } catch (e: Throwable) { - Logger.w(TAG, "Failed to send unauthorize", e) - } - - syncSession.close() - synchronized(_sessions) { - _sessions.remove(remotePublicKey) - } - } - } - ) - } - } else { - val publicKey = syncSession.remotePublicKey - syncSession.unauthorize() - syncSession.close() - - synchronized(_sessions) { - _sessions.remove(publicKey) - } - - Logger.i(TAG, "Connection unauthorized for $remotePublicKey because not authorized and not on pairing activity to ask") - } - } else { - //Responder does not need to check because already approved - syncSession.authorize() - Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized") - } - } else { - //Initiator does not need to check because the manual action of scanning the QR counts as approval - syncSession.authorize() - Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator") - } - } - inline fun broadcastJsonData(subOpcode: UByte, data: T) { broadcast(Opcode.DATA.value, subOpcode, Json.encodeToString(data)); } @@ -1074,84 +484,17 @@ class StateSync { } fun stop() { - _started = false - - try { - _nsdManager?.stopServiceDiscovery(_discoveryListener) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to stop discovery listener", e) - } - - try { - _nsdManager?.unregisterService(_registrationListener) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to unregister service", e) - } - - _relaySession?.stop() - _serverSocket?.close() - _serverSocket = null - - synchronized(_sessions) { - _sessions.values.forEach { it.close() } - _sessions.clear() - } - - _thread = null - _connectThread = null - _threadRelay = null - _relaySession = null + syncService?.stop() + syncService = null } - fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) { - try { - connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate) - } catch (e: Throwable) { - Logger.e(TAG, "Failed to connect directly", e) - val relaySession = _relaySession - if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) { - onStatusUpdate?.invoke(null, "Connecting via relay...") - - runBlocking { - if (onStatusUpdate != null) { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate - } - } - relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), APP_ID, deviceInfo.pairingCode) - } - } else { - throw e - } - } - } - - fun connect(addresses: Array, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession { - onStatusUpdate?.invoke(null, "Connecting directly...") - val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") - onStatusUpdate?.invoke(null, "Handshaking...") - - val session = createSocketSession(socket, false) - if (onStatusUpdate != null) { - synchronized(_remotePendingStatusUpdate) { - _remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate - } - } - - session.startAsInitiator(publicKey, APP_ID, pairingCode) - return session - } fun getAll(): List { - synchronized(_authorizedDevices) { - return _authorizedDevices.values.toList() - } + return syncService?.getAllAuthorizedDevices()?.toList() ?: listOf() } fun getCachedName(publicKey: String): String? { - return synchronized(_nameStorage) { - _nameStorage.get(publicKey) - } + return syncService?.getCachedName(publicKey) } suspend fun delete(publicKey: String) { @@ -1168,14 +511,8 @@ class StateSync { session.close() } - synchronized(_sessions) { - _sessions.remove(publicKey) - } - - synchronized(_authorizedDevices) { - _authorizedDevices.remove(publicKey) - } - _authorizedDevices.save() + syncService?.removeSession(publicKey) + syncService?.removeAuthorizedDevice(publicKey) withContext(Dispatchers.Main) { deviceRemoved.emit(publicKey) @@ -1184,17 +521,45 @@ class StateSync { Logger.w(TAG, "Failed to delete", e) } } + } + class StoreBasedSyncDatabaseProvider : ISyncDatabaseProvider { + private val _authorizedDevices = FragmentedStorage.get("authorized_devices") + private val _nameStorage = FragmentedStorage.get("sync_remembered_name_storage") + private val _syncKeyPair = FragmentedStorage.get("sync_key_pair") + private val _lastAddressStorage = FragmentedStorage.get("sync_last_address_storage") + + override fun isAuthorized(publicKey: String): Boolean = synchronized(_authorizedDevices) { _authorizedDevices.values.contains(publicKey) } + override fun addAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) { + _authorizedDevices.addDistinct(publicKey) + _authorizedDevices.save() + } + override fun removeAuthorizedDevice(publicKey: String) = synchronized(_authorizedDevices) { + _authorizedDevices.remove(publicKey) + _authorizedDevices.save() + } + override fun getAllAuthorizedDevices(): Array = synchronized(_authorizedDevices) { _authorizedDevices.values.toTypedArray() } + override fun getAuthorizedDeviceCount(): Int = synchronized(_authorizedDevices) { _authorizedDevices.values.size } + override fun getSyncKeyPair(): SyncKeyPair? = try { + Json.decodeFromString(GEncryptionProvider.instance.decrypt(_syncKeyPair.value)) + } catch (e: Throwable) { null } + override fun setSyncKeyPair(value: SyncKeyPair) { _syncKeyPair.setAndSave(GEncryptionProvider.instance.encrypt(Json.encodeToString(value))) } + override fun getLastAddress(publicKey: String): String? = synchronized(_lastAddressStorage) { _lastAddressStorage.map[publicKey] } + override fun setLastAddress(publicKey: String, address: String) = synchronized(_lastAddressStorage) { + _lastAddressStorage.map[publicKey] = address + _lastAddressStorage.save() + } + override fun getDeviceName(publicKey: String): String? = synchronized(_nameStorage) { _nameStorage.map[publicKey] } + override fun setDeviceName(publicKey: String, name: String) = synchronized(_nameStorage) { + _nameStorage.map[publicKey] = name + _nameStorage.save() + } } companion object { - val dh = "25519" - val pattern = "IK" - val cipher = "ChaChaPoly" - val hash = "BLAKE2b" - var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" val version = 1 val RELAY_SERVER = "relay.grayjay.app" + val SERVICE_NAME = "_gsync._tcp" val RELAY_PUBLIC_KEY = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw=" val APP_ID = 0x534A5247u //GRayJaySync (GRJS) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt index 89e4e3a7..e17b6309 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/Channel.kt @@ -73,12 +73,12 @@ class ChannelRelayed( private val sendLock = Object() private val decryptLock = Object() private var handshakeState: HandshakeState? = if (initiator) { - HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR).apply { + HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR).apply { localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) remotePublicKey.setPublicKey(Base64.getDecoder().decode(publicKey), 0) } } else { - HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER).apply { + HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER).apply { localKeyPair.copyFrom(this@ChannelRelayed.localKeyPair) } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt new file mode 100644 index 00000000..d1209abd --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt @@ -0,0 +1,721 @@ +package com.futo.platformplayer.sync.internal + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.util.Log +import com.futo.platformplayer.Settings +import com.futo.platformplayer.generateReadablePassword +import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.noise.protocol.DHState +import com.futo.platformplayer.noise.protocol.Noise +import com.futo.platformplayer.states.StateSync +import com.futo.polycentric.core.base64ToByteArray +import com.futo.polycentric.core.toBase64 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.ByteBuffer +import java.util.Base64 +import java.util.Locale +import kotlin.math.min + +public data class SyncServiceSettings( + val listenerPort: Int = 12315, + val mdnsBroadcast: Boolean = true, + val mdnsConnectDiscovered: Boolean = true, + val bindListener: Boolean = true, + val connectLastKnown: Boolean = true, + val relayHandshakeAllowed: Boolean = true, + val relayPairAllowed: Boolean = true, + val relayEnabled: Boolean = true, + val relayConnectDirect: Boolean = true, + val relayConnectRelayed: Boolean = true +) + +interface ISyncDatabaseProvider { + fun isAuthorized(publicKey: String): Boolean + fun addAuthorizedDevice(publicKey: String) + fun removeAuthorizedDevice(publicKey: String) + fun getAllAuthorizedDevices(): Array? + fun getAuthorizedDeviceCount(): Int + fun getSyncKeyPair(): SyncKeyPair? + fun setSyncKeyPair(value: SyncKeyPair) + fun getLastAddress(publicKey: String): String? + fun setLastAddress(publicKey: String, address: String) + fun getDeviceName(publicKey: String): String? + fun setDeviceName(publicKey: String, name: String) +} + +class SyncService( + private val serviceName: String, + private val relayServer: String, + private val relayPublicKey: String, + private val appId: UInt, + private val database: ISyncDatabaseProvider, + private val settings: SyncServiceSettings = SyncServiceSettings() +) { + private var _serverSocket: ServerSocket? = null + private var _thread: Thread? = null + private var _connectThread: Thread? = null + @Volatile private var _started = false + private val _sessions: MutableMap = mutableMapOf() + private val _lastConnectTimesMdns: MutableMap = mutableMapOf() + private val _lastConnectTimesIp: MutableMap = mutableMapOf() + var serverSocketFailedToStart = false + //TODO: Should sync mdns and casting mdns be merged? + //TODO: Decrease interval that devices are updated + //TODO: Send less data + + private val _pairingCode: String? = generateReadablePassword(8) + val pairingCode: String? get() = _pairingCode + private var _relaySession: SyncSocketSession? = null + private var _threadRelay: Thread? = null + private val _remotePendingStatusUpdate = mutableMapOf Unit>() + private var _nsdManager: NsdManager? = null + private var _scope: CoroutineScope? = null + private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.e(TAG, "service lost: $service") + // TODO: Handle service lost, e.g., remove device + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode") + try { + _nsdManager?.stopServiceDiscovery(this) + } catch (e: Throwable) { + Logger.w(TAG, "Failed to stop service discovery", e) + } + } + + fun addOrUpdate(name: String, adrs: Array, port: Int, attributes: Map) { + if (!Settings.instance.synchronization.connectDiscovered) { + return + } + + val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return + val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) + val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) + val authorized = isAuthorized(pkey) + + if (authorized && !isConnected(pkey)) { + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] = now + } + + Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") + + try { + connect(syncDeviceInfo) + } catch (e: Throwable) { + Logger.i(TAG, "Failed to connect to $pkey", e) + } + } + } + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.v(TAG, "Service discovery success for ${service.serviceType}: $service") + addOrUpdate(service.serviceName, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.hostAddresses.toTypedArray() + } else { + if(service.host != null) + arrayOf(service.host); + else + arrayOf(); + }, service.port, service.attributes) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + _nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback { + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUpdated: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port, serviceInfo.attributes) + } + + override fun onServiceLost() { + Log.v(TAG, "onServiceLost: $service") + // TODO: Handle service lost + } + + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode") + } + + override fun onServiceInfoCallbackUnregistered() { + Log.v(TAG, "onServiceInfoCallbackUnregistered") + } + }) + } else { + _nsdManager?.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "Resolve Succeeded: $serviceInfo") + addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port, serviceInfo.attributes) + } + }) + } + } + } + + private val _registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceRegistered: ${serviceInfo.serviceName}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "onRegistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { + Log.v(TAG, "onServiceUnregistered: ${serviceInfo.serviceName}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.v(TAG, "onUnregistrationFailed: ${serviceInfo.serviceName} (error code: $errorCode)") + } + } + + var keyPair: DHState? = null + var publicKey: String? = null + + var onAuthorized: ((SyncSession, Boolean, Boolean) -> Unit)? = null + var onUnauthorized: ((SyncSession) -> Unit)? = null + var onConnectedChanged: ((SyncSession, Boolean) -> Unit)? = null + var onClose: ((SyncSession) -> Unit)? = null + var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null + var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null + + fun start(context: Context, onServerBindFail: (() -> Unit)? = null) { + if (_started) { + Logger.i(TAG, "Already started.") + return + } + _started = true + _scope = CoroutineScope(Dispatchers.IO) + + try { + val syncKeyPair = database.getSyncKeyPair() ?: throw Exception("SyncKeyPair not found") + val p = Noise.createDH(dh) + p.setPublicKey(syncKeyPair.publicKey.base64ToByteArray(), 0) + p.setPrivateKey(syncKeyPair.privateKey.base64ToByteArray(), 0) + keyPair = p + } catch (e: Throwable) { + //Sync key pair non-existing, invalid or lost + val p = Noise.createDH(dh) + p.generateKeyPair() + + val publicKey = ByteArray(p.publicKeyLength) + p.getPublicKey(publicKey, 0) + val privateKey = ByteArray(p.privateKeyLength) + p.getPrivateKey(privateKey, 0) + + val syncKeyPair = SyncKeyPair(1, publicKey.toBase64(), privateKey.toBase64()) + database.setSyncKeyPair(syncKeyPair) + + Logger.e(TAG, "Failed to load existing key pair", e) + keyPair = p + } + + publicKey = keyPair?.let { + val pkey = ByteArray(it.publicKeyLength) + it.getPublicKey(pkey, 0) + return@let pkey.toBase64() + } + + _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + if (settings.mdnsConnectDiscovered) { + _nsdManager?.apply { + discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener) + } + } + + if (settings.mdnsBroadcast) { + val pk = publicKey + val nsdManager = _nsdManager + + if (pk != null && nsdManager != null) { + val sn = serviceName + val serviceInfo = NsdServiceInfo().apply { + serviceName = getDeviceName() + serviceType = sn + port = settings.listenerPort + setAttribute("pk", pk.replace('+', '-').replace('/', '_').replace("=", "")) + } + + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, _registrationListener) + } + } + + Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") + + if (settings.bindListener) { + startListener(onServerBindFail) + } + + if (settings.relayEnabled) { + startRelayLoop() + } + + if (settings.connectLastKnown) { + startConnectLastLoop() + } + } + + private fun startListener(onServerBindFail: (() -> Unit)? = null) { + serverSocketFailedToStart = false + _thread = Thread { + try { + val serverSocket = ServerSocket(settings.listenerPort) + _serverSocket = serverSocket + + Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") + + while (_started) { + val socket = serverSocket.accept() + val session = createSocketSession(socket, true) + session.startAsResponder() + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) + serverSocketFailedToStart = true + onServerBindFail?.invoke() + } + }.apply { start() } + } + + private fun startConnectLastLoop() { + _connectThread = Thread { + Log.i(TAG, "Running auto reconnector") + + while (_started) { + val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf() + val addressesToConnect = authorizedDevices.mapNotNull { + val connected = isConnected(it) + if (connected) { + return@mapNotNull null + } + + val lastKnownAddress = database.getLastAddress(it) ?: return@mapNotNull null + return@mapNotNull Pair(it, lastKnownAddress) + } + + for (connectPair in addressesToConnect) { + try { + val now = System.currentTimeMillis() + val lastConnectTime = synchronized(_lastConnectTimesIp) { + _lastConnectTimesIp[connectPair.first] ?: 0 + } + + //Connect once every 30 seconds, max + if (now - lastConnectTime > 30000) { + synchronized(_lastConnectTimesIp) { + _lastConnectTimesIp[connectPair.first] = now + } + + Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}") + connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null) + } + } catch (e: Throwable) { + Logger.i(TAG, "Failed to connect to " + connectPair.first, e) + } + } + Thread.sleep(5000) + } + }.apply { start() } + } + + private fun startRelayLoop() { + _threadRelay = Thread { + var backoffs: Array = arrayOf(1000, 5000, 10000, 20000) + var backoffIndex = 0; + + while (_started) { + try { + Log.i(TAG, "Starting relay session...") + + var socketClosed = false; + val socket = Socket(relayServer, 9000) + _relaySession = SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + socket, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, + onNewChannel = { _, c -> + val remotePublicKey = c.remotePublicKey + if (remotePublicKey == null) { + Log.e(TAG, "Remote public key should never be null in onNewChannel.") + return@SyncSocketSession + } + + Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") + + var session: SyncSession? + synchronized(_sessions) { + session = _sessions[remotePublicKey] + if (session == null) { + val remoteDeviceName = database.getDeviceName(remotePublicKey) + session = createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(c) + } + + c.setDataHandler { _, channel, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + c.setCloseHandler { channel -> + session?.removeChannel(channel) + } + }, + onChannelEstablished = { _, channel, isResponder -> + handleAuthorization(channel, isResponder) + }, + onClose = { socketClosed = true }, + onHandshakeComplete = { relaySession -> + backoffIndex = 0 + + Thread { + try { + while (_started && !socketClosed) { + val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf() + relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed) + + Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") + val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } + Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") + + for ((targetKey, connectionInfo) in connectionInfos) { + val potentialLocalAddresses = connectionInfo.ipv4Addresses + .filter { it != connectionInfo.remoteIp } + if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { + Thread { + try { + Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") + connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null) + } catch (e: Throwable) { + Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) + } + }.start() + } + + if (connectionInfo.allowRemoteDirect) { + // TODO: Implement direct remote connection if needed + } + + if (connectionInfo.allowRemoteHolePunched) { + // TODO: Implement hole punching if needed + } + + if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { + try { + Logger.v(TAG, "Attempting relayed connection with '$targetKey'.") + runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e) + } + } + } + + Thread.sleep(15000) + } + } catch (e: Throwable) { + Logger.e(TAG, "Unhandled exception in relay session.", e) + relaySession.stop() + } + }.start() + } + ) + + _relaySession!!.authorizable = object : IAuthorizable { + override val isAuthorized: Boolean get() = true + } + + _relaySession!!.runAsInitiator(relayPublicKey, appId, null) + + Log.i(TAG, "Started relay session.") + } catch (e: Throwable) { + Log.e(TAG, "Relay session failed.", e) + } finally { + _relaySession?.stop() + _relaySession = null + Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) + } + } + }.apply { start() } + } + + private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession { + var session: SyncSession? = null + var channelSocket: ChannelSocket? = null + return SyncSocketSession( + (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, + keyPair!!, + socket, + onClose = { s -> + if (channelSocket != null) + session?.removeChannel(channelSocket!!) + }, + isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, + onHandshakeComplete = { s -> + val remotePublicKey = s.remotePublicKey + if (remotePublicKey == null) { + s.stop() + return@SyncSocketSession + } + + Logger.i(TAG, "Handshake complete with (LocalPublicKey = ${s.localPublicKey}, RemotePublicKey = ${s.remotePublicKey})") + + channelSocket = ChannelSocket(s) + + synchronized(_sessions) { + session = _sessions[s.remotePublicKey] + if (session == null) { + val remoteDeviceName = database.getDeviceName(remotePublicKey) + database.setLastAddress(remotePublicKey, s.remoteAddress) + session = createNewSyncSession(remotePublicKey, remoteDeviceName) + _sessions[remotePublicKey] = session!! + } + session!!.addChannel(channelSocket!!) + } + + handleAuthorization(channelSocket!!, isResponder) + }, + onData = { s, opcode, subOpcode, data -> + session?.handlePacket(opcode, subOpcode, data) + } + ) + } + + private fun handleAuthorization(channel: IChannel, isResponder: Boolean) { + val syncSession = channel.syncSession!! + val remotePublicKey = channel.remotePublicKey!! + + if (isResponder) { + val isAuthorized = database.isAuthorized(remotePublicKey) + if (!isAuthorized) { + val ap = this.authorizePrompt + if (ap == null) { + try { + Logger.i(TAG, "$remotePublicKey unauthorized because AuthorizePrompt is null") + syncSession.unauthorize() + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize result.", e) + } + return; + } + + ap.invoke(remotePublicKey) { + try { + _scope?.launch(Dispatchers.IO) { + if (it) { + Logger.i(TAG, "$remotePublicKey manually authorized") + syncSession.authorize() + } else { + Logger.i(TAG, "$remotePublicKey manually unauthorized") + syncSession.unauthorize() + syncSession.close() + } + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to send authorize result.") + } + } + } else { + //Responder does not need to check because already approved + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey because already authorized") + } + } else { + //Initiator does not need to check because the manual action of scanning the QR counts as approval + syncSession.authorize() + Logger.i(TAG, "Connection authorized for $remotePublicKey because initiator") + } + } + + private fun isHandshakeAllowed(linkType: LinkType, syncSocketSession: SyncSocketSession, publicKey: String, pairingCode: String?, appId: UInt): Boolean { + Log.v(TAG, "Check if handshake allowed from '$publicKey' (app id: $appId).") + if (publicKey == StateSync.RELAY_PUBLIC_KEY) + return true + + if (database.isAuthorized(publicKey)) { + if (linkType == LinkType.Relayed && !settings.relayHandshakeAllowed) + return false + return true + } + + Log.v(TAG, "Check if handshake allowed with pairing code '$pairingCode' with active pairing code '$_pairingCode' (app id: $appId).") + if (_pairingCode == null || pairingCode.isNullOrEmpty()) + return false + + if (linkType == LinkType.Relayed && !settings.relayPairAllowed) + return false + + return _pairingCode == pairingCode + } + + private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession { + val remotePublicKey = rpk.base64ToByteArray().toBase64() + return SyncSession( + remotePublicKey, + onAuthorized = { it, isNewlyAuthorized, isNewSession -> + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized") + } + + if (isNewSession) { + it.remoteDeviceName?.let { remoteDeviceName -> + database.setDeviceName(remotePublicKey, remoteDeviceName) + } + + database.addAuthorizedDevice(remotePublicKey) + } + + onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession) + }, + onUnauthorized = { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized") + } + + onUnauthorized?.invoke(it) + }, + onConnectedChanged = { it, connected -> + Logger.i(TAG, "$remotePublicKey connected: $connected") + onConnectedChanged?.invoke(it, connected) + }, + onClose = { + Logger.i(TAG, "$remotePublicKey closed") + + removeSession(it.remotePublicKey) + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed") + } + + onClose?.invoke(it) + }, + dataHandler = { it, opcode, subOpcode, data -> + onData?.invoke(it, opcode, subOpcode, data) + }, + remoteDeviceName + ) + } + + fun isConnected(publicKey: String): Boolean = synchronized(_sessions) { _sessions[publicKey]?.connected ?: false } + fun isAuthorized(publicKey: String): Boolean = database.isAuthorized(publicKey) + fun getSession(publicKey: String): SyncSession? = synchronized(_sessions) { _sessions[publicKey] } + fun getSessions(): List = synchronized(_sessions) { _sessions.values.toList() } + fun removeSession(publicKey: String) = synchronized(_sessions) { _sessions.remove(publicKey) } + fun getCachedName(publicKey: String): String? = database.getDeviceName(publicKey) + fun getAuthorizedDeviceCount(): Int = database.getAuthorizedDeviceCount() + fun getAllAuthorizedDevices(): Array? = database.getAllAuthorizedDevices() + fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey) + + fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) { + try { + connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate) + } catch (e: Throwable) { + Logger.e(TAG, "Failed to connect directly", e) + val relaySession = _relaySession + if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) { + onStatusUpdate?.invoke(null, "Connecting via relay...") + + runBlocking { + if (onStatusUpdate != null) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate + } + } + relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode) + } + } else { + throw e + } + } + } + + fun connect(addresses: Array, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession { + onStatusUpdate?.invoke(null, "Connecting directly...") + val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect") + onStatusUpdate?.invoke(null, "Handshaking...") + + val session = createSocketSession(socket, false) + if (onStatusUpdate != null) { + synchronized(_remotePendingStatusUpdate) { + _remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate + } + } + + session.startAsInitiator(publicKey, appId, pairingCode) + return session + } + + fun stop() { + _scope?.cancel() + _scope = null + _relaySession?.stop() + _relaySession = null + _serverSocket?.close() + _serverSocket = null + synchronized(_sessions) { + _sessions.values.forEach { it.close() } + _sessions.clear() + } + } + + private fun getDeviceName(): String { + val manufacturer = Build.MANUFACTURER.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + val model = Build.MODEL + + return if (model.startsWith(manufacturer, ignoreCase = true)) { + model.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } else { + "$manufacturer $model".replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + } + + companion object { + val dh = "25519" + val pattern = "IK" + val cipher = "ChaChaPoly" + val hash = "BLAKE2b" + var protocolName = "Noise_${pattern}_${dh}_${cipher}_${hash}" + + private const val TAG = "SyncService" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt index 3dc81334..fb4e920f 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSocketSession.kt @@ -1,9 +1,6 @@ package com.futo.platformplayer.sync.internal import android.os.Build -import com.futo.platformplayer.LittleEndianDataInputStream -import com.futo.platformplayer.LittleEndianDataOutputStream -import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.ensureNotMainThread import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.noise.protocol.CipherStatePair @@ -11,7 +8,6 @@ import com.futo.platformplayer.noise.protocol.DHState import com.futo.platformplayer.noise.protocol.HandshakeState import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateSync -import com.futo.platformplayer.sync.internal.ChannelRelayed.Companion import com.futo.polycentric.core.base64ToByteArray import com.futo.polycentric.core.toBase64 import kotlinx.coroutines.CompletableDeferred @@ -34,9 +30,7 @@ import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream -import kotlin.math.min import kotlin.system.measureTimeMillis -import kotlin.time.measureTime class SyncSocketSession { private val _socket: Socket @@ -257,7 +251,7 @@ class SyncSocketSession { private fun handshakeAsInitiator(remotePublicKey: String, appId: UInt, pairingCode: String?) { performVersionCheck() - val initiator = HandshakeState(StateSync.protocolName, HandshakeState.INITIATOR) + val initiator = HandshakeState(SyncService.protocolName, HandshakeState.INITIATOR) initiator.localKeyPair.copyFrom(_localKeyPair) initiator.remotePublicKey.setPublicKey(Base64.getDecoder().decode(remotePublicKey), 0) initiator.start() @@ -311,7 +305,7 @@ class SyncSocketSession { private fun handshakeAsResponder(): Boolean { performVersionCheck() - val responder = HandshakeState(StateSync.protocolName, HandshakeState.RESPONDER) + val responder = HandshakeState(SyncService.protocolName, HandshakeState.RESPONDER) responder.localKeyPair.copyFrom(_localKeyPair) responder.start() From 120ded5274f54d19a000a85e0fc86a35d6a67cb0 Mon Sep 17 00:00:00 2001 From: Koen J Date: Fri, 16 May 2025 12:11:41 +0200 Subject: [PATCH 118/128] Prepared Sync logic to be separated from the rest of the logic. --- .../futo/platformplayer/states/StateSync.kt | 17 ++-- .../sync/internal/SyncService.kt | 79 ++++++++++++------- app/src/main/res/values/strings.xml | 32 ++++---- 3 files changed, 77 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt index 0e5cda56..81efece4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSync.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSync.kt @@ -67,6 +67,7 @@ class StateSync { mdnsBroadcast = Settings.instance.synchronization.broadcast, mdnsConnectDiscovered = Settings.instance.synchronization.connectDiscovered, bindListener = Settings.instance.synchronization.localConnections, + connectLastKnown = Settings.instance.synchronization.connectLast, relayHandshakeAllowed = Settings.instance.synchronization.connectThroughRelay, relayPairAllowed = Settings.instance.synchronization.pairThroughRelay, relayEnabled = Settings.instance.synchronization.discoverThroughRelay, @@ -74,21 +75,21 @@ class StateSync { relayConnectRelayed = Settings.instance.synchronization.connectThroughRelay ) ).apply { - syncService?.onAuthorized = { sess, isNewlyAuthorized, isNewSession -> + onAuthorized = { sess, isNewlyAuthorized, isNewSession -> if (isNewSession) { deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) - StateApp.instance.scope.launch { checkForSync(sess) } + StateApp.instance.scope.launch(Dispatchers.IO) { checkForSync(sess) } } } - syncService?.onUnauthorized = { sess -> + onUnauthorized = { sess -> StateApp.instance.scope.launch(Dispatchers.Main) { UIDialogs.showConfirmationDialog( context, "Device Unauthorized: ${sess.displayName}", action = { Logger.i(TAG, "${sess.remotePublicKey} unauthorized received") - syncService?.removeAuthorizedDevice(sess.remotePublicKey) + removeAuthorizedDevice(sess.remotePublicKey) deviceRemoved.emit(sess.remotePublicKey) }, cancelAction = {} @@ -96,9 +97,9 @@ class StateSync { } } - syncService?.onConnectedChanged = { sess, _ -> deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) } - syncService?.onClose = { sess -> deviceRemoved.emit(sess.remotePublicKey) } - syncService?.onData = { it, opcode, subOpcode, data -> + onConnectedChanged = { sess, _ -> deviceUpdatedOrAdded.emit(sess.remotePublicKey, sess) } + onClose = { sess -> deviceRemoved.emit(sess.remotePublicKey) } + onData = { it, opcode, subOpcode, data -> val dataCopy = ByteArray(data.remaining()) data.get(dataCopy) @@ -111,7 +112,7 @@ class StateSync { } } } - syncService?.authorizePrompt = { remotePublicKey, callback -> + authorizePrompt = { remotePublicKey, callback -> val scope = StateApp.instance.scopeOrNull val activity = SyncShowPairingCodeActivity.activity diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt index d1209abd..e6f9e6d8 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncService.kt @@ -66,6 +66,7 @@ class SyncService( private var _serverSocket: ServerSocket? = null private var _thread: Thread? = null private var _connectThread: Thread? = null + private var _mdnsThread: Thread? = null @Volatile private var _started = false private val _sessions: MutableMap = mutableMapOf() private val _lastConnectTimesMdns: MutableMap = mutableMapOf() @@ -82,6 +83,7 @@ class SyncService( private val _remotePendingStatusUpdate = mutableMapOf Unit>() private var _nsdManager: NsdManager? = null private var _scope: CoroutineScope? = null + private val _mdnsCache = mutableMapOf() private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener { override fun onDiscoveryStarted(regType: String) { Log.d(TAG, "Service discovery started for $regType") @@ -93,7 +95,11 @@ class SyncService( override fun onServiceLost(service: NsdServiceInfo) { Log.e(TAG, "service lost: $service") - // TODO: Handle service lost, e.g., remove device + val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return + val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + synchronized(_mdnsCache) { + _mdnsCache.remove(pkey) + } } override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { @@ -120,30 +126,11 @@ class SyncService( } val urlSafePkey = attributes.get("pk")?.decodeToString() ?: return - val pkey = Base64.getEncoder().encodeToString(Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/'))) + val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() val syncDeviceInfo = SyncDeviceInfo(pkey, adrs.map { it.hostAddress }.toTypedArray(), port, null) - val authorized = isAuthorized(pkey) - if (authorized && !isConnected(pkey)) { - val now = System.currentTimeMillis() - val lastConnectTime = synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] ?: 0 - } - - //Connect once every 30 seconds, max - if (now - lastConnectTime > 30000) { - synchronized(_lastConnectTimesMdns) { - _lastConnectTimesMdns[pkey] = now - } - - Logger.i(TAG, "Found device authorized device '${name}' with pkey=$pkey, attempting to connect") - - try { - connect(syncDeviceInfo) - } catch (e: Throwable) { - Logger.i(TAG, "Failed to connect to $pkey", e) - } - } + synchronized(_mdnsCache) { + _mdnsCache[pkey] = syncDeviceInfo } } @@ -167,7 +154,11 @@ class SyncService( override fun onServiceLost() { Log.v(TAG, "onServiceLost: $service") - // TODO: Handle service lost + val urlSafePkey = service.attributes["pk"]?.decodeToString() ?: return + val pkey = Base64.getDecoder().decode(urlSafePkey.replace('-', '+').replace('_', '/')).toBase64() + synchronized(_mdnsCache) { + _mdnsCache.remove(pkey) + } } override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { @@ -260,9 +251,7 @@ class SyncService( _nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager if (settings.mdnsConnectDiscovered) { - _nsdManager?.apply { - discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener) - } + startMdnsRetryLoop() } if (settings.mdnsBroadcast) { @@ -319,6 +308,42 @@ class SyncService( }.apply { start() } } + private fun startMdnsRetryLoop() { + _nsdManager?.apply { + discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener) + } + + _mdnsThread = Thread { + while (_started) { + try { + val now = System.currentTimeMillis() + synchronized(_mdnsCache) { + for ((pkey, info) in _mdnsCache) { + if (!database.isAuthorized(pkey) || isConnected(pkey)) continue + + val last = synchronized(_lastConnectTimesMdns) { + _lastConnectTimesMdns[pkey] ?: 0L + } + if (now - last > 30_000L) { + _lastConnectTimesMdns[pkey] = now + try { + Logger.i(TAG, "MDNS-retry: connecting to $pkey") + connect(info) + } catch (ex: Throwable) { + Logger.w(TAG, "MDNS retry failed for $pkey", ex) + } + } + } + } + } catch (ex: Throwable) { + Logger.e(TAG, "Error in MDNS retry loop", ex) + } + Thread.sleep(5000) + } + }.apply { start() } + } + + private fun startConnectLastLoop() { _connectThread = Thread { Log.i(TAG, "Running auto reconnector") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29642238..f81f1642 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -370,22 +370,22 @@ Networking Synchronization Enable feature - Broadcast - Allow device to broadcast presence - Connect discovered - Allow device to search for and initiate connection with known paired devices - Try connect last - Allow device to automatically connect to last known - Discover through relay - Allow paired devices to be discovered and connected to through the relay - Pair through relay - Allow devices to be paired through the relay - Connection through relay - Allow devices to be connected to through the relay - Connect direct through relay - Allow devices to be directly locally connected to through information discovered from the relay - Local connections - Allow device to be directly locally connected + mDNS Broadcast + Allow device to broadcast presence using mDNS + mDNS Connect + Allow device to search for and initiate connection with known paired devices using mDNS + Connect Last Known + Allow device to automatically connect to last known endpoints + Relay Enable + Allow device to use a relay for discovery/relaying connection + Relay Pairing + Allow device to be paired through the relay + Relay Connect Relayed + Allow device to be connected to using a relayed connection + Relay Connect Direct + Allow device to be directly connected to using relay published information + Bind Listener + Allow device to be directly connected to Gesture controls Volume slider Enable slide gesture to change volume From 94965cf3ba09116a535e83df2b2554c3ed5ec0ce Mon Sep 17 00:00:00 2001 From: Koen J Date: Fri, 16 May 2025 12:34:48 +0200 Subject: [PATCH 119/128] Use snapshots for sending order to improve send efficiency. --- .../sync/internal/SyncSession.kt | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index f7754710..575b8af8 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -17,6 +17,8 @@ interface IAuthorizable { class SyncSession : IAuthorizable { private val _channels: MutableList = mutableListOf() + @Volatile + private var _snapshot: Array = emptyArray() private var _authorized: Boolean = false private var _remoteAuthorized: Boolean = false private val _onAuthorized: (session: SyncSession, isNewlyAuthorized: Boolean, isNewSession: Boolean) -> Unit @@ -83,6 +85,8 @@ class SyncSession : IAuthorizable { synchronized(_channels) { _channels.add(channel) + _channels.sortBy { it.linkType.ordinal } + _snapshot = _channels.toTypedArray() connected = _channels.isNotEmpty() } @@ -124,15 +128,20 @@ class SyncSession : IAuthorizable { fun removeChannel(channel: IChannel) { synchronized(_channels) { _channels.remove(channel) + _snapshot = _channels.toTypedArray() connected = _channels.isNotEmpty() } } fun close() { - synchronized(_channels) { - _channels.toTypedArray() - }.forEach { it.close() } - + val toClose = synchronized(_channels) { + val arr = _channels.toTypedArray() + _channels.clear() + _snapshot = emptyArray() + connected = false + arr + } + toClose.forEach { it.close() } _onClose(this) } @@ -209,13 +218,12 @@ class SyncSession : IAuthorizable { fun send(opcode: UByte, subOpcode: UByte, data: ByteBuffer? = null, contentEncoding: ContentEncoding? = null) { ensureNotMainThread() - val channels = synchronized(_channels) { _channels.sortedBy { it.linkType.ordinal }.toList() } + val channels = _snapshot if (channels.isEmpty()) { - //TODO: Should this throw? - Logger.v(TAG, "Packet was not sent (opcode = $opcode, subOpcode = $subOpcode) due to no connected sockets") + Logger.v(TAG, "Packet was not sent … no connected sockets") return } - + var sent = false for (channel in channels) { try { From f5673425b785facc0a5bc22d554ed74637f1b3cf Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 17 May 2025 08:54:40 +0200 Subject: [PATCH 120/128] Updated default price. --- app/src/main/res/layout/fragment_buy.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/fragment_buy.xml b/app/src/main/res/layout/fragment_buy.xml index d12534bd..27464791 100644 --- a/app/src/main/res/layout/fragment_buy.xml +++ b/app/src/main/res/layout/fragment_buy.xml @@ -76,7 +76,7 @@ android:id="@+id/button_buy_text" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="$9.99 + Tax" + android:text="$19 + Tax" android:textSize="14dp" android:textColor="@color/white" android:fontFamily="@font/inter_regular" From 542235cca0f257aa791b8b1ad2bb67fbcba8de34 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 19 May 2025 11:45:17 +0200 Subject: [PATCH 121/128] Keep live chat open when minimizing. #2227 --- .../fragment/mainactivity/main/VideoDetailView.kt | 4 ---- .../java/com/futo/platformplayer/sync/internal/SyncSession.kt | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) 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 21f20403..8fa4e0a5 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 @@ -3138,10 +3138,6 @@ class VideoDetailView : ConstraintLayout { fun applyFragment(frag: VideoDetailFragment) { fragment = frag; - fragment.onMinimize.subscribe { - _liveChat?.stop(); - _container_content_liveChat.close(); - } } diff --git a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt index 575b8af8..59e048c6 100644 --- a/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt +++ b/app/src/main/java/com/futo/platformplayer/sync/internal/SyncSession.kt @@ -223,7 +223,7 @@ class SyncSession : IAuthorizable { Logger.v(TAG, "Packet was not sent … no connected sockets") return } - + var sent = false for (channel in channels) { try { From af337b18749caee7b9f49a8f64add2bae668a241 Mon Sep 17 00:00:00 2001 From: Koen J Date: Mon, 19 May 2025 15:37:39 +0200 Subject: [PATCH 122/128] Added missing checks for polycentric enabled. --- .../channel/tab/ChannelContentsFragment.kt | 4 +- .../mainactivity/main/ChannelFragment.kt | 3 + .../mainactivity/main/PostDetailFragment.kt | 11 +- .../mainactivity/main/VideoDetailView.kt | 113 ++++++++++-------- 4 files changed, 78 insertions(+), 53 deletions(-) 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 b26c9b35..84aa2ab6 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 @@ -70,8 +70,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(), val lastPolycentricProfile = _lastPolycentricProfile; var pager: IPager? = null; - if (lastPolycentricProfile != null) - pager= StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); + if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) + pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile); if(pager == null) { if(subType != null) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt index 6be65482..3ac71315 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ChannelFragment.kt @@ -47,6 +47,7 @@ import com.futo.platformplayer.selectHighestResolutionImage import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlaylists +import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.toHumanNumber import com.futo.platformplayer.views.adapters.ChannelTab @@ -135,6 +136,8 @@ class ChannelFragment : MainFragment() { inflater.inflate(R.layout.fragment_channel, this) _taskLoadPolycentricProfile = TaskHandler({ fragment.lifecycleScope }, { id -> + if (!StatePolycentric.instance.enabled) + return@TaskHandler null return@TaskHandler ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, id.claimFieldType.toLong(), id.claimType.toLong(), id.value!!) }).success { setPolycentricProfile(it, animate = true) }.exception { Logger.w(TAG, "Failed to load polycentric profile.", it) diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt index d4dc1672..896e975e 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PostDetailFragment.kt @@ -168,7 +168,12 @@ class PostDetailFragment : MainFragment { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment); } else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); @@ -327,6 +332,10 @@ class PostDetailFragment : MainFragment { val version = _version; _rating.onLikeDislikeUpdated.remove(this); + + if (!StatePolycentric.instance.enabled) + return + _fragment.lifecycleScope.launch(Dispatchers.IO) { if (version != _version) { return@launch; 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 8fa4e0a5..d1a3e41e 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 @@ -1525,60 +1525,68 @@ class VideoDetailView : ConstraintLayout { _rating.visibility = View.GONE; - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - val queryReferencesResponse = ApiMethods.getQueryReferences( - ApiMethods.SERVER, ref, null, null, - arrayListOf( - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.like.data) - ).build(), - Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() - .setFromType(ContentType.OPINION.value).setValue( - ByteString.copyFrom(Opinion.dislike.data) - ).build() - ), - extraByteReferences = listOfNotNull(extraBytesRef) - ); + if (StatePolycentric.instance.enabled) { + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + val queryReferencesResponse = ApiMethods.getQueryReferences( + ApiMethods.SERVER, ref, null, null, + arrayListOf( + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.like.data) + ).build(), + Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder() + .setFromType(ContentType.OPINION.value).setValue( + ByteString.copyFrom(Opinion.dislike.data) + ).build() + ), + extraByteReferences = listOfNotNull(extraBytesRef) + ); - val likes = queryReferencesResponse.countsList[0]; - val dislikes = queryReferencesResponse.countsList[1]; - val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; - val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; + val likes = queryReferencesResponse.countsList[0]; + val dislikes = queryReferencesResponse.countsList[1]; + val hasLiked = + StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/; + val hasDisliked = + StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/; - withContext(Dispatchers.Main) { - _rating.visibility = View.VISIBLE; - _rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked); - _rating.onLikeDislikeUpdated.subscribe(this) { args -> - if (args.hasLiked) { - args.processHandle.opinion(ref, Opinion.like); - } else if (args.hasDisliked) { - args.processHandle.opinion(ref, Opinion.dislike); - } else { - args.processHandle.opinion(ref, Opinion.neutral); - } - - fragment.lifecycleScope.launch(Dispatchers.IO) { - try { - Logger.i(TAG, "Started backfill"); - args.processHandle.fullyBackfillServersAnnounceExceptions(); - Logger.i(TAG, "Finished backfill"); - } catch (e: Throwable) { - Logger.e(TAG, "Failed to backfill servers", e) + withContext(Dispatchers.Main) { + _rating.visibility = View.VISIBLE; + _rating.setRating( + RatingLikeDislikes(likes, dislikes), + hasLiked, + hasDisliked + ); + _rating.onLikeDislikeUpdated.subscribe(this) { args -> + if (args.hasLiked) { + args.processHandle.opinion(ref, Opinion.like); + } else if (args.hasDisliked) { + args.processHandle.opinion(ref, Opinion.dislike); + } else { + args.processHandle.opinion(ref, Opinion.neutral); } - } - StatePolycentric.instance.updateLikeMap( - ref, - args.hasLiked, - args.hasDisliked - ) - }; + fragment.lifecycleScope.launch(Dispatchers.IO) { + try { + Logger.i(TAG, "Started backfill"); + args.processHandle.fullyBackfillServersAnnounceExceptions(); + Logger.i(TAG, "Finished backfill"); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to backfill servers", e) + } + } + + StatePolycentric.instance.updateLikeMap( + ref, + args.hasLiked, + args.hasDisliked + ) + }; + } + } catch (e: Throwable) { + Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); + _rating.visibility = View.GONE; } - } catch (e: Throwable) { - Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e); - _rating.visibility = View.GONE; } } @@ -3066,7 +3074,12 @@ class VideoDetailView : ConstraintLayout { Logger.w(TAG, "Failed to load recommendations.", it); }; - private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }) + private val _taskLoadPolycentricProfile = TaskHandler(StateApp.instance.scopeGetter, { + if (!StatePolycentric.instance.enabled) + return@TaskHandler null + + ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) + }) .success { it -> setPolycentricProfile(it, animate = true) } .exception { Logger.w(TAG, "Failed to load claims.", it); From b906c1d36b2c8250011451add69426183d940814 Mon Sep 17 00:00:00 2001 From: Kai Date: Wed, 21 May 2025 16:20:35 -0500 Subject: [PATCH 123/128] add assignment for getReplies to PlatformComment Changelog: added --- app/src/main/assets/scripts/source.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js index b6b4ab6d..0aa817af 100644 --- a/app/src/main/assets/scripts/source.js +++ b/app/src/main/assets/scripts/source.js @@ -595,6 +595,8 @@ class PlatformComment { this.date = obj.date ?? 0; this.replyCount = obj.replyCount ?? 0; this.context = obj.context ?? {}; + if(obj.getReplies) + this.getReplies = obj.getReplies; } } From b794ff47ef223188db6910a86967d92c7979eaf9 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 22 May 2025 09:34:32 +0200 Subject: [PATCH 124/128] Added dependency. --- .gitattributes | 1 + aar/ffmpeg-kit-full-6.0-2.LTS.aar | 3 +++ app/build.gradle | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 aar/ffmpeg-kit-full-6.0-2.LTS.aar diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..525c662a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +aar/* filter=lfs diff=lfs merge=lfs -text diff --git a/aar/ffmpeg-kit-full-6.0-2.LTS.aar b/aar/ffmpeg-kit-full-6.0-2.LTS.aar new file mode 100644 index 00000000..27b62b35 --- /dev/null +++ b/aar/ffmpeg-kit-full-6.0-2.LTS.aar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81 +size 65512557 diff --git a/app/build.gradle b/app/build.gradle index 8d55d000..88cd4558 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,7 +197,7 @@ dependencies { implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.arthenica:ffmpeg-kit-full:6.0-2.LTS' + implementation fileTree(dir: 'aar', include: ['*.aar']) implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.google.zxing:core:3.4.1' From 1aa9adc899fec9256ccbcb715a90bb67f847f447 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 22 May 2025 11:41:16 +0200 Subject: [PATCH 125/128] Build fix. --- .gitattributes | 1 + {aar => app/aar}/ffmpeg-kit-full-6.0-2.LTS.aar | 0 2 files changed, 1 insertion(+) rename {aar => app/aar}/ffmpeg-kit-full-6.0-2.LTS.aar (100%) diff --git a/.gitattributes b/.gitattributes index 525c662a..173a6f10 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ aar/* filter=lfs diff=lfs merge=lfs -text +app/aar/* filter=lfs diff=lfs merge=lfs -text diff --git a/aar/ffmpeg-kit-full-6.0-2.LTS.aar b/app/aar/ffmpeg-kit-full-6.0-2.LTS.aar similarity index 100% rename from aar/ffmpeg-kit-full-6.0-2.LTS.aar rename to app/aar/ffmpeg-kit-full-6.0-2.LTS.aar From 1ecd1f5e04f8413847164349b38dcb04abc9f3f3 Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 22 May 2025 13:45:57 +0200 Subject: [PATCH 126/128] Fix for throttled networks (airplane wifi) freezing app opening downloaded content. --- .../api/media/PlatformClientPool.kt | 6 ++++-- .../api/media/PlatformMultiClientPool.kt | 6 ++++-- .../api/media/platforms/js/DevJSClient.kt | 7 +++++-- .../api/media/platforms/js/JSClient.kt | 9 ++++++-- .../platformplayer/states/StatePlatform.kt | 21 ++++++++++--------- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt index 0119bf38..211f83a6 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientPool.kt @@ -14,14 +14,16 @@ class PlatformClientPool { private var _poolCounter = 0; private val _poolName: String?; private val _privatePool: Boolean; + private val _isolatedInitialization: Boolean var isDead: Boolean = false private set; val onDead = Event2(); - constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false) { + constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) { _poolName = name; _privatePool = privatePool; + _isolatedInitialization = isolatedInitialization if(parentClient !is JSClient) throw IllegalArgumentException("Pooling only supported for JSClients right now"); Logger.i(TAG, "Pool for ${parentClient.name} was started"); @@ -53,7 +55,7 @@ class PlatformClientPool { reserved = _pool.keys.find { !it.isBusy }; if(reserved == null && _pool.size < capacity) { Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})"); - reserved = _parent.getCopy(_privatePool); + reserved = _parent.getCopy(_privatePool, _isolatedInitialization); reserved?.onCaptchaException?.subscribe { client, ex -> StateApp.instance.handleCaptchaException(client, ex); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt index a9fc3819..fcc85371 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformMultiClientPool.kt @@ -7,13 +7,15 @@ class PlatformMultiClientPool { private var _isFake = false; private var _privatePool = false; + private val _isolatedInitialization: Boolean - constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) { + constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) { _name = name; _maxCap = if(maxCap > 0) maxCap else 99; _privatePool = isPrivatePool; + _isolatedInitialization = isolatedInitialization } fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient { @@ -21,7 +23,7 @@ class PlatformMultiClientPool { return parentClient; val pool = synchronized(_clientPools) { if(!_clientPools.containsKey(parentClient)) - _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply { + _clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply { this.onDead.subscribe { _, pool -> synchronized(_clientPools) { if(_clientPools[parentClient] == pool) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt index 1d726dd5..b26abe45 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/DevJSClient.kt @@ -54,8 +54,11 @@ class DevJSClient : JSClient { return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings); } - override fun getCopy(privateCopy: Boolean): JSClient { - return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, saveState(), devID); + override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient { + val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID); + if (noSaveState) + client.initialize() + return client } override fun initialize() { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 371d846b..476bad8a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -195,8 +195,11 @@ open class JSClient : IPlatformClient { _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); } - open fun getCopy(withoutCredentials: Boolean = false): JSClient { - return JSClient(_context, descriptor, saveState(), _script, withoutCredentials); + open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient { + val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials); + if (noSaveState) + client.initialize() + return client } fun getUnderlyingPlugin(): V8Plugin { @@ -211,6 +214,8 @@ open class JSClient : IPlatformClient { } override fun initialize() { + if (_initialized) return + Logger.i(TAG, "Plugin [${config.name}] initializing"); plugin.start(); plugin.execute("plugin.config = ${Json.encodeToString(config)}"); 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 be364967..2f33538d 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -94,6 +94,7 @@ class StatePlatform { private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events private val _privateClientPool = PlatformMultiClientPool("Private", 2, true); //Used primarily for calls if in incognito mode + private val _instantClientPool = PlatformMultiClientPool("Instant", 1, false, true); //Used for all instant calls private val _icons : HashMap = HashMap(); @@ -114,14 +115,14 @@ class StatePlatform { Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]"); if(!StateApp.instance.privateMode) { - _enabledClients.find { it.isContentDetailsUrl(url) }?.let { + _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _mainClientPool.getClientPooled(it).getContentDetails(url) } ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); } else { Logger.i(TAG, "Fetching details with private client"); - _enabledClients.find { it.isContentDetailsUrl(url) }?.let { + _enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let { _privateClientPool.getClientPooled(it).getContentDetails(url) } ?: throw NoPlatformClientException("No client enabled that supports this url ($url)"); @@ -668,10 +669,10 @@ class StatePlatform { //Video - fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { it.isContentDetailsUrl(url) }; + fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url) ?: throw NoPlatformClientException("No client enabled that supports this content url (${url})"); - fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { it.isContentDetailsUrl(url) }; + fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }; fun getContentDetails(url: String, forceRefetch: Boolean = false): Deferred { Logger.i(TAG, "Platform - getContentDetails (${url})"); if(forceRefetch) @@ -712,14 +713,14 @@ class StatePlatform { return client.getContentRecommendations(url); } - fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { it.isChannelUrl(url) }; + fun hasEnabledChannelClient(url : String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isChannelUrl(url) }; fun getChannelClient(url : String, exclude: List? = null) : IPlatformClient = getChannelClientOrNull(url, exclude) ?: throw NoPlatformClientException("No client enabled that supports this channel url (${url})"); fun getChannelClientOrNull(url : String, exclude: List? = null) : IPlatformClient? = if(exclude == null) - getEnabledClients().find { it.isChannelUrl(url) } + getEnabledClients().find { _instantClientPool.getClientPooled(it).isChannelUrl(url) } else - getEnabledClients().find { !exclude.contains(it.id) && it.isChannelUrl(url) }; + getEnabledClients().find { !exclude.contains(it.id) && _instantClientPool.getClientPooled(it).isChannelUrl(url) }; fun getChannel(url: String, updateSubscriptions: Boolean = true): Deferred { Logger.i(TAG, "Platform - getChannel"); @@ -906,9 +907,9 @@ class StatePlatform { return urls; } - fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { it.isPlaylistUrl(url) }; - fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { it.isPlaylistUrl(url) } - fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { it.isPlaylistUrl(url) } + fun hasEnabledPlaylistClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) }; + fun getPlaylistClientOrNull(url: String): IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) } + fun getPlaylistClient(url: String): IPlatformClient = getEnabledClients().find { _instantClientPool.getClientPooled(it).isPlaylistUrl(url) } ?: throw NoPlatformClientException("No client enabled that supports this playlist url (${url})"); fun getPlaylist(url: String): IPlatformPlaylistDetails { return getPlaylistClient(url).getPlaylist(url); From ed1f7e7c72c3de0acd6575f537249f12ddd0d62f Mon Sep 17 00:00:00 2001 From: Koen J Date: Thu, 22 May 2025 15:57:12 +0200 Subject: [PATCH 127/128] Button incognito is now visible but translated when minimized. --- .../platformplayer/activities/MainActivity.kt | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index 35fc95f0..095ac521 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.isVisible @@ -40,6 +41,7 @@ import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.constructs.Event1 +import com.futo.platformplayer.dp import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment @@ -190,7 +192,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { private var _privateModeEnabled = false private var _pictureInPictureEnabled = false private var _isFullscreen = false - private var _isMinimized = false private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data) @@ -369,8 +370,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { _buttonIncognito = findViewById(R.id.incognito_button); - _buttonIncognito.elevation = -99f; - _buttonIncognito.alpha = 0f; + updatePrivateModeVisibility() StateApp.instance.privateModeChanged.subscribe { //Messing with visibility causes some issues with layout ordering? _privateModeEnabled = it @@ -398,12 +398,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } _fragVideoDetail.onMinimize.subscribe { - _isMinimized = true updatePrivateModeVisibility() } _fragVideoDetail.onMaximized.subscribe { - _isMinimized = false updatePrivateModeVisibility() } @@ -647,13 +645,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher { } @OptIn(UnstableApi::class) - fun updatePrivateModeVisibility() { - if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen && !_isMinimized)) { + private fun updatePrivateModeVisibility() { + if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) { _buttonIncognito.elevation = 99f; _buttonIncognito.alpha = 1f; - _buttonIncognito.layoutParams = _buttonIncognito.layoutParams.apply { - - } + _buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f } else { _buttonIncognito.elevation = -99f; _buttonIncognito.alpha = 0f; From 8aa4de75228006bd0e702aef803e5e3ec620aedf Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 22 May 2025 11:28:59 -0500 Subject: [PATCH 128/128] make sure the plugin supports shorts content Changelog: added --- .../main/java/com/futo/platformplayer/states/StatePlatform.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0bb8fb85..f4460c3a 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -790,7 +790,7 @@ class StatePlatform { pagerResult = client.getChannelContents(channelUrl, ResultCapabilities.TYPE_MIXED, ResultCapabilities.ORDER_CHONOLOGICAL); } } else { - pagerResult = if (type == ResultCapabilities.TYPE_SHORTS) { + pagerResult = if (type == ResultCapabilities.TYPE_SHORTS && clientCapabilities.hasType(ResultCapabilities.TYPE_SHORTS)) { client.getChannelContents(channelUrl, ResultCapabilities.TYPE_SHORTS, ResultCapabilities.ORDER_CHONOLOGICAL); } else { EmptyPager()