Merge branch 'refs/heads/master' into update-deps

# Conflicts:
#	dep/futopay
#	dep/polycentricandroid
This commit is contained in:
Kai DeLorenzo 2024-08-27 05:54:35 -05:00
commit 228ab359ed
No known key found for this signature in database
56 changed files with 4023 additions and 566 deletions

View file

@ -144,10 +144,18 @@ android {
buildFeatures {
buildConfig true
}
sourceSets {
main {
assets {
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
}
}
}
}
dependencies {
implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core
@ -186,7 +194,6 @@ dependencies {
implementation 'androidx.media:media:1.7.0'
//Other
implementation 'org.jmdns:jmdns:3.5.1'
implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'

View file

@ -406,6 +406,39 @@ class DashSource {
this.requestModifier = obj.requestModifier;
}
}
class DashManifestRawSource {
constructor(obj) {
obj = obj ?? {};
this.plugin_type = "DashRawSource";
this.name = obj.name ?? "";
this.bitrate = obj.bitrate ?? 0;
this.container = obj.container ?? "";
this.codec = obj.codec ?? "";
this.duration = obj.duration ?? 0;
this.url = obj.url;
this.language = obj.language ?? Language.UNKNOWN;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class DashManifestRawAudioSource {
constructor(obj) {
obj = obj ?? {};
this.plugin_type = "DashRawAudioSource";
this.name = obj.name ?? "";
this.bitrate = obj.bitrate ?? 0;
this.container = obj.container ?? "";
this.codec = obj.codec ?? "";
this.duration = obj.duration ?? 0;
this.url = obj.url;
this.language = obj.language ?? Language.UNKNOWN;
this.manifest = obj.manifest ?? null;
if(obj.requestModifier)
this.requestModifier = obj.requestModifier;
}
}
class RequestModifier {
constructor(obj) {

View file

@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
@UnstableApi
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
val requestModifier = getRequestModifier();
return if (requestModifier != null) {
val requestExecutor = getRequestExecutor();
return if (requestExecutor != null) {
JSHttpDataSource.Factory().setRequestExecutor(requestExecutor);
} else if (requestModifier != null) {
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
} else {
DefaultHttpDataSource.Factory();

File diff suppressed because one or more lines are too long

View file

@ -350,7 +350,7 @@ class Settings : FragmentedStorageFileJson() {
var playback = PlaybackSettings();
@Serializable
class PlaybackSettings {
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1)
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
@ -377,7 +377,7 @@ class Settings : FragmentedStorageFileJson() {
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.playback_speeds)
var defaultPlaybackSpeed: Int = 3;
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
@ -393,22 +393,26 @@ class Settings : FragmentedStorageFileJson() {
else -> 1.0f;
};
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 1)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredQuality: Int = 0;
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredMeteredQuality: Int = 0;
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
var simplifySources: Boolean = true;
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2;
@ -466,6 +470,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
var fullscreenPortrait: Boolean = false;
@FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 14)
var preferWebmVideo: Boolean = false;
@FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 15)
var preferWebmAudio: Boolean = false;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@ -795,10 +805,10 @@ class Settings : FragmentedStorageFileJson() {
fun export() {
val activity = SettingsActivity.getActivity() ?: return;
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
StateBackup.shareExternalBackup();
}),
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = {
StateBackup.saveExternalBackup(activity);
})
)

View file

@ -40,7 +40,6 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptionGroups
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.LoaderView
@ -92,9 +91,17 @@ class UISlideOverlays {
withContext(Dispatchers.Main) {
items.addAll(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false),
SlideUpMenuItem(
container.context,
R.drawable.ic_notifications,
"Notifications",
"",
tag = "notifications",
call = {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
},
invokeParent = false
),
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
SlideUpMenuGroup(container.context, "Subscription Groups",
"You can select which groups this subscription is part of.",
@ -129,22 +136,62 @@ class UISlideOverlays {
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
container.context,
R.drawable.ic_live_tv,
"Livestreams",
"Check for livestreams",
tag = "fetchLive",
call = {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Streams",
"Check for streams",
tag = "fetchStreams",
call = {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
}, false) else null/*,,
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Videos",
"Check for videos",
tag = "fetchVideos",
call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
},
invokeParent = false
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(
container.context,
R.drawable.ic_play,
"Content",
"Check for content",
tag = "fetchVideos",
call = {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
},
invokeParent = false
) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
container.context,
R.drawable.ic_chat,
"Posts",
"Check for posts",
tag = "fetchPosts",
call = {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
},
invokeParent = false
) else null/*,,
SlideUpMenuGroup(container.context, "Actions",
"Various things you can do with this subscription",
@ -243,11 +290,23 @@ class UISlideOverlays {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
masterPlaylist.getAudioSources().forEach { it ->
audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudioVariant = it
slideUpMenuOverlay.selectOption(audioButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
@ -259,11 +318,22 @@ class UISlideOverlays {
}*/
masterPlaylist.getVideoSources().forEach {
videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideoVariant = it
slideUpMenuOverlay.selectOption(videoButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
}
val newItems = arrayListOf<View>()
@ -342,29 +412,56 @@ class UISlideOverlays {
}
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", {
selectedVideo = null;
menu?.selectOption(videoSources, "none");
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)) +
listOf(listOf(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
container.context.getString(R.string.none),
container.context.getString(R.string.audio_only),
tag = "none",
call = {
selectedVideo = null;
menu?.selectOption(videoSources, "none");
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)) +
videoSources
.filter { it.isDownloadable() }
.map {
when (it) {
is IVideoUrlSource -> {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
}, false)
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedVideo = it
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
}
is IHLSManifestSource -> {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
showHlsPicker(video, it, it.url, container)
}, false)
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"HLS",
tag = it,
call = {
showHlsPicker(video, it, it.url, container)
},
invokeParent = false
)
}
else -> {
@ -389,17 +486,36 @@ class UISlideOverlays {
.map {
when (it) {
is IAudioUrlSource -> {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
}, false);
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(
container.context,
R.drawable.ic_music,
it.name,
"${it.bitrate}",
(prefix + it.codec).trim(),
tag = it,
call = {
selectedAudio = it
menu?.selectOption(audioSources, it);
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
);
}
is IHLSManifestAudioSource -> {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
showHlsPicker(video, it, it.url, container)
}, false)
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.name,
"HLS Audio",
tag = it,
call = {
showHlsPicker(video, it, it.url, container)
},
invokeParent = false
)
}
else -> {
@ -417,15 +533,23 @@ class UISlideOverlays {
if(contentResolver != null && subtitleSources.isNotEmpty()) {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map {
SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
if (selectedSubtitle == it) {
selectedSubtitle = null;
menu?.selectOption(subtitleSources, null);
} else {
selectedSubtitle = it;
menu?.selectOption(subtitleSources, it);
}
}, false);
SlideUpMenuItem(
container.context,
R.drawable.ic_edit,
it.name,
"",
tag = it,
call = {
if (selectedSubtitle == it) {
selectedSubtitle = null;
menu?.selectOption(subtitleSources, null);
} else {
selectedSubtitle = it;
menu?.selectOption(subtitleSources, it);
}
},
invokeParent = false
);
})
);
}
@ -537,23 +661,47 @@ class UISlideOverlays {
);
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, {
targetPxSize = it.third;
menu?.selectOption("Video", it.third);
}, false)
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
it.first,
it.second,
tag = it.third,
call = {
targetPxSize = it.third;
menu?.selectOption("Video", it.third);
},
invokeParent = false
)
}));
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
targetBitrate = 1;
menu?.selectOption("Bitrate", 1);
menu?.setOk(container.context.getString(R.string.download));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
targetBitrate = 9999999;
menu?.selectOption("Bitrate", 9999999);
menu?.setOk(container.context.getString(R.string.download));
}, false)
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
container.context.getString(R.string.low_bitrate),
"",
tag = 1,
call = {
targetBitrate = 1;
menu?.selectOption("Bitrate", 1);
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
),
SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
container.context.getString(R.string.high_bitrate),
"",
tag = 9999999,
call = {
targetBitrate = 9999999;
menu?.selectOption("Bitrate", 9999999);
menu?.setOk(container.context.getString(R.string.download));
},
invokeParent = false
)
)));
@ -676,8 +824,12 @@ class UISlideOverlays {
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
{
SlideUpMenuItem(container.context,
R.drawable.ic_playlist_add,
lastUpdated.name,
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
}))
@ -689,44 +841,90 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
showDownloadVideoOverlay(video, container, true);
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
container.context.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain";
}, null));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = "download",
call = {
showDownloadVideoOverlay(video, container, true);
},
invokeParent = false
),
SlideUpMenuItem(
container.context,
R.drawable.ic_share,
container.context.getString(R.string.share),
"Share the video",
tag = "share",
call = {
val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
container.context.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, url);
type = "text/plain";
}, null));
},
invokeParent = false
),
SlideUpMenuItem(
container.context,
R.drawable.ic_visibility_off,
container.context.getString(R.string.hide_creator_from_home),
"",
tag = "hide_creator",
call = {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions)
));
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
{ StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", "history",
{ StateHistory.instance.markAsWatched(video); }),
SlideUpMenuItem(container.context,
R.drawable.ic_queue_add,
container.context.getString(R.string.add_to_queue),
"${queue.size} " + container.context.getString(R.string.videos),
tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context,
R.drawable.ic_watchlist_add,
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context,
R.drawable.ic_history,
container.context.getString(R.string.add_to_history),
"Mark as watched",
tag = "history",
call = { StateHistory.instance.markAsWatched(video); }),
));
val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
};
}, false))
playlistItems.add(SlideUpMenuItem(
container.context,
R.drawable.ic_playlist_add,
container.context.getString(R.string.new_playlist),
container.context.getString(R.string.add_to_new_playlist),
tag = "add_to_new_playlist",
call = {
showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
};
},
invokeParent = false
))
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{
playlistItems.add(SlideUpMenuItem(container.context,
R.drawable.ic_playlist_add,
"${container.context.getString(R.string.add_to)} " + playlist.name + "",
"${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
}));
@ -748,8 +946,12 @@ class UISlideOverlays {
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
{
SlideUpMenuItem(container.context,
R.drawable.ic_playlist_add,
lastUpdated.name,
"${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
}))
@ -761,25 +963,52 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
{ StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
{ StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download),
{ showDownloadVideoOverlay(video, container, true); }, false))
SlideUpMenuItem(container.context,
R.drawable.ic_queue_add,
container.context.getString(R.string.queue),
"${queue.size} " + container.context.getString(R.string.videos),
tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context,
R.drawable.ic_watchlist_add,
StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
SlideUpMenuItem(
container.context,
R.drawable.ic_download,
container.context.getString(R.string.download),
container.context.getString(R.string.download_the_video),
tag = container.context.getString(R.string.download),
call = { showDownloadVideoOverlay(video, container, true); },
invokeParent = false
))
);
val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
});
}, false))
playlistItems.add(SlideUpMenuItem(
container.context,
R.drawable.ic_playlist_add,
container.context.getString(R.string.new_playlist),
container.context.getString(R.string.add_to_new_playlist),
tag = "add_to_new_playlist",
call = {
slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
});
},
invokeParent = false
))
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{
playlistItems.add(SlideUpMenuItem(container.context,
R.drawable.ic_playlist_add,
playlist.name,
"${playlist.videos.size} " + container.context.getString(R.string.videos),
tag = "",
call = {
StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
}));
@ -804,20 +1033,36 @@ class UISlideOverlays {
val views = arrayOf(
hidden
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
btn.handler?.invoke(btn);
}, invokeParents) as View }.toTypedArray(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
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 }
.map { it!! }
.toList();
.map { btn -> SlideUpMenuItem(
container.context,
btn.iconResource,
btn.text.text.toString(),
"",
tag = "",
call = {
btn.handler?.invoke(btn);
},
invokeParent = invokeParents
) as View }.toTypedArray(),
arrayOf(SlideUpMenuItem(
container.context,
R.drawable.ic_pin,
container.context.getString(R.string.change_pins),
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!!) }) {
val selected = it
.map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
.filter { it != null }
.map { it!! }
.toList();
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
}
}, false))
onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
}
},
invokeParent = false
))
).flatten().toTypedArray();
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
@ -829,14 +1074,21 @@ class UISlideOverlays {
var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
options.map { SlideUpMenuItem(
container.context,
R.drawable.ic_move_up,
it.first,
"",
tag = it.second,
call = {
if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second))
selection.add(it.second);
}
else
} else
selection.remove(it.second);
}, false)
},
invokeParent = false
)
});
overlay.onOK.subscribe {
onOrdered.invoke(selection);

View file

@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) {
companion object {
fun fromInt(value: Int): ChapterType
{
val result = ChapterType.values().firstOrNull { it.value == value };
val result = ChapterType.entries.firstOrNull { it.value == value };
if(result == null)
throw UnknownPlatformException(value.toString());
return result;

View file

@ -21,7 +21,7 @@ enum class ContentType(val value: Int) {
companion object {
fun fromInt(value: Int): ContentType
{
val result = ContentType.values().firstOrNull { it.value == value };
val result = ContentType.entries.firstOrNull { it.value == value };
if(result == null)
throw UnknownPlatformException(value.toString());
return result;

View file

@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) {
companion object{
fun fromInt(value : Int) : LiveEventType{
return LiveEventType.values().first { it.value == value };
return LiveEventType.entries.first { it.value == value };
}
}
}

View file

@ -10,7 +10,7 @@ enum class TextType(val value: Int) {
companion object {
fun fromInt(value: Int): TextType
{
val result = TextType.values().firstOrNull { it.value == value };
val result = TextType.entries.firstOrNull { it.value == value };
if(result == null)
throw IllegalArgumentException("Unknown Texttype: $value");
return result;

View file

@ -8,7 +8,7 @@ enum class RatingType(val value : Int) {
companion object{
fun fromInt(value : Int) : RatingType{
return RatingType.values().first { it.value == value };
return RatingType.entries.first { it.value == value };
}
}
}

View file

@ -0,0 +1,7 @@
package com.futo.platformplayer.api.media.platforms.js
class JSClientConstants {
companion object {
val PLUGIN_SPEC_VERSION = 2;
}
}

View file

@ -4,6 +4,7 @@ import android.net.Uri
import com.futo.platformplayer.SignatureProvider
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.Contextual
import java.net.URL
@ -79,7 +80,7 @@ class SourcePluginConfig(
private val _allowUrlsLower: List<String> get() {
if(_allowUrlsLowerVal == null)
_allowUrlsLowerVal = allowUrls.map { it.lowercase() }
.filter { it.length > 0 && (it[0] != '*' || (_allowRegex.matches(it))) };
.filter { it.length > 0 };
return _allowUrlsLowerVal!!;
};
@ -172,12 +173,10 @@ class SourcePluginConfig(
return true;
val uri = Uri.parse(url);
val host = uri.host?.lowercase() ?: "";
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '*' && host.endsWith(it.substring(1))) };
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
}
companion object {
private val _allowRegex = Regex("\\*\\.[a-z0-9]+\\.[a-z]+");
fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);
if(obj.sourceUrl == null)

View file

@ -54,4 +54,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
_hasGetDetails = _content.has("getDetails");
}
fun getUnderlyingObject(): V8ValueObject? {
return _content;
}
}

View file

@ -0,0 +1,134 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.primitive.V8ValueUndefined
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.serialization.Serializable
import java.util.Base64
class JSRequestExecutor {
private val _plugin: JSClient;
private val _config: IV8PluginConfig;
private var _executor: V8ValueObject;
val urlPrefix: String?;
private val hasCleanup: Boolean;
constructor(plugin: JSClient, executor: V8ValueObject) {
this._plugin = plugin;
this._executor = executor;
this._config = plugin.config;
val config = plugin.config;
urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
if(!executor.has("executeRequest"))
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
hasCleanup = executor.has("cleanup");
}
//TODO: Executor properties?
@Throws(ScriptException::class)
open fun executeRequest(url: String, headers: Map<String, String>): ByteArray {
if (_executor.isClosed)
throw IllegalStateException("Executor object is closed");
val result = if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
V8Plugin.catchScriptErrors<Any>(
_config,
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invoke("executeRequest", url, headers);
} as V8Value;
}
else V8Plugin.catchScriptErrors<Any>(
_config,
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invoke("executeRequest", url, headers);
} as V8Value;
try {
if(result is V8ValueString) {
val base64Result = Base64.getDecoder().decode(result.value);
return base64Result;
}
if(result is V8ValueTypedArray) {
val buffer = result.buffer;
val byteBuffer = buffer.byteBuffer;
val bytesResult = ByteArray(result.byteLength);
byteBuffer.get(bytesResult, 0, result.byteLength);
buffer.close();
return bytesResult;
}
if(result is V8ValueObject && result.has("type")) {
val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier");
when(type) {
//TODO: Buffer type?
}
}
if(result is V8ValueUndefined) {
if(_plugin is DevJSClient)
StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined");
throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null);
}
throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name);
}
finally {
result.close();
}
}
open fun cleanup() {
if (!hasCleanup || _executor.isClosed)
return;
if(_plugin is DevJSClient)
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
V8Plugin.catchScriptErrors<Any>(
_config,
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invokeVoid("cleanup", null);
};
}
else V8Plugin.catchScriptErrors<Any>(
_config,
"[${_config.name}] JSRequestExecutor",
"builder.modifyRequest()"
) {
_executor.invokeVoid("cleanup", null);
};
}
protected fun finalize() {
cleanup();
}
}
//TODO: are these available..?
@Serializable
class ExecutorParameters {
var rangeStart: Int = -1;
var rangeEnd: Int = -1;
var segment: Int = -1;
}

View file

@ -0,0 +1,64 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper
class JSDashManifestRawAudioSource : JSSource, IAudioSource {
override val container : String = "application/dash+xml";
override val name : String;
override val codec: String;
override val bitrate: Int;
override val duration: Long;
override val priority: Boolean;
override val language: String;
val url: String;
var manifest: String?;
val hasGenerate: Boolean;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
manifest = _obj.getOrThrow(config, "manifest", contextName);
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
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;
hasGenerate = _obj.has("generate");
}
fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val plugin = _plugin.getUnderlyingPlugin();
if(_plugin is DevJSClient)
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate");
}
}
else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
_obj.invokeString("generate");
}
}
}

View file

@ -0,0 +1,109 @@
package com.futo.platformplayer.api.media.platforms.js.models.sources
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.states.StateDeveloper
open class JSDashManifestRawSource: JSSource, IVideoSource {
override val container : String = "application/dash+xml";
override val name : String;
override val width: Int;
override val height: Int;
override val codec: String;
override val bitrate: Int?;
override val duration: Long;
override val priority: Boolean;
var url: String?;
var manifest: String?;
val hasGenerate: Boolean;
val canMerge: Boolean;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
hasGenerate = _obj.has("generate");
}
open fun generate(): String? {
if(!hasGenerate)
return manifest;
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
if(_plugin is DevJSClient) {
return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate");
});
}
}
else
return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
_obj.invokeString("generate");
});
}
}
class JSDashManifestMergingRawSource(
val video: JSDashManifestRawSource,
val audio: JSDashManifestRawAudioSource): JSDashManifestRawSource(video.getUnderlyingPlugin()!!, video.getUnderlyingObject()!!), IVideoSource {
override val name: String
get() = video.name;
override val bitrate: Int
get() = (video.bitrate ?: 0) + audio.bitrate;
override val codec: String
get() = video.codec
override val container: String
get() = video.container
override val duration: Long
get() = video.duration;
override val height: Int
get() = video.height;
override val width: Int
get() = video.width;
override val priority: Boolean
get() = video.priority;
override fun generate(): String? {
val videoDash = video.generate();
val audioDash = audio.generate();
if(videoDash != null && audioDash == null) return videoDash;
if(audioDash != null && videoDash == null) return audioDash;
if(videoDash == null) return null;
//TODO: Temporary simple solution..make more reliable version
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
if(audioAdaptationSet != null) {
return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
}
else
return videoDash;
}
companion object {
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
}
}

View file

@ -10,10 +10,12 @@ import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.models.JSRequest
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.orNull
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
@ -21,9 +23,17 @@ abstract class JSSource {
protected val _plugin: JSClient;
protected val _config: IV8PluginConfig;
protected val _obj: V8ValueObject;
val hasRequestModifier: Boolean;
private val _requestModifier: JSRequest?;
val hasRequestExecutor: Boolean;
private val _requestExecutor: JSRequest?;
val requiresCustomDatasource: Boolean get() {
return hasRequestModifier || hasRequestExecutor;
}
val type : String;
constructor(type: String, plugin: JSClient, obj: V8ValueObject) {
@ -36,6 +46,11 @@ abstract class JSSource {
JSRequest(plugin, it, null, null, true);
}
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
JSRequest(plugin, it, null, null, true);
}
hasRequestExecutor = _requestModifier != null || obj.has("getRequestExecutor");
}
fun getRequestModifier(): IRequestModifier? {
@ -44,20 +59,38 @@ abstract class JSSource {
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
};
if (!hasRequestModifier || _obj.isClosed) {
if (!hasRequestModifier || _obj.isClosed)
return null;
}
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
_obj.invoke("getRequestModifier", arrayOf<Any>());
};
if (result !is V8ValueObject) {
if (result !is V8ValueObject)
return null;
}
return JSRequestModifier(_plugin, result)
}
open fun getRequestExecutor(): JSRequestExecutor? {
if (!hasRequestExecutor || _obj.isClosed)
return null;
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
_obj.invoke("getRequestExecutor", arrayOf<Any>());
};
if (result !is V8ValueObject)
return null;
return JSRequestExecutor(_plugin, result)
}
fun getUnderlyingPlugin(): JSClient? {
return _plugin;
}
fun getUnderlyingObject(): V8ValueObject? {
return _obj;
}
companion object {
const val TYPE_AUDIOURL = "AudioUrlSource";
@ -65,33 +98,45 @@ abstract class JSSource {
const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
const val TYPE_DASH = "DashSource";
const val TYPE_DASH_RAW = "DashRawSource";
const val TYPE_DASH_RAW_AUDIO = "DashRawAudioSource";
const val TYPE_HLS = "HLSSource";
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource {
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? {
val type = obj.getString("plugin_type");
return when(type) {
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
TYPE_HLS -> fromV8HLS(plugin, obj);
TYPE_DASH -> fromV8Dash(plugin, obj);
else -> throw NotImplementedError("Unknown type ${type}");
TYPE_DASH_RAW -> fromV8DashRaw(plugin, obj);
else -> {
Logger.w("JSSource", "Unknown video type ${type}");
null;
};
}
}
fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) };
fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj);
fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj);
fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj);
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj);
fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource {
fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? {
val type = obj.getString("plugin_type");
return when(type) {
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj);
TYPE_DASH_RAW_AUDIO -> fromV8DashRawAudio(plugin, obj);
TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj);
TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(plugin, obj);
else -> throw NotImplementedError("Unknown type ${type}");
else -> {
Logger.w("JSSource", "Unknown audio type ${type}");
null;
};
}
}
}

View file

@ -23,9 +23,11 @@ class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor {
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
.filterNotNull()
.toTypedArray();
this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray()
.map { JSSource.fromV8Audio(plugin, it as V8ValueObject) }
.filterNotNull()
.toTypedArray();
}
}

View file

@ -21,6 +21,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
.map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
.filterNotNull()
.toTypedArray();
}

View file

@ -31,6 +31,8 @@ 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
@ -45,15 +47,10 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.InetAddress
import java.util.UUID
import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
import javax.jmdns.ServiceTypeListener
class StateCasting {
private val _scopeIO = CoroutineScope(Dispatchers.IO);
private val _scopeMain = CoroutineScope(Dispatchers.Main);
private var _jmDNS: JmDNS? = null;
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
private val _castServer = ManagedHttpServer(9999);
@ -72,103 +69,47 @@ class StateCasting {
var activeDevice: CastingDevice? = 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) }
val isCasting: Boolean get() = activeDevice != null;
private val _chromecastServiceListener = object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
Logger.i(TAG, "ChromeCast service added: " + event.info);
addOrUpdateDevice(event);
}
override fun serviceRemoved(event: ServiceEvent) {
Logger.i(TAG, "ChromeCast service removed: " + event.info);
synchronized(devices) {
val device = devices[event.info.name];
if (device != null) {
onDeviceRemoved.emit(device);
private fun handleServiceUpdated(services: List<DnsService>) {
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)
}
}
}
override fun serviceResolved(event: ServiceEvent) {
Logger.v(TAG, "ChromeCast service resolved: " + event.info);
addOrUpdateDevice(event);
}
fun addOrUpdateDevice(event: ServiceEvent) {
addOrUpdateChromeCastDevice(event.info.name, event.info.inetAddresses, event.info.port);
}
}
private val _airPlayServiceListener = object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
Logger.i(TAG, "AirPlay service added: " + event.info);
addOrUpdateDevice(event);
}
override fun serviceRemoved(event: ServiceEvent) {
Logger.i(TAG, "AirPlay service removed: " + event.info);
synchronized(devices) {
val device = devices[event.info.name];
if (device != null) {
onDeviceRemoved.emit(device);
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)
}
}
}
override fun serviceResolved(event: ServiceEvent) {
Logger.i(TAG, "AirPlay service resolved: " + event.info);
addOrUpdateDevice(event);
}
fun addOrUpdateDevice(event: ServiceEvent) {
addOrUpdateAirPlayDevice(event.info.name, event.info.inetAddresses, event.info.port);
}
}
private val _fastCastServiceListener = object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
Logger.i(TAG, "FastCast service added: " + event.info);
addOrUpdateDevice(event);
}
override fun serviceRemoved(event: ServiceEvent) {
Logger.i(TAG, "FastCast service removed: " + event.info);
synchronized(devices) {
val device = devices[event.info.name];
if (device != null) {
onDeviceRemoved.emit(device);
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)
}
}
override fun serviceResolved(event: ServiceEvent) {
Logger.i(TAG, "FastCast service resolved: " + event.info);
addOrUpdateDevice(event);
}
fun addOrUpdateDevice(event: ServiceEvent) {
addOrUpdateFastCastDevice(event.info.name, event.info.inetAddresses, event.info.port);
}
}
private val _serviceTypeListener = object : ServiceTypeListener {
override fun serviceTypeAdded(event: ServiceEvent?) {
if (event == null) {
return;
}
Logger.i(TAG, "Service type added (name: ${event.name}, type: ${event.type})");
}
override fun subTypeForServiceTypeAdded(event: ServiceEvent?) {
if (event == null) {
return;
}
Logger.i(TAG, "Sub type for service type added (name: ${event.name}, type: ${event.type})");
}
}
fun handleUrl(context: Context, url: String) {
@ -237,29 +178,30 @@ class StateCasting {
rememberedDevices.clear();
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
_scopeIO.launch {
try {
val jmDNS = JmDNS.create(InetAddress.getLocalHost());
jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener);
jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
jmDNS.addServiceListener("_fcast._tcp.local.", _fastCastServiceListener);
if (BuildConfig.DEBUG) {
jmDNS.addServiceTypeListener(_serviceTypeListener);
}
_jmDNS = jmDNS;
} catch (e: Throwable) {
Logger.e(TAG, "Failed to start casting service.", e);
}
}
_castServer.start();
enableDeveloper(true);
Logger.i(TAG, "CastingService started.");
}
@Synchronized
fun startDiscovering() {
try {
_serviceDiscoverer.start()
} catch (e: Throwable) {
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
}
}
@Synchronized
fun stopDiscovering() {
try {
_serviceDiscoverer.stop()
} catch (e: Throwable) {
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
}
}
@Synchronized
fun stop() {
if (!_started)
@ -269,25 +211,7 @@ class StateCasting {
Logger.i(TAG, "CastingService stopping.")
val jmDNS = _jmDNS;
if (jmDNS != null) {
_scopeIO.launch {
try {
jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener);
jmDNS.removeServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
if (BuildConfig.DEBUG) {
jmDNS.removeServiceTypeListener(_serviceTypeListener);
}
jmDNS.close();
} catch (e: Throwable) {
Logger.e(TAG, "Failed to stop mDNS.", e);
}
}
}
stopDiscovering()
_scopeIO.cancel();
_scopeMain.cancel();
@ -1245,7 +1169,7 @@ class StateCasting {
}
} else {
val newDevice = deviceFactory();
devices[name] = newDevice;
this.devices[name] = newDevice;
invokeEvents = {
onDeviceAdded.emit(newDevice);

View file

@ -25,10 +25,8 @@ import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StatePlatform
import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -573,7 +571,7 @@ class DeveloperEndpoints(private val context: Context) {
val resp = _client.get(body.url!!, body.headers);
context.respondCode(200,
Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.url, resp.code, resp.body?.string())),
Json.encodeToString(PackageHttp.BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string())),
context.query.getOrDefault("CT", "text/plain"));
}
catch(ex: Exception) {

View file

@ -104,6 +104,8 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
super.show();
Logger.i(TAG, "Dialog shown.");
StateCasting.instance.startDiscovering()
(_imageLoader.drawable as Animatable?)?.start();
_devices.clear();
@ -169,6 +171,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
(_imageLoader.drawable as Animatable?)?.stop();
StateCasting.instance.stopDiscovering()
StateCasting.instance.onDeviceAdded.remove(this);
StateCasting.instance.onDeviceChanged.remove(this);
StateCasting.instance.onDeviceRemoved.remove(this);

View file

@ -6,6 +6,8 @@ import com.caoccao.javet.exceptions.JavetException
import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interop.V8Host
import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.interop.options.V8Flags
import com.caoccao.javet.interop.options.V8RuntimeOptions
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueBoolean
import com.caoccao.javet.values.primitive.V8ValueInteger
@ -133,9 +135,10 @@ class V8Plugin {
synchronized(_runtimeLock) {
if (_runtime != null)
return;
//V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
val host = V8Host.getV8Instance();
val options = host.jsRuntimeType.getRuntimeOptions();
_runtime = host.createV8Runtime(options);
if (!host.isIsolateCreated)
throw IllegalStateException("Isolate not created");

View file

@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.packages
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.annotations.V8Property
import com.caoccao.javet.values.V8Value
import com.futo.platformplayer.BuildConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.engine.IV8PluginConfig
@ -49,6 +51,16 @@ class PackageBridge : V8Package {
fun buildFlavor(): String {
return BuildConfig.FLAVOR;
}
@V8Property
fun buildSpecVersion(): Int {
return JSClientConstants.PLUGIN_SPEC_VERSION;
}
@V8Function
fun dispose(value: V8Value) {
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
value.close();
}
@V8Function
fun toast(str: String) {

View file

@ -7,7 +7,11 @@ import com.caoccao.javet.enums.V8ConversionMode
import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArrayBuffer
import com.caoccao.javet.values.reference.V8ValueObject
import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
@ -16,6 +20,9 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException
import kotlin.streams.asSequence
@ -64,33 +71,42 @@ class PackageHttp: V8Package {
}
@V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.request(method, url, headers)
_packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.request(method, url, headers);
_packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.requestWithBody(method, url, body, headers)
_packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.requestWithBody(method, url, body, headers);
_packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
return if(useAuth)
_packageClientAuth.GET(url, headers)
_packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
else
_packageClient.GET(url, headers);
_packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
}
@V8Function
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
return if(useAuth)
_packageClientAuth.POST(url, body, headers)
fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
val client = if(useAuth) _packageClientAuth else _packageClient;
if(body is V8ValueString)
return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is V8ValueTypedArray)
return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ByteArray)
return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else if(body is ArrayList<*>) //Avoid this case, used purely for testing
return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
else
_packageClient.POST(url, body, headers);
throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
}
@V8Function
@ -111,8 +127,19 @@ class PackageHttp: V8Package {
}
}
interface IBridgeHttpResponse {
val url: String;
val code: Int;
val headers: Map<String, List<String>>?;
}
@kotlinx.serialization.Serializable
class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable {
class BridgeHttpStringResponse(
override val url: String,
override val code: Int, val
body: String?,
override val headers: Map<String, List<String>>? = null) : IV8Convertable, IBridgeHttpResponse {
val isOk = code >= 200 && code < 300;
override fun toV8(runtime: V8Runtime): V8Value? {
@ -125,6 +152,37 @@ class PackageHttp: V8Package {
return obj;
}
}
@kotlinx.serialization.Serializable
class BridgeHttpBytesResponse: IV8Convertable, IBridgeHttpResponse {
override val url: String;
override val code: Int;
val body: ByteArray?;
override val headers: Map<String, List<String>>?;
val isOk: Boolean;
constructor(url: String, code: Int, body: ByteArray? = null, headers: Map<String, List<String>>? = null) {
this.url = url;
this.code = code;
this.body = body;
this.headers = headers;
this.isOk = code >= 200 && code < 300;
}
override fun toV8(runtime: V8Runtime): V8Value? {
val obj = runtime.createV8ValueObject();
obj.set("url", url);
obj.set("code", code);
if(body != null) {
val buffer = runtime.createV8ValueArrayBuffer(body.size);
buffer.fromBytes(body);
obj.set("body", body);
}
obj.set("headers", headers);
obj.set("isOk", isOk);
return obj;
}
}
//TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future.
@V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
@ -147,6 +205,12 @@ class PackageHttp: V8Package {
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder
= clientPOST(_package.getDefaultClient(useAuth), url, body, headers);
@V8Function
fun DUMMY(): BatchBuilder {
_reqs.add(Pair(_package.getDefaultClient(false), RequestDescriptor("DUMMY", "", mutableMapOf())));
return BatchBuilder(_package, _reqs);
}
//Client-specific
@V8Function
@ -169,12 +233,14 @@ class PackageHttp: V8Package {
//Finalizer
@V8Function
fun execute(): List<BridgeHttpResponse> {
fun execute(): List<IBridgeHttpResponse?> {
return _reqs.parallelStream().map {
if(it.second.method == "DUMMY")
return@map null;
if(it.second.body != null)
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers);
return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
else
return@map it.first.request(it.second.method, it.second.url, it.second.headers);
return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
}
.asSequence()
.toList();
@ -232,63 +298,108 @@ class PackageHttp: V8Package {
}
@V8Function
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
return@logExceptions catchHttp {
val client = _client;
//logRequest(method, url, headers, null);
val resp = client.requestMethod(method, url, headers);
val responseBody = resp.body?.string();
//logResponse(method, url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
return@catchHttp when(returnType) {
ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
}
}
};
}
@V8Function
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
val client = _client;
//logRequest(method, url, headers, body);
val resp = client.requestMethod(method, url, body, headers);
val responseBody = resp.body?.string();
//logResponse(method, url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
return@catchHttp when(returnType) {
ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
}
}
};
}
@V8Function
fun GET(url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
val client = _client;
//logRequest("GET", url, headers, null);
val resp = client.get(url, headers);
val responseBody = resp.body?.string();
//val responseBody = resp.body?.string();
//logResponse("GET", url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
return@catchHttp when(returnType) {
ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
}
}
};
}
@V8Function
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
val client = _client;
//logRequest("POST", url, headers, body);
val resp = client.post(url, body, headers);
val responseBody = resp.body?.string();
//val responseBody = resp.body?.string();
//logResponse("POST", url, resp.code, resp.headers, responseBody);
return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
return@catchHttp when(returnType) {
ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
}
}
};
}
@V8Function
fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
applyDefaultHeaders(headers);
return logExceptions {
catchHttp {
val client = _client;
//logRequest("POST", url, headers, body);
val resp = client.post(url, body, headers);
//val responseBody = resp.body?.string();
//logResponse("POST", url, resp.code, resp.headers, responseBody);
return@catchHttp when(returnType) {
ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
_client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
}
}
};
}
@ -388,13 +499,13 @@ class PackageHttp: V8Package {
}
}
private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse {
private fun catchHttp(handle: ()->IBridgeHttpResponse): IBridgeHttpResponse {
try{
return handle();
}
//Forward timeouts
catch(ex: SocketTimeoutException) {
return BridgeHttpResponse("", 408, null);
return BridgeHttpStringResponse("", 408, null);
}
}
}
@ -514,20 +625,25 @@ class PackageHttp: V8Package {
val url: String,
val headers: MutableMap<String, String>,
val body: String? = null,
val contentType: String? = null
val contentType: String? = null,
val respType: ReturnType = ReturnType.STRING
)
private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse {
private fun catchHttp(handle: ()->BridgeHttpStringResponse): BridgeHttpStringResponse {
try{
return handle();
}
//Forward timeouts
catch(ex: SocketTimeoutException) {
return BridgeHttpResponse("", 408, null);
return BridgeHttpStringResponse("", 408, null);
}
}
enum class ReturnType(val value: Int) {
STRING(0),
BYTES(1);
}
companion object {
private const val TAG = "PackageHttp";

View file

@ -118,8 +118,13 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
_overlayContainer.let {
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(
context,
R.drawable.ic_visibility_off,
context.getString(R.string.hide),
context.getString(R.string.hide_from_home),
tag = "hide",
call = { StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
@ -128,8 +133,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
SlideUpMenuItem(context,
R.drawable.ic_playlist,
context.getString(R.string.play_feed_as_queue),
context.getString(R.string.play_entire_feed),
tag = "playFeed",
call = {
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };

View file

@ -109,19 +109,31 @@ class PlaylistFragment : MainFragment() {
val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
SlideUpMenuItem(context, R.drawable.ic_list, context.getString(R.string.share_as_text), context.getString(R.string.share_as_a_list_of_video_urls), 1, {
_fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("text/plain")
.setText(reconstruction)
.intent);
}),
SlideUpMenuItem(context, R.drawable.ic_move_up, context.getString(R.string.share_as_import), context.getString(R.string.share_as_a_import_file_for_grayjay), 2, {
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
_fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("application/json")
.setStream(shareUri)
.intent);
})
SlideUpMenuItem(
context,
R.drawable.ic_list,
context.getString(R.string.share_as_text),
context.getString(R.string.share_as_a_list_of_video_urls),
tag = 1,
call = {
_fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("text/plain")
.setText(reconstruction)
.intent);
}),
SlideUpMenuItem(
context,
R.drawable.ic_move_up,
context.getString(R.string.share_as_import),
context.getString(R.string.share_as_a_import_file_for_grayjay),
tag = 2,
call = {
val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
_fragment.startActivity(ShareCompat.IntentBuilder(context)
.setType("application/json")
.setStream(shareUri)
.intent);
})
);
};

View file

@ -157,7 +157,7 @@ class VideoDetailFragment : MainFragment {
_viewDetail?.preventPictureInPicture = true;
}
fun minimizeVideoDetail(){
fun minimizeVideoDetail() {
_viewDetail?.setFullscreen(false);
if(_view != null)
_view!!.transitionToStart();

View file

@ -23,7 +23,6 @@ import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
@ -59,8 +58,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
@ -75,6 +72,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.StateCasting
@ -115,6 +113,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.db.types.DBHistory
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.toHumanTime
@ -695,6 +694,7 @@ class VideoDetailView : ConstraintLayout {
_lastAudioSource = null;
_lastSubtitleSource = null;
video = null;
_player.clear();
cleanupPlaybackTracker();
Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
@ -1674,7 +1674,7 @@ class VideoDetailView : ConstraintLayout {
_didTriggerDatasourceErrroCount++;
UIDialogs.toast("Block detected, attempting bypass");
//return;
fragment.lifecycleScope.launch(Dispatchers.IO) {
val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
val previousVideoSource = _lastVideoSource;
@ -1808,7 +1808,7 @@ class VideoDetailView : ConstraintLayout {
}
}
val doDedup = false;
val doDedup = Settings.instance.playback.simplifySources;
val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
?.distinct()
@ -1851,40 +1851,56 @@ class VideoDetailView : ConstraintLayout {
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
*localVideoSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it,
{ handleSelectVideoTrack(it) });
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it.name,
"${it.width}x${it.height}",
tag = it,
call = { handleSelectVideoTrack(it) });
}.toList().toTypedArray())
else null,
if(localAudioSource?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
*localAudioSource
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
{ handleSelectAudioTrack(it) });
SlideUpMenuItem(this.context,
R.drawable.ic_music,
it.name,
it.bitrate.toHumanBitrate(),
tag = it,
call = { handleSelectAudioTrack(it) });
}.toList().toTypedArray())
else null,
if(localSubtitleSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
*localSubtitleSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
{ handleSelectSubtitleTrack(it) })
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray())
else null,
if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
{ _player.selectVideoTrack(it.height) });
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it.label ?: it.containerMimeType ?: it.bitrate.toString(),
"${it.width}x${it.height}",
tag = it,
call = { _player.selectVideoTrack(it.height) });
}.toList().toTypedArray())
else null,
if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
*liveStreamAudioFormats
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", it,
{ _player.selectAudioTrack(it.bitrate) });
SlideUpMenuItem(this.context,
R.drawable.ic_music,
"${it.label ?: it.containerMimeType} ${it.bitrate}",
"",
tag = it,
call = { _player.selectAudioTrack(it.bitrate) });
}.toList().toTypedArray())
else null,
@ -1892,24 +1908,38 @@ class VideoDetailView : ConstraintLayout {
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
{ handleSelectVideoTrack(it) });
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context,
R.drawable.ic_movie,
it!!.name,
if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
(prefix + it.codec.trim()).trim(),
tag = it,
call = { handleSelectVideoTrack(it) });
}.toList().toTypedArray())
else null,
if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
*bestAudioSources
.map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
{ handleSelectAudioTrack(it) });
val estSize = VideoHelper.estimateSourceSize(it);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
SlideUpMenuItem(this.context,
R.drawable.ic_music,
it.name,
it.bitrate.toHumanBitrate(),
(prefix + it.codec.trim()).trim(),
tag = it,
call = { handleSelectAudioTrack(it) });
}.toList().toTypedArray())
else null,
if(video?.subtitles?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
*video.subtitles
.map {
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
{ handleSelectSubtitleTrack(it) })
SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
call = { handleSelectSubtitleTrack(it) })
}.toList().toTypedArray())
else null);
}

View file

@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
@ -186,5 +187,25 @@ class VideoHelper {
return@Resolver dataSpec;
})).createMediaSource(manifest, MediaItem.Builder().setUri(Uri.parse(audioSource.getAudioUrl())).build())
}
fun estimateSourceSize(source: IVideoSource?): Int {
if(source == null) return 0;
if(source is IVideoUrlSource) {
if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0)
return 0;
return (source.duration / 8).toInt() * source.bitrate!!;
}
else return 0;
}
fun estimateSourceSize(source: IAudioSource?): Int {
if(source == null) return 0;
if(source is IAudioUrlSource) {
if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0)
return 0;
return (source.duration!! / 8).toInt() * source.bitrate;
}
else return 0;
}
}
}

View file

@ -0,0 +1,11 @@
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<String>? = null
)

View file

@ -0,0 +1,93 @@
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<DnsQuestion>,
val answers: List<DnsResourceRecord>,
val authorities: List<DnsResourceRecord>,
val additionals: List<DnsResourceRecord>
) {
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
)
}
}
}

View file

@ -0,0 +1,110 @@
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<DnsQuestion, Int> {
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
)

View file

@ -0,0 +1,514 @@
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<String>)
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<Pair<Byte, ByteArray>>)
data class NSEC3Record(
val hashAlgorithm: Byte,
val flags: Byte,
val iterations: UShort,
val salt: ByteArray,
val nextHashedOwnerName: ByteArray,
val typeBitMaps: List<UShort>
)
data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
data class SPFRecord(val texts: List<String>)
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<OPTRecordOption>)
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<String>()
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<Pair<Byte, ByteArray>>()
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<UShort>()
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<String>()
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<OPTRecordOption>()
while (position < endPosition) {
val optionCode = readUInt16()
val optionLength = readUInt16().toInt()
val optionData = readBytes(optionLength)
options.add(OPTRecordOption(optionCode, optionData))
}
return OPTRecord(options)
}
}

View file

@ -0,0 +1,117 @@
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<DnsResourceRecord, Int> {
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)
}
}

View file

@ -0,0 +1,208 @@
package com.futo.platformplayer.mdns
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
class DnsWriter {
private val data = mutableListOf<Byte>()
private val namePositions = mutableMapOf<String, Int>()
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()) // End of domain name
}
}
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)
}
}
}

View file

@ -0,0 +1,63 @@
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<String, Int> {
var position = startPosition
return readDomainName(position, 0)
}
private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
val domainParts = mutableListOf<String>()
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
}
}

View file

@ -0,0 +1,482 @@
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: DatagramSocket? = null
private var _receiver6: DatagramSocket? = null
private val _senders = mutableListOf<DatagramSocket>()
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<DnsService>) -> Unit)? = null
private val _recordLockObject = ReentrantLock()
private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
private val _services = mutableListOf<BroadcastService>()
init {
_nicMonitor.added = { onNicsAdded(it) }
_nicMonitor.removed = { onNicsRemoved(it) }
_serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
}
fun start() {
if (_started) throw Exception("Already running.")
_started = true
_scope = CoroutineScope(Dispatchers.IO);
Logger.i(TAG, "Starting")
_lockObject.withLock {
val receiver4 = DatagramSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
}
_receiver4 = receiver4
val receiver6 = DatagramSocket(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<String>) {
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<String>) {
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<NetworkInterface>) {
_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 -> {
val sender = MulticastSocket(null).apply {
reuseAddress = true
bind(InetSocketAddress(address, MulticastPort))
joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
}
_senders.add(sender)
}
is Inet6Address -> {
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<NetworkInterface>) {
_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(1024)
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<String>? = 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<DnsQuestion>? = null) {
val writer = DnsWriter()
_recordLockObject.withLock {
val recordsA: List<Pair<DnsResourceRecord, ARecord>>
val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
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
}
}

View file

@ -0,0 +1,66 @@
package com.futo.platformplayer.mdns
import kotlinx.coroutines.*
import java.net.NetworkInterface
class NICMonitor {
private val lockObject = Any()
private val nics = mutableListOf<NetworkInterface>()
private var cts: Job? = null
val current: List<NetworkInterface>
get() = synchronized(nics) { nics.toList() }
var added: ((List<NetworkInterface>) -> Unit)? = null
var removed: ((List<NetworkInterface>) -> 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<NetworkInterface> {
val nics = NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp }
}
}

View file

@ -0,0 +1,68 @@
package com.futo.platformplayer.mdns
import com.futo.platformplayer.logging.Logger
import java.lang.Thread.sleep
class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
private val _names: Array<String>
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<String>? = 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) throw Exception("Already running.")
_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"
}
}

View file

@ -0,0 +1,219 @@
package com.futo.platformplayer.mdns
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<InetAddress> = mutableListOf(),
val pointers: MutableList<String> = mutableListOf(),
val texts: MutableList<String> = mutableListOf()
)
data class CachedDnsAddressRecord(
val expirationTime: Date,
val address: InetAddress
)
data class CachedDnsTxtRecord(
val expirationTime: Date,
val texts: List<String>
)
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<String, MutableList<CachedDnsAddressRecord>>()
private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
private val _currentServices = mutableListOf<DnsService>()
private var _cts: Job? = null
var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
fun start() {
synchronized(_lockObject) {
if (_cts != null) throw Exception("Already started.")
_cts = CoroutineScope(Dispatchers.Default).launch {
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)
delay(5000)
}
}
}
}
fun stop() {
synchronized(_lockObject) {
_cts?.cancel()
_cts = null
}
}
fun add(packet: DnsPacket) {
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(" ${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(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
txtRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
aRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
aaaaRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
synchronized(lockObject) {
// Save to file if necessary
}*/
val currentServices: MutableList<DnsService>
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<DnsQuestion> {
val questions = mutableListOf<DnsQuestion>()
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<DnsService> {
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 <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
val index = indexOfFirst(predicate)
if (index >= 0) {
this[index] = newElement
} else {
add(newElement)
}
}
}

View file

@ -16,7 +16,7 @@ enum class FeedStyle(val value: Int) {
fun fromInt(value: Int): FeedStyle
{
val result = FeedStyle.values().firstOrNull { it.value == value };
val result = FeedStyle.entries.firstOrNull { it.value == value };
if(result == null)
throw UnknownPlatformException(value.toString());
return result;

View file

@ -6,14 +6,17 @@ import android.view.LayoutInflater
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.futo.platformplayer.R
class SlideUpMenuItem : RelativeLayout {
class SlideUpMenuItem : ConstraintLayout {
private lateinit var _root: RelativeLayout;
private lateinit var _root: ConstraintLayout;
private lateinit var _image: ImageView;
private lateinit var _text: TextView;
private lateinit var _subtext: TextView;
private lateinit var _description: TextView;
var selectedOption: Boolean = false;
@ -25,11 +28,27 @@ class SlideUpMenuItem : RelativeLayout {
init();
}
constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any?, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){
constructor(
context: Context,
imageRes: Int = 0,
mainText: String,
subText: String = "",
description: String? = "",
tag: Any?,
call: (() -> Unit)? = null,
invokeParent: Boolean = true
): super(context){
init();
_image.setImageResource(imageRes);
_text.text = mainText;
_subtext.text = subText;
if(description.isNullOrEmpty())
_description.isVisible = false;
else {
_description.text = description;
_description.isVisible = true;
}
this.itemTag = tag;
if (call != null) {
@ -48,6 +67,7 @@ class SlideUpMenuItem : RelativeLayout {
_image = findViewById(R.id.slide_up_menu_item_image);
_text = findViewById(R.id.slide_up_menu_item_text);
_subtext = findViewById(R.id.slide_up_menu_item_subtext);
_description = findViewById(R.id.slide_up_menu_item_description);
setOptionSelected(false);
}

View file

@ -3,9 +3,14 @@ package com.futo.platformplayer.views.video
import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.util.Xml
import android.widget.RelativeLayout
import androidx.annotation.OptIn
import androidx.fragment.app.findFragment
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.C.Encoding
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
@ -17,6 +22,8 @@ import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.dash.manifest.DashManifest
import androidx.media3.exoplayer.dash.manifest.DashManifestParser
import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
@ -42,6 +49,9 @@ import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
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.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
@ -52,12 +62,14 @@ import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
import com.google.gson.Gson
import getHttpDataSourceFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import java.io.File
import kotlin.math.abs
@ -319,18 +331,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
swapSources(videoSource, audioSource,false, play, keepSubtitles);
}
fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
swapSourceInternal(videoSource);
swapSourceInternal(audioSource);
var videoSourceUsed = videoSource;
var audioSourceUsed = audioSource;
if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource);
audioSourceUsed = null;
}
val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume);
val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume);
if(!keepSubtitles)
_lastSubtitleMediaSource = null;
return loadSelectedSources(play, resume);
if(didSetVideo && didSetAudio)
return loadSelectedSources(play, resume);
else
return true;
}
fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
swapSourceInternal(videoSource);
return loadSelectedSources(play, resume);
var videoSourceUsed = videoSource;
if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource)
videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio);
val didSet = swapSourceInternal(videoSourceUsed, play, resume);
if(didSet)
return loadSelectedSources(play, resume);
else
return true;
}
fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean {
swapSourceInternal(audioSource);
if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource)
swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume);
else
swapSourceInternal(audioSource, play, resume);
return loadSelectedSources(play, resume);
}
@ -381,30 +412,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
}
private fun swapSourceInternal(videoSource: IVideoSource?) {
private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean {
_lastGeneratedDash = null;
when(videoSource) {
is LocalVideoSource -> swapVideoSourceLocal(videoSource);
is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource);
is IDashManifestSource -> swapVideoSourceDash(videoSource);
is IHLSManifestSource -> swapVideoSourceHLS(videoSource);
is IVideoUrlSource -> swapVideoSourceUrl(videoSource);
null -> _lastVideoMediaSource = null;
val didSet = when(videoSource) {
is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; }
is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; }
is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;}
is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume);
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
null -> { _lastVideoMediaSource = null; true;}
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
}
lastVideoSource = videoSource;
return didSet;
}
private fun swapSourceInternal(audioSource: IAudioSource?) {
when(audioSource) {
is LocalAudioSource -> swapAudioSourceLocal(audioSource);
is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource);
is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource);
is IAudioUrlWidevineSource -> swapAudioSourceUrlWidevine(audioSource)
is IAudioUrlSource -> swapAudioSourceUrl(audioSource);
null -> _lastAudioMediaSource = null;
private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean {
val didSet = when(audioSource) {
is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; }
is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; }
is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; }
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume);
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
null -> { _lastAudioMediaSource = null; true; }
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
}
lastAudioSource = audioSource;
return didSet;
}
//Video loads
@ -441,7 +476,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class)
private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) {
Logger.i(TAG, "Loading VideoSource [Url]");
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
videoSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@ -451,7 +486,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class)
private fun swapVideoSourceDash(videoSource: IDashManifestSource) {
Logger.i(TAG, "Loading VideoSource [Dash]");
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
videoSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@ -459,9 +494,60 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(videoSource.url))
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean {
Logger.i(TAG, "Loading VideoSource [Dash]");
if(videoSource.hasGenerate) {
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
try {
val generated = videoSource.generate();
if (generated != null) {
withContext(Dispatchers.Main) {
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
videoSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource)
dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor());
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
.createMediaSource(
DashManifestParser().parse(
Uri.parse(videoSource.url),
ByteArrayInputStream(
generated?.toByteArray() ?: ByteArray(0)
)
)
);
if(lastVideoSource == videoSource || (videoSource is JSDashManifestMergingRawSource && videoSource.video == lastVideoSource));
loadSelectedSources(play, resume);
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "DashRaw generator failed", ex);
}
}
return false;
}
else {
val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
videoSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource)
dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor());
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
.createMediaSource(DashManifestParser().parse(Uri.parse(videoSource.url),
ByteArrayInputStream(videoSource.manifest?.toByteArray() ?: ByteArray(0))));
return true;
}
}
@OptIn(UnstableApi::class)
private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) {
Logger.i(TAG, "Loading VideoSource [HLS]");
val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
videoSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@ -503,7 +589,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class)
private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) {
Logger.i(TAG, "Loading AudioSource [Url]");
val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
audioSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@ -513,7 +599,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
@OptIn(UnstableApi::class)
private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) {
Logger.i(TAG, "Loading AudioSource [HLS]");
val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
audioSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@ -521,10 +607,42 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
.createMediaSource(MediaItem.fromUri(audioSource.url));
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean {
Logger.i(TAG, "Loading AudioSource [DashRaw]");
val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource))
audioSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
if(audioSource.hasGenerate) {
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
val generated = audioSource.generate();
if(generated != null) {
withContext(Dispatchers.Main) {
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
.createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url),
ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0))));
loadSelectedSources(play, resume);
}
}
}
return false;
}
else {
_lastVideoMediaSource = DashMediaSource.Factory(dataSource)
.createMediaSource(
DashManifestParser().parse(
Uri.parse(audioSource.url),
ByteArrayInputStream(audioSource.manifest?.toByteArray() ?: ByteArray(0))
)
);
return true;
}
}
@OptIn(UnstableApi::class)
private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) {
Logger.i(TAG, "Loading AudioSource [UrlWidevine]")
val dataSource = if (audioSource is JSSource && audioSource.hasRequestModifier)
val dataSource = if (audioSource is JSSource && audioSource.requiresCustomDatasource)
audioSource.getHttpDataSourceFactory()
else
DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
@ -574,28 +692,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val sourceAudio = _lastAudioMediaSource;
val sourceSubs = _lastSubtitleMediaSource;
val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray()
beforeSourceChanged();
_mediaSource = if(sources.size == 1) {
Logger.i(TAG, "Using single source mode")
(sourceVideo ?: sourceAudio);
}
else if(sources.size > 1) {
Logger.i(TAG, "Using multi source mode ${sources.size}")
MergingMediaSource(true, *sources);
}
else {
Logger.i(TAG, "Using no sources loaded");
stop();
val source = mergeMediaSources(sourceVideo, sourceAudio, sourceSubs);
if(source == null)
return false;
}
_mediaSource = source;
reloadMediaSource(play, resume);
return true;
}
@OptIn(UnstableApi::class)
fun mergeMediaSources(sourceVideo: MediaSource?, sourceAudio: MediaSource?, sourceSubs: MediaSource?): MediaSource? {
val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray()
if(sources.size == 1) {
Logger.i(TAG, "Using single source mode")
return (sourceVideo ?: sourceAudio);
}
else if(sources.size > 1) {
Logger.i(TAG, "Using multi source mode ${sources.size}")
return MergingMediaSource(true, *sources);
}
else {
Logger.i(TAG, "Using no sources loaded");
stop();
return null;
}
}
@OptIn(UnstableApi::class)
private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) {
val player = exoPlayer ?: return
@ -619,6 +746,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
fun clear() {
exoPlayer?.player?.stop();
exoPlayer?.player?.clearMediaItems();
_lastVideoMediaSource = null;
_lastAudioMediaSource = null;
_lastSubtitleMediaSource = null;
_mediaSource = null;
}
fun stop(){
@ -697,8 +828,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
companion object {
val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
val PREFERED_VIDEO_CONTAINERS = arrayOf("video/mp4", "video/webm", "video/3gpp");
val PREFERED_AUDIO_CONTAINERS = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus");
val PREFERED_VIDEO_CONTAINERS_MP4Pref = arrayOf("video/mp4", "video/webm", "video/3gpp");
val PREFERED_VIDEO_CONTAINERS_WEBMPref = arrayOf("video/webm", "video/mp4", "video/3gpp");
val PREFERED_VIDEO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmVideo)
PREFERED_VIDEO_CONTAINERS_WEBMPref else PREFERED_VIDEO_CONTAINERS_MP4Pref }
val PREFERED_AUDIO_CONTAINERS_MP4Pref = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus");
val PREFERED_AUDIO_CONTAINERS_WEBMPref = arrayOf("audio/webm", "audio/opus", "audio/mp3", "audio/mp4");
val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio)
PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref }
val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip");
}

View file

@ -13,6 +13,8 @@ import androidx.annotation.VisibleForTesting;
import com.futo.platformplayer.api.media.models.modifier.IRequest;
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier;
import com.futo.platformplayer.api.media.platforms.js.models.JSRequest;
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor;
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier;
import androidx.media3.common.C;
import androidx.media3.common.PlaybackException;
@ -26,12 +28,16 @@ import androidx.media3.datasource.HttpUtil;
import androidx.media3.datasource.TransferListener;
import com.futo.platformplayer.engine.dev.V8RemoteObject;
import com.futo.platformplayer.engine.exceptions.PluginException;
import com.futo.platformplayer.engine.exceptions.ScriptException;
import com.futo.platformplayer.logging.Logger;
import com.google.common.base.Predicate;
import com.google.common.collect.ForwardingMap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import com.google.common.net.HttpHeaders;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
@ -67,6 +73,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
private boolean allowCrossProtocolRedirects;
private boolean keepPostFor302Redirects;
@Nullable private IRequestModifier requestModifier = null;
@Nullable public JSRequestExecutor requestExecutor = null;
@Nullable public JSRequestExecutor requestExecutor2 = null;
/** Creates an instance. */
public Factory() {
@ -93,6 +102,30 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
this.requestModifier = requestModifier;
return this;
}
/**
* Sets the request executor that will be used.
*
* <p>The default is {@code null}, which results in no request modification
*
* @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier
* @return This factory.
*/
public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
this.requestExecutor = requestExecutor;
return this;
}
/**
* Sets the secondary request executor that will be used.
*
* <p>The default is {@code null}, which results in no request modification
*
* @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier
* @return This factory.
*/
public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
this.requestExecutor2 = requestExecutor;
return this;
}
/**
* Sets the user agent that will be used.
@ -199,7 +232,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
defaultRequestProperties,
contentTypePredicate,
keepPostFor302Redirects,
requestModifier);
requestModifier,
requestExecutor,
requestExecutor2);
if (transferListener != null) {
dataSource.addTransferListener(transferListener);
}
@ -235,6 +270,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
private long bytesToRead;
private long bytesRead;
@Nullable private IRequestModifier requestModifier;
@Nullable public JSRequestExecutor requestExecutor;
@Nullable public JSRequestExecutor requestExecutor2; //Not ideal, but required for now to have 2 executors under 1 datasource
private Uri fallbackUri = null;
private JSHttpDataSource(
@Nullable String userAgent,
@ -244,7 +283,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
@Nullable RequestProperties defaultRequestProperties,
@Nullable Predicate<String> contentTypePredicate,
boolean keepPostFor302Redirects,
@Nullable IRequestModifier requestModifier) {
@Nullable IRequestModifier requestModifier,
@Nullable JSRequestExecutor requestExecutor,
@Nullable JSRequestExecutor requestExecutor2) {
super(/* isNetwork= */ true);
this.userAgent = userAgent;
this.connectTimeoutMillis = connectTimeoutMillis;
@ -255,12 +296,14 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
this.requestProperties = new RequestProperties();
this.keepPostFor302Redirects = keepPostFor302Redirects;
this.requestModifier = requestModifier;
this.requestExecutor = requestExecutor;
this.requestExecutor2 = requestExecutor2;
}
@Override
@Nullable
public Uri getUri() {
return connection == null ? null : Uri.parse(connection.getURL().toString());
return connection == null ? fallbackUri : Uri.parse(connection.getURL().toString());
}
@Override
@ -310,119 +353,147 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
bytesToRead = 0;
transferInitializing(dataSpec);
String responseMessage;
HttpURLConnection connection;
try {
this.connection = makeConnection(dataSpec);
connection = this.connection;
responseCode = connection.getResponseCode();
responseMessage = connection.getResponseMessage();
} catch (IOException e) {
closeConnectionQuietly();
throw HttpDataSourceException.createForIOException(
e, dataSpec, HttpDataSourceException.TYPE_OPEN);
}
//Use executor 2 if it matches the urlPrefix
JSRequestExecutor executor = (requestExecutor2 != null && requestExecutor2.getUrlPrefix() != null && dataSpec.uri.toString().startsWith(requestExecutor2.getUrlPrefix())) ?
requestExecutor2 : requestExecutor;
// Check for a valid response code.
if (responseCode < 200 || responseCode > 299) {
Map<String, List<String>> headers = connection.getHeaderFields();
if (responseCode == 416) {
long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
if (dataSpec.position == documentSize) {
opened = true;
transferStarted(dataSpec);
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
}
}
@Nullable InputStream errorStream = connection.getErrorStream();
byte[] errorResponseBody;
if(executor != null) {
try {
errorResponseBody =
errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY;
Logger.Companion.i(TAG, "Executor for " + dataSpec.uri.toString(), null);
byte[] data = executor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders);
Logger.Companion.i(TAG, "Executor result for " + dataSpec.uri.toString() + " : " + data.length, null);
if (data == null)
throw new HttpDataSourceException(
"No response",
dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
inputStream = new ByteArrayInputStream(data);
fallbackUri = dataSpec.uri;
bytesToRead = data.length;
transferStarted(dataSpec);
return data.length;
}
catch(PluginException ex) {
throw HttpDataSourceException.createForIOException(new IOException("Executor failed: " + ex.getMessage(), ex), dataSpec, HttpDataSourceException.TYPE_OPEN);
}
}
else {
String responseMessage;
HttpURLConnection connection;
try {
this.connection = makeConnection(dataSpec);
connection = this.connection;
responseCode = connection.getResponseCode();
responseMessage = connection.getResponseMessage();
} catch (IOException e) {
errorResponseBody = Util.EMPTY_BYTE_ARRAY;
closeConnectionQuietly();
throw HttpDataSourceException.createForIOException(
e, dataSpec, HttpDataSourceException.TYPE_OPEN);
}
closeConnectionQuietly();
@Nullable
IOException cause = responseCode == 416
? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
: null;
throw new InvalidResponseCodeException(
responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody);
}
// Check for a valid response code.
if (responseCode < 200 || responseCode > 299) {
Map<String, List<String>> headers = connection.getHeaderFields();
if (responseCode == 416) {
long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
if (dataSpec.position == documentSize) {
opened = true;
transferStarted(dataSpec);
return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
}
}
// Check for a valid content type.
String contentType = connection.getContentType();
if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
closeConnectionQuietly();
throw new InvalidContentTypeException(contentType, dataSpec);
}
@Nullable InputStream errorStream = connection.getErrorStream();
byte[] errorResponseBody;
try {
errorResponseBody =
errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY;
} catch (IOException e) {
errorResponseBody = Util.EMPTY_BYTE_ARRAY;
}
closeConnectionQuietly();
@Nullable
IOException cause = responseCode == 416
? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
: null;
// If we requested a range starting from a non-zero position and received a 200 rather than a
// 206, then the server does not support partial requests. We'll need to manually skip to the
// requested position.
long bytesToSkip;
if (requestModifier != null && !requestModifier.getAllowByteSkip()) {
bytesToSkip = 0;
} else {
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
}
throw new InvalidResponseCodeException(
responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody);
}
// Determine the length of the data to be read, after skipping.
boolean isCompressed = isCompressed(connection);
if (!isCompressed) {
if (dataSpec.length != C.LENGTH_UNSET) {
bytesToRead = dataSpec.length;
// Check for a valid content type.
String contentType = connection.getContentType();
if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
closeConnectionQuietly();
throw new InvalidContentTypeException(contentType, dataSpec);
}
// If we requested a range starting from a non-zero position and received a 200 rather than a
// 206, then the server does not support partial requests. We'll need to manually skip to the
// requested position.
long bytesToSkip;
if (requestModifier != null && !requestModifier.getAllowByteSkip()) {
bytesToSkip = 0;
} else {
long contentLength = HttpUtil.getContentLength(
connection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
connection.getHeaderField(HttpHeaders.CONTENT_RANGE)
);
bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
}
} else {
// Gzip is enabled. If the server opts to use gzip then the content length in the response
// will be that of the compressed data, which isn't what we want. Always use the dataSpec
// length in this case.
bytesToRead = dataSpec.length;
}
try {
inputStream = connection.getInputStream();
if (isCompressed) {
inputStream = new GZIPInputStream(inputStream);
// Determine the length of the data to be read, after skipping.
boolean isCompressed = isCompressed(connection);
if (!isCompressed) {
if (dataSpec.length != C.LENGTH_UNSET) {
bytesToRead = dataSpec.length;
} else {
long contentLength = HttpUtil.getContentLength(
connection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
connection.getHeaderField(HttpHeaders.CONTENT_RANGE)
);
bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
}
} else {
// Gzip is enabled. If the server opts to use gzip then the content length in the response
// will be that of the compressed data, which isn't what we want. Always use the dataSpec
// length in this case.
bytesToRead = dataSpec.length;
}
} catch (IOException e) {
closeConnectionQuietly();
throw new HttpDataSourceException(
e,
dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
opened = true;
transferStarted(dataSpec);
try {
skipFully(bytesToSkip, dataSpec);
} catch (IOException e) {
closeConnectionQuietly();
if (e instanceof HttpDataSourceException) {
throw (HttpDataSourceException) e;
try {
inputStream = connection.getInputStream();
if (isCompressed) {
inputStream = new GZIPInputStream(inputStream);
}
} catch (IOException e) {
closeConnectionQuietly();
throw new HttpDataSourceException(
e,
dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
throw new HttpDataSourceException(
e,
dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
return bytesToRead;
opened = true;
transferStarted(dataSpec);
try {
skipFully(bytesToSkip, dataSpec);
} catch (IOException e) {
closeConnectionQuietly();
if (e instanceof HttpDataSourceException) {
throw (HttpDataSourceException) e;
}
throw new HttpDataSourceException(
e,
dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
return bytesToRead;
}
}
@Override

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M320,880Q303,880 291.5,868.5Q280,857 280,840L280,200Q280,183 291.5,171.5Q303,160 320,160L400,160L400,80L560,80L560,160L640,160Q657,160 668.5,171.5Q680,183 680,200L680,840Q680,857 668.5,868.5Q657,880 640,880L320,880Z"/>
</vector>

View file

@ -1,44 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_marginTop="10dp"
android:background="@drawable/background_slide_up_option"
android:id="@+id/slide_up_menu_item_root"
android:gravity="center_vertical">
<ImageView
android:id="@+id/slide_up_menu_item_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginLeft="15dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_share" />
<TextView
android:id="@+id/slide_up_menu_item_text"
android:layout_width="wrap_content"
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_alignParentLeft="true"
android:layout_marginLeft="45dp"
tools:text="Cat videos"
android:textSize="11dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/inter_light"
android:textColor="@color/white" />
<TextView
android:id="@+id/slide_up_menu_item_subtext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_alignParentRight="true"
android:layout_marginRight="15dp"
tools:text="3 videos"
android:textSize="11dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/inter_light"
android:textColor="#ACACAC" />
android:id="@+id/slide_up_menu_item_root"
android:layout_marginTop="10dp"
android:background="@drawable/background_slide_up_option"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/slide_up_menu_item_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginLeft="15dp"
android:scaleType="fitCenter"
android:layout_gravity="center_vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:srcCompat="@drawable/ic_share" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="12dp"
android:paddingBottom="12dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@id/slide_up_menu_item_image"
app:layout_constraintRight_toLeftOf="@id/slide_up_menu_item_subtext"
android:gravity="start"
android:orientation="vertical">
<TextView
android:id="@+id/slide_up_menu_item_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_alignParentLeft="true"
android:layout_marginLeft="20dp"
tools:text="Cat videos"
android:textSize="11dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/inter_light"
android:textColor="@color/white" />
<TextView
android:id="@+id/slide_up_menu_item_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_alignParentLeft="true"
android:layout_marginLeft="20dp"
tools:text="test Description"
android:textSize="11dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/inter_light"
android:textColor="#ACACAC" />
</LinearLayout>
<TextView
android:id="@+id/slide_up_menu_item_subtext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_alignParentRight="true"
android:layout_marginRight="15dp"
tools:text="3 videos"
android:textSize="11dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/inter_light"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:textColor="#ACACAC" />
</androidx.constraintlayout.widget.ConstraintLayout>
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -285,6 +285,8 @@
<string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
<string name="auto_update">Auto Update</string>
<string name="auto_rotate">Auto-Rotate</string>
<string name="simplify_sources">Simplify sources</string>
<string name="simplify_sources_description">Deduplicate sources by resolution so that only more relevant sources are visible.</string>
<string name="auto_rotate_dead_zone">Auto-Rotate Dead Zone</string>
<string name="automatic_backup">Automatic Backup</string>
<string name="background_behavior">Background Behavior</string>
@ -371,6 +373,10 @@
<string name="system_volume_descr">Gesture controls adjust system volume</string>
<string name="live_chat_webview">Live Chat Webview</string>
<string name="full_screen_portrait">Fullscreen portrait</string>
<string name="prefer_webm">Prefer Webm Video Codecs</string>
<string name="prefer_webm_description">If player should prefer Webm codecs (vp9/opus) over mp4 codecs (h264/AAC), may result in worse compatibility.</string>
<string name="prefer_webm_audio">Prefer Webm Audio Codecs</string>
<string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string>
<string name="allow_full_screen_portrait">Allow fullscreen portrait</string>
<string name="background_switch_audio">Switch to Audio in Background</string>
<string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>

@ -1 +1 @@
Subproject commit 4d554e93882d29542cb05d2956f4b2484d7df27f
Subproject commit c700081466038ee4782610feaa05cd4d34d024d8

@ -1 +1 @@
Subproject commit 24f9e4456faf97fbbb866e1df2df9d94628ffcc6
Subproject commit 546d862342b10398d0737f0f2163691b611af8f2

View file

@ -0,0 +1,394 @@
package com.futo.platformplayer
import com.futo.platformplayer.mdns.DnsOpcode
import com.futo.platformplayer.mdns.DnsPacket
import com.futo.platformplayer.mdns.DnsPacketHeader
import com.futo.platformplayer.mdns.DnsQuestion
import com.futo.platformplayer.mdns.DnsReader
import com.futo.platformplayer.mdns.DnsResponseCode
import com.futo.platformplayer.mdns.DnsWriter
import com.futo.platformplayer.mdns.QueryResponse
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
import java.net.InetAddress
import kotlin.test.Test
import kotlin.test.assertContentEquals
class MdnsTests {
@Test
fun `BasicOperation`() {
val expectedData = byteArrayOf(
0x00, 0x01,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03,
0x00, 0x01,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03
)
val writer = DnsWriter()
writer.write(1.toUShort())
writer.write(2.toUInt())
writer.write(3.toULong())
writer.write(1.toShort())
writer.write(2)
writer.write(3L)
assertContentEquals(expectedData, writer.toByteArray())
}
@Test
fun `DnsQuestionFormat`() {
val expectedBytes = ubyteArrayOf(
0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x08u, 0x5fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6cu, 0x61u, 0x79u, 0x04u, 0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u
).asByteArray()
val writer = DnsWriter()
writer.writePacket(
header = DnsPacketHeader(
identifier = 0.toUShort(),
queryResponse = QueryResponse.Query.value.toInt(),
opcode = DnsOpcode.StandardQuery.value.toInt(),
authoritativeAnswer = false,
truncated = false,
recursionDesired = false,
recursionAvailable = false,
answerAuthenticated = false,
nonAuthenticatedData = false,
responseCode = DnsResponseCode.NoError
),
questionCount = 1,
questionWriter = { w, _ ->
w.write(DnsQuestion(
name = "_airplay._tcp.local",
type = QuestionType.PTR.value.toInt(),
clazz = QuestionClass.IN.value.toInt(),
queryUnicast = false
))
}
)
assertContentEquals(expectedBytes, writer.toByteArray())
}
@Test
fun `BeyondTests`() {
val data = byteArrayOf(
0x00, 0x01,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03,
0x00, 0x01,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03
)
val reader = DnsReader(data)
assertEquals(1, reader.readInt16())
assertEquals(2, reader.readInt32())
assertEquals(3L, reader.readInt64())
assertEquals(1.toUShort(), reader.readUInt16())
assertEquals(2.toUInt(), reader.readUInt32())
assertEquals(3.toULong(), reader.readUInt64())
}
@Test
fun `ParseDnsPrinter`() {
val data = ubyteArrayOf(
0x00u, 0x00u,
0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x06u, 0x04u, 0x5fu, 0x69u, 0x70u, 0x70u, 0x04u,
0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u, 0x00u,
0x00u, 0x11u, 0x94u, 0x00u, 0x1eu, 0x1bu, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u,
0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u,
0x73u, 0xc0u, 0x0cu, 0xc0u, 0x27u, 0x00u, 0x10u, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x02u, 0x53u, 0x09u,
0x74u, 0x78u, 0x74u, 0x76u, 0x65u, 0x72u, 0x73u, 0x3du, 0x31u, 0x08u, 0x71u, 0x74u, 0x6fu, 0x74u, 0x61u, 0x6cu,
0x3du, 0x31u, 0x42u, 0x70u, 0x64u, 0x6cu, 0x3du, 0x61u, 0x70u, 0x70u, 0x6cu, 0x69u, 0x63u, 0x61u, 0x74u, 0x69u,
0x6fu, 0x6eu, 0x2fu, 0x6fu, 0x63u, 0x74u, 0x65u, 0x74u, 0x2du, 0x73u, 0x74u, 0x72u, 0x65u, 0x61u, 0x6du, 0x2cu,
0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x75u, 0x72u, 0x66u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu,
0x6au, 0x70u, 0x65u, 0x67u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x70u, 0x77u, 0x67u, 0x2du, 0x72u,
0x61u, 0x73u, 0x74u, 0x65u, 0x72u, 0x0cu, 0x72u, 0x70u, 0x3du, 0x69u, 0x70u, 0x70u, 0x2fu, 0x70u, 0x72u, 0x69u,
0x6eu, 0x74u, 0x05u, 0x6eu, 0x6fu, 0x74u, 0x65u, 0x3du, 0x1eu, 0x74u, 0x79u, 0x3du, 0x42u, 0x72u, 0x6fu, 0x74u,
0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u,
0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x25u, 0x70u, 0x72u, 0x6fu, 0x64u, 0x75u, 0x63u, 0x74u, 0x3du,
0x28u, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u,
0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x29u, 0x3cu, 0x61u, 0x64u,
0x6du, 0x69u, 0x6eu, 0x75u, 0x72u, 0x6cu, 0x3du, 0x68u, 0x74u, 0x74u, 0x70u, 0x3au, 0x2fu, 0x2fu, 0x42u, 0x52u,
0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u, 0x37u, 0x30u, 0x2eu, 0x6cu, 0x6fu,
0x63u, 0x61u, 0x6cu, 0x2eu, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x61u, 0x69u, 0x72u,
0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x2eu, 0x68u, 0x74u, 0x6du, 0x6cu, 0x0bu, 0x70u, 0x72u, 0x69u, 0x6fu, 0x72u,
0x69u, 0x74u, 0x79u, 0x3du, 0x32u, 0x35u, 0x0fu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x46u, 0x47u, 0x3du, 0x42u,
0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x1bu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x44u, 0x4cu, 0x3du, 0x44u,
0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u,
0x65u, 0x73u, 0x19u, 0x75u, 0x73u, 0x62u, 0x5fu, 0x43u, 0x4du, 0x44u, 0x3du, 0x50u, 0x4au, 0x4cu, 0x2cu, 0x50u,
0x43u, 0x4cu, 0x2cu, 0x50u, 0x43u, 0x4cu, 0x58u, 0x4cu, 0x2cu, 0x55u, 0x52u, 0x46u, 0x07u, 0x43u, 0x6fu, 0x6cu,
0x6fu, 0x72u, 0x3du, 0x54u, 0x08u, 0x43u, 0x6fu, 0x70u, 0x69u, 0x65u, 0x73u, 0x3du, 0x54u, 0x08u, 0x44u, 0x75u,
0x70u, 0x6cu, 0x65u, 0x78u, 0x3du, 0x54u, 0x05u, 0x46u, 0x61u, 0x78u, 0x3du, 0x46u, 0x06u, 0x53u, 0x63u, 0x61u,
0x6eu, 0x3du, 0x54u, 0x0du, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x43u, 0x75u, 0x73u, 0x74u, 0x6fu, 0x6du, 0x3du,
0x54u, 0x08u, 0x42u, 0x69u, 0x6eu, 0x61u, 0x72u, 0x79u, 0x3du, 0x54u, 0x0du, 0x54u, 0x72u, 0x61u, 0x6eu, 0x73u,
0x70u, 0x61u, 0x72u, 0x65u, 0x6eu, 0x74u, 0x3du, 0x54u, 0x06u, 0x54u, 0x42u, 0x43u, 0x50u, 0x3du, 0x46u, 0x3eu,
0x55u, 0x52u, 0x46u, 0x3du, 0x53u, 0x52u, 0x47u, 0x42u, 0x32u, 0x34u, 0x2cu, 0x57u, 0x38u, 0x2cu, 0x43u, 0x50u,
0x31u, 0x2cu, 0x49u, 0x53u, 0x34u, 0x2du, 0x31u, 0x2cu, 0x4du, 0x54u, 0x31u, 0x2du, 0x33u, 0x2du, 0x34u, 0x2du,
0x35u, 0x2du, 0x38u, 0x2du, 0x31u, 0x31u, 0x2cu, 0x4fu, 0x42u, 0x31u, 0x30u, 0x2cu, 0x50u, 0x51u, 0x34u, 0x2cu,
0x52u, 0x53u, 0x36u, 0x30u, 0x30u, 0x2cu, 0x56u, 0x31u, 0x2eu, 0x34u, 0x2cu, 0x44u, 0x4du, 0x31u, 0x25u, 0x6bu,
0x69u, 0x6eu, 0x64u, 0x3du, 0x64u, 0x6fu, 0x63u, 0x75u, 0x6du, 0x65u, 0x6eu, 0x74u, 0x2cu, 0x65u, 0x6eu, 0x76u,
0x65u, 0x6cu, 0x6fu, 0x70u, 0x65u, 0x2cu, 0x6cu, 0x61u, 0x62u, 0x65u, 0x6cu, 0x2cu, 0x70u, 0x6fu, 0x73u, 0x74u,
0x63u, 0x61u, 0x72u, 0x64u, 0x11u, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x4du, 0x61u, 0x78u, 0x3du, 0x6cu, 0x65u,
0x67u, 0x61u, 0x6cu, 0x2du, 0x41u, 0x34u, 0x29u, 0x55u, 0x55u, 0x49u, 0x44u, 0x3du, 0x65u, 0x33u, 0x32u, 0x34u,
0x38u, 0x30u, 0x30u, 0x30u, 0x2du, 0x38u, 0x30u, 0x63u, 0x65u, 0x2du, 0x31u, 0x31u, 0x64u, 0x62u, 0x2du, 0x38u,
0x30u, 0x30u, 0x30u, 0x2du, 0x33u, 0x63u, 0x32u, 0x61u, 0x66u, 0x34u, 0x61u, 0x61u, 0x63u, 0x30u, 0x61u, 0x34u,
0x0cu, 0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x5fu, 0x77u, 0x66u, 0x64u, 0x73u, 0x3du, 0x54u, 0x14u, 0x6du, 0x6fu,
0x70u, 0x72u, 0x69u, 0x61u, 0x2du, 0x63u, 0x65u, 0x72u, 0x74u, 0x69u, 0x66u, 0x69u, 0x65u, 0x64u, 0x3du, 0x31u,
0x2eu, 0x33u, 0x0fu, 0x42u, 0x52u, 0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u,
0x37u, 0x30u, 0xc0u, 0x16u, 0x00u, 0x01u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x04u, 0xc0u, 0xa8u,
0x01u, 0xc5u, 0xc2u, 0xa4u, 0x00u, 0x1cu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x10u, 0xfeu, 0x80u,
0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x12u, 0x5bu, 0xadu, 0xffu, 0xfeu, 0x4au, 0x15u, 0x70u, 0xc0u, 0x27u,
0x00u, 0x21u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x08u, 0x00u, 0x00u, 0x00u, 0x00u, 0x02u, 0x77u,
0xc2u, 0xa4u, 0xc0u, 0x27u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xc0u, 0x27u,
0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u, 0xc2u, 0xa4u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u,
0x78u, 0x00u, 0x08u, 0xc2u, 0xa4u, 0x00u, 0x04u, 0x40u, 0x00u, 0x00u, 0x08u
)
val packet = DnsPacket.parse(data.asByteArray())
assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse)
assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode)
assertTrue(packet.header.authoritativeAnswer)
assertEquals(false, packet.header.truncated)
assertEquals(false, packet.header.recursionDesired)
assertEquals(false, packet.header.recursionAvailable)
assertEquals(false, packet.header.answerAuthenticated)
assertEquals(false, packet.header.nonAuthenticatedData)
assertEquals(DnsResponseCode.NoError, packet.header.responseCode)
assertEquals(0, packet.questions.size)
assertEquals(1, packet.answers.size)
assertEquals(0, packet.authorities.size)
assertEquals(6, packet.additionals.size)
val firstAnswer = packet.answers[0]
assertEquals("_ipp._tcp.local", firstAnswer.name)
assertEquals(ResourceRecordType.PTR.value.toInt(), firstAnswer.type)
assertEquals(ResourceRecordClass.IN.value.toInt(), firstAnswer.clazz)
assertEquals(false, firstAnswer.cacheFlush)
assertEquals(4500u, firstAnswer.timeToLive)
assertEquals(30, firstAnswer.dataLength)
assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAnswer.getDataReader().readPTRRecord().domainName)
val firstAdditional = packet.additionals[0]
assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAdditional.name)
assertEquals(ResourceRecordType.TXT.value.toInt(), firstAdditional.type)
assertEquals(ResourceRecordClass.IN.value.toInt(), firstAdditional.clazz)
assertEquals(true, firstAdditional.cacheFlush)
assertEquals(4500u, firstAdditional.timeToLive)
assertEquals(595, firstAdditional.dataLength)
val txtRecord = firstAdditional.getDataReader().readTXTRecord()
assertContentEquals(arrayOf(
"txtvers=1",
"qtotal=1",
"pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster",
"rp=ipp/print",
"note=",
"ty=Brother DCP-L3550CDW series",
"product=(Brother DCP-L3550CDW series)",
"adminurl=http://BRW105BAD4A1570.local./net/net/airprint.html",
"priority=25",
"usb_MFG=Brother",
"usb_MDL=DCP-L3550CDW series",
"usb_CMD=PJL,PCL,PCLXL,URF",
"Color=T",
"Copies=T",
"Duplex=T",
"Fax=F",
"Scan=T",
"PaperCustom=T",
"Binary=T",
"Transparent=T",
"TBCP=F",
"URF=SRGB24,W8,CP1,IS4-1,MT1-3-4-5-8-11,OB10,PQ4,RS600,V1.4,DM1",
"kind=document,envelope,label,postcard",
"PaperMax=legal-A4",
"UUID=e3248000-80ce-11db-8000-3c2af4aac0a4",
"print_wfds=T",
"mopria-certified=1.3"
), txtRecord.texts.toTypedArray())
val aRecord = packet.additionals[1].getDataReader().readARecord()
assertEquals(InetAddress.getByName("192.168.1.197"), aRecord.address)
val aaaaRecord = packet.additionals[2].getDataReader().readAAAARecord()
assertEquals(InetAddress.getByName("fe80::125b:adff:fe4a:1570"), aaaaRecord.address)
val srvRecord = packet.additionals[3].getDataReader().readSRVRecord()
assertEquals("BRW105BAD4A1570.local", srvRecord.target)
assertEquals(0, srvRecord.weight.toInt())
assertEquals(0, srvRecord.priority.toInt())
assertEquals(631, srvRecord.port.toInt())
val nSECRecord = packet.additionals[4].getDataReader().readNSECRecord()
assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", nSECRecord.ownerName)
assertEquals(1, nSECRecord.typeBitMaps.size)
assertEquals(0, nSECRecord.typeBitMaps[0].first)
assertContentEquals(byteArrayOf(0, 0, 128.toByte(), 0, 64), nSECRecord.typeBitMaps[0].second)
}
@Test
fun `ParseSamsungTV`() {
val data = loadByteArray("samsung-airplay.hex")
val packet = DnsPacket.parse(data)
assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse)
assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode)
assertTrue(packet.header.authoritativeAnswer)
assertEquals(false, packet.header.truncated)
assertEquals(false, packet.header.recursionDesired)
assertEquals(false, packet.header.recursionAvailable)
assertEquals(false, packet.header.answerAuthenticated)
assertEquals(false, packet.header.nonAuthenticatedData)
assertEquals(DnsResponseCode.NoError, packet.header.responseCode)
assertEquals(0, packet.questions.size)
assertEquals(6, packet.answers.size)
assertEquals(0, packet.authorities.size)
assertEquals(4, packet.additionals.size)
assertEquals("9.1.168.192.in-addr.arpa", packet.answers[0].name)
assertEquals(ResourceRecordType.PTR.value.toInt(), packet.answers[0].type)
assertEquals(ResourceRecordClass.IN.value.toInt(), packet.answers[0].clazz)
assertTrue(packet.answers[0].cacheFlush)
assertEquals(120u, packet.answers[0].timeToLive)
assertEquals(15, packet.answers[0].dataLength)
assertEquals("Samsung.local", packet.answers[0].getDataReader().readPTRRecord().domainName)
val txtRecord = packet.answers[1].getDataReader().readTXTRecord()
assertContentEquals(arrayOf(
"acl=0",
"deviceid=D4:9D:C0:2F:52:16",
"features=0x7F8AD0,0x38BCB46",
"rsf=0x3",
"fv=p20.0.1",
"flags=0x244",
"model=URU8000",
"manufacturer=Samsung",
"serialNumber=0EQC3HDM900064X",
"protovers=1.1",
"srcvers=377.17.24.6",
"pi=ED:0C:A5:ED:10:08",
"psi=00000000-0000-0000-0000-ED0CA5ED1008",
"gid=00000000-0000-0000-0000-ED0CA5ED1008",
"gcgl=0",
"pk=d25488cbff1334756165cd7229a235475ef591f2595f38ed251d46b8a4d2345d"
), txtRecord.texts.toTypedArray())
val srvRecord = packet.answers[4].getDataReader().readSRVRecord()
assertEquals(33482, srvRecord.port.toInt())
assertEquals(0, srvRecord.priority.toInt())
assertEquals(0, srvRecord.weight.toInt())
assertEquals("Samsung.local", srvRecord.target)
val aRecord = packet.answers[5].getDataReader().readARecord()
assertEquals(InetAddress.getByName("192.168.1.9"), aRecord.address)
val nSECRecord = packet.additionals[0].getDataReader().readNSECRecord()
assertEquals("9.1.168.192.in-addr.arpa", nSECRecord.ownerName)
assertEquals(1, nSECRecord.typeBitMaps.size)
assertEquals(0, nSECRecord.typeBitMaps[0].first)
assertContentEquals(byteArrayOf(0, 8), nSECRecord.typeBitMaps[0].second)
val optRecord = packet.additionals[3].getDataReader().readOPTRecord()
assertEquals(1, optRecord.options.size)
assertEquals(65001, optRecord.options[0].code.toInt())
assertEquals(5, optRecord.options[0].data.size)
assertContentEquals(byteArrayOf(0, 0, 116, 206.toByte(), 97), optRecord.options[0].data)
}
@Test
fun `UnicodeTest`() {
val data = ubyteArrayOf(
0x00u, 0x00u, 0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x01u, 0x15u, 0x41u, 0x69u, 0x64u,
0x61u, 0x6Eu, 0xE2u, 0x80u, 0x99u, 0x73u, 0x20u, 0x4Du, 0x61u, 0x63u, 0x42u, 0x6Fu, 0x6Fu, 0x6Bu, 0x20u, 0x50u,
0x72u, 0x6Fu, 0x0Fu, 0x5Fu, 0x63u, 0x6Fu, 0x6Du, 0x70u, 0x61u, 0x6Eu, 0x69u, 0x6Fu, 0x6Eu, 0x2Du, 0x6Cu, 0x69u,
0x6Eu, 0x6Bu, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu, 0x63u, 0x61u, 0x6Cu, 0x00u, 0x00u, 0x10u,
0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x5Bu, 0x16u, 0x72u, 0x70u, 0x42u, 0x41u, 0x3Du, 0x30u, 0x33u,
0x3Au, 0x43u, 0x32u, 0x3Au, 0x33u, 0x33u, 0x3Au, 0x38u, 0x36u, 0x3Au, 0x33u, 0x43u, 0x3Au, 0x45u, 0x45u, 0x11u,
0x72u, 0x70u, 0x41u, 0x44u, 0x3Du, 0x66u, 0x33u, 0x33u, 0x37u, 0x61u, 0x38u, 0x61u, 0x32u, 0x38u, 0x64u, 0x35u,
0x31u, 0x0Cu, 0x72u, 0x70u, 0x46u, 0x6Cu, 0x3Du, 0x30u, 0x78u, 0x32u, 0x30u, 0x30u, 0x30u, 0x30u, 0x11u, 0x72u,
0x70u, 0x48u, 0x4Eu, 0x3Du, 0x31u, 0x66u, 0x66u, 0x64u, 0x64u, 0x64u, 0x66u, 0x33u, 0x63u, 0x39u, 0x65u, 0x33u,
0x07u, 0x72u, 0x70u, 0x4Du, 0x61u, 0x63u, 0x3Du, 0x30u, 0x0Au, 0x72u, 0x70u, 0x56u, 0x72u, 0x3Du, 0x33u, 0x36u,
0x30u, 0x2Eu, 0x34u, 0xC0u, 0x0Cu, 0x00u, 0x2Fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xC0u,
0x0Cu, 0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u
)
val packet = DnsPacket.parse(data.asByteArray())
assertEquals("Aidans MacBook Pro._companion-link._tcp.local", packet.additionals[0].name)
}
/*@Test
fun `TestReadDomainName`() {
val data = ubyteArrayOf(
0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x0Bu, 0x5Fu, 0x67u, 0x6Fu,
0x6Fu, 0x67u, 0x6Cu, 0x65u, 0x63u, 0x61u, 0x73u, 0x74u, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu,
0x63u, 0x61u, 0x6Cu, 0xC0u, 0x0Cu, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x08u, 0x5Fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6Cu,
0x61u, 0x79u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x09u, 0x5Fu, 0x66u, 0x61u, 0x73u, 0x74u, 0x63u, 0x61u,
0x73u, 0x74u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x06u, 0x5Fu, 0x66u, 0x63u, 0x61u, 0x73u, 0x74u, 0xC0u,
0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u
)
val packet = DnsPacket.parse(data.asByteArray())
println()
}*/
private fun loadByteArray(name: String): ByteArray {
javaClass.classLoader.getResourceAsStream(name).use { input ->
requireNotNull(input) { "File not found: $name" }
val result = ByteArrayOutputStream()
val buffer = ByteArray(4096)
var length: Int
while ((input.read(buffer).also { length = it }) > 0) {
result.write(buffer, 0, length)
}
return result.toByteArray()
}
}
@Test
fun `ReserializeDnsPrinter`() {
val data = loadByteArray("samsung-airplay.hex")
val packet = DnsPacket.parse(data)
val writer = DnsWriter()
writer.writePacket(
header = packet.header,
questionCount = packet.questions.size,
questionWriter = { _, _ -> },
answerCount = packet.answers.size,
answerWriter = { w, i ->
w.write(packet.answers[i]) { v ->
val reader = packet.answers[i].getDataReader()
when (i) {
0, 2, 3 -> v.write(reader.readPTRRecord())
1 -> v.write(reader.readTXTRecord())
4 -> v.write(reader.readSRVRecord())
5 -> v.write(reader.readARecord())
}
}
},
authorityCount = packet.authorities.size,
authorityWriter = { _, _ -> },
additionalsCount = packet.additionals.size,
additionalWriter = { w, i ->
w.write(packet.additionals[i]) { v ->
val reader = packet.additionals[i].getDataReader()
when (i) {
0, 1, 2 -> v.write(reader.readNSECRecord())
3 -> v.write(reader.readOPTRecord())
}
}
}
)
assertContentEquals(data, writer.toByteArray())
}
}

View file

@ -1,6 +1,8 @@
package com.futo.platformplayer
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Test
@ -52,4 +54,21 @@ class UtilityTests {
assertEquals("\ud83d\udc80\ud83d\udd14", "\\ud83d\\udc80\\ud83d\\udd14".decodeUnicode())
assertEquals("String with a slash (/) in it", "String with a slash (/) in it".decodeUnicode())
}
@Test
fun testMatchDomain() {
//TLD
assertTrue("test.abc.com".matchesDomain(".abc.com"))
assertTrue("abc.com".matchesDomain("abc.com"))
assertFalse("test.abc.com".matchesDomain("abc.com"))
assertThrows(IllegalStateException::class.java, { "test.uk".matchesDomain(".uk") });
//SLD
assertTrue("abc.co.uk".matchesDomain("abc.co.uk"))
assertTrue("test.abc.co.uk".matchesDomain("test.abc.co.uk"))
assertTrue("test.abc.co.uk".matchesDomain(".abc.co.uk"))
assertThrows(IllegalStateException::class.java, { "test.abc.co.uk".matchesDomain(".co.uk") });
}
}

Binary file not shown.

@ -1 +1 @@
Subproject commit 4d554e93882d29542cb05d2956f4b2484d7df27f
Subproject commit c700081466038ee4782610feaa05cd4d34d024d8

@ -1 +1 @@
Subproject commit 24f9e4456faf97fbbb866e1df2df9d94628ffcc6
Subproject commit 91639d939738d9cc81ebdb1cd047ead9edd3a5e8