Compare commits

...

291 Commits

Author SHA1 Message Date
Kelvin 5499706a9b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 16:14:01 +01:00
Kelvin ba57e32920 Minor queue styling, refs 2023-12-21 16:13:53 +01:00
Koen df96c5b51c Fixed live video previews in preview layout. Fixed time bar spacing at bottom. 2023-12-21 16:07:03 +01:00
Koen 75f81d20db Fixed buttons in subscription groups and made select button click only work when there are things selected. 2023-12-21 13:10:38 +01:00
Koen 3fc92e4065 Fixed rounding of subscription groups. 2023-12-21 12:56:35 +01:00
Koen 8ffd5f411f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 11:21:34 +01:00
Koen 918161a299 Fixed margins for subscription groups and menu bar icons now show filled variant if existing. 2023-12-21 11:20:53 +01:00
Kelvin 9f50f72eaa Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-21 11:12:45 +01:00
Kelvin 2f66f124aa Toast on cancel creation, default subgroup creator image, fix subgroup cache load 2023-12-21 11:12:34 +01:00
Koen 9a11717cf4 Fixed Rumble channel contents having empty author. Changed TutorialVideo so author is not clickable. 2023-12-21 10:20:52 +01:00
Kelvin 0d80424799 Optimized selection creator overlay, global subscription progress listener 2023-12-20 21:54:39 +01:00
Kelvin ed9a65b2f0 Filter cache results by enabled clients 2023-12-20 18:35:13 +01:00
Kelvin 8a53297be2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 18:08:52 +01:00
Kelvin 20862a27c8 Search creator overlay, Fix crash Add topbar 2023-12-20 18:08:40 +01:00
Koen 95785e6c78 Added something more similar to Jdenticons. 2023-12-20 17:35:57 +01:00
Koen e88c649578 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 17:09:19 +01:00
Koen 09f91e64fb Fixed Kick subscription imports. 2023-12-20 17:04:10 +01:00
Koen b8923e59a1 Fixed bottom bar new tabs not showing up for people who changed tabs. 2023-12-20 17:03:48 +01:00
Kelvin e722c0ce9a Explore subscription groups button 2023-12-20 17:02:40 +01:00
Kelvin 56248bf4b0 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 12:36:12 +01:00
Kelvin 5af4787c45 Fix nested overlays in more buttons 2023-12-20 12:35:40 +01:00
Koen 0990247322 Fixed Rumble channel content fetching, channel image and show comments when sign in is not required. 2023-12-20 12:06:21 +01:00
Koen 0154525578 Revert "Fixed download button overlay not working in more button."
This reverts commit 1dc6eee242.
2023-12-20 12:02:35 +01:00
Koen 1dc6eee242 Fixed download button overlay not working in more button. 2023-12-20 11:51:28 +01:00
Koen c63a63cb33 Fixed Gesture control distances for portrait full screen. 2023-12-20 11:06:18 +01:00
Koen c1967556ac Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-20 10:56:28 +01:00
Koen 309a57f5a1 Added icon based on the system key when a polycentric user has not set a profile picture. Made managing Polycentric profile most robust. Fixed an issue where latest events were not always being shown in relation to Polycentric profiles. Added copyable (on long press) system key in Polycentric profile activity. Added tutorial videos tab and flow. 2023-12-20 10:56:16 +01:00
Kelvin ee0bc96e53 Support for initial skip chapters 2023-12-20 00:28:21 +01:00
Kelvin a4422fdd56 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-19 23:08:48 +01:00
Kelvin b7c4047f1d Progress bars subscription groups, Various notification permission requests, improved notification experience, Settings activity open to specific settings, refs 2023-12-19 23:08:38 +01:00
Koen 65174ffc97 Show connected controls as disabled when still connecting. 2023-12-19 11:46:40 +01:00
Koen eac3e37af5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-19 11:38:32 +01:00
Koen 0d5ad90ff9 Fixed duration format. Added tutorial fragment. Added dialog that asks you if you want to see the tutorials on the first app boot. Made casting when clicking start now open connected dialog. Fixed crashes related to sliders in casting connected control. Fixed history tab title. Removed old FCast encryption. 2023-12-19 11:38:22 +01:00
Kelvin f42b14e95a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-18 23:37:27 +01:00
Kelvin b8acd0b5b2 Subscriptions fetching support for subgroups 2023-12-18 23:37:10 +01:00
Koen ef72561768 Added support for chapter skip in casting. 2023-12-18 16:28:35 +01:00
Koen d63627bd61 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-18 15:48:17 +01:00
Koen 422cceb225 Added support for FCast encrypted connection upgrade and added support for ensuring threads are restarted onResume for FCast and ChromeCAst. 2023-12-18 15:48:05 +01:00
Kelvin 76f5962232 Temp remove niconico 2023-12-15 22:47:04 +01:00
Kelvin 30df22d225 Fixing after merge 2023-12-15 22:44:24 +01:00
Kelvin cd4295be59 Merge branch 'video-cookie-support' into 'master'
Video Cookie Support & Static Request Modifiers

See merge request videostreaming/grayjay!11
2023-12-15 21:33:06 +00:00
Kelvin 7d366110b1 Merge branch 'wip-subgroups' into 'master'
Subscription Groups

See merge request videostreaming/grayjay!12
2023-12-15 21:28:03 +00:00
Kelvin 35c5045b3f Named presets 2023-12-15 20:10:28 +01:00
Kelvin 4930ea8183 Working subscription groups and image pickers 2023-12-15 19:38:33 +01:00
Kelvin 02292fed04 Progress 2023-12-13 23:28:34 +01:00
Koen bf6e61ed90 Fixed playback controls. 2023-12-13 17:14:40 +01:00
Koen 2ac8e0e621 Added casting controls to connected dialog. 2023-12-13 15:02:49 +01:00
Koen 0432f06eb3 Merge branch 'media3-migration' into 'master'
Media3 migration.

See merge request videostreaming/grayjay!10
2023-12-13 12:57:43 +00:00
Koen 7bfab8409f Made FCastCastingDevice more able to reconnect. 2023-12-13 13:47:38 +01:00
Koen 52d833d726 Added next and previous buttons to cast controls. 2023-12-13 13:40:11 +01:00
Koen 14d579eb1b Added support for playback rate changing when casting. 2023-12-13 13:28:51 +01:00
Koen d3ab8ecf3a Added support for automatically resuming casting device if stopped. 2023-12-13 12:48:39 +01:00
Koen 627b8c2b5d Implemented support for automatically connecting using QR code. 2023-12-13 12:36:16 +01:00
Koen 7f1cb22c12 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into media3-migration 2023-12-13 12:22:45 +01:00
Koen 5551bd31fe Added setting to allow full-screen portrait. 2023-12-13 12:18:54 +01:00
Koen 189d855c3f Automatic playback continuation should only happen when it was not paused or stopped. 2023-12-13 11:27:21 +01:00
Koen 0ab52e8f4d Fixed notification implementation. 2023-12-13 10:34:14 +01:00
Kelvin 27eb5aa6e1 WIP views 2023-12-12 22:20:38 +01:00
Kelvin 49b5b16641 Abstract RequestModifier, AdHocRequestModifier, RequestModifierOptions 2023-12-12 14:44:07 +01:00
Kelvin 73dd52af28 Working video cookies 2023-12-11 23:35:59 +01:00
Koen 3b8d256bad Media3 migration. 2023-12-11 17:54:18 +01:00
Kelvin 5d7dc1fdcb Language fixes, now uses standard language tags 2023-12-11 16:52:31 +01:00
Kelvin f31b6c50e9 Solving some warning 2023-12-11 13:26:13 +01:00
Koen fa12f8277c Reduced amount of warnings. 2023-12-11 11:51:43 +01:00
Koen 150a7d5006 Added proper permissions for download and export service (A14). 2023-12-10 09:29:33 +01:00
Koen a0a73a8e5c Fixes Android SDK. 2023-12-10 00:34:34 +01:00
Kelvin 4723a0b29a Fix up next view 2023-12-09 19:03:47 +01:00
Kelvin adbe0357ba Refs 2023-12-09 18:12:36 +01:00
Kelvin b0a35bcf3f No notification on known item, Polycentric logging, refs 2023-12-09 18:11:45 +01:00
Kelvin 0e7482321c Import platform redirect and disabled buttons, minor ui fixes 2023-12-09 16:31:34 +01:00
Koen e50d195b85 Fixed artwork not displaying properly. Loop button now hidden if you have a queue. Videos on queue editor now properly updates the amount of videos when a video is deleted. 2023-12-08 21:42:27 +01:00
Koen 33780f1046 Cleanup. 2023-12-08 14:53:57 +01:00
Koen 8b20b4909f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-08 14:43:33 +01:00
Koen 71a3828fe4 Migration to new deps. 2023-12-08 14:43:24 +01:00
Kelvin d713f2bd55 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-08 13:24:06 +01:00
Kelvin 069a615193 Polycentric persistent cache fixes for subscriptions 2023-12-08 13:23:58 +01:00
Koen f7d2cb4055 Updated Odysee. 2023-12-08 12:03:24 +01:00
Koen f109d82537 Fixed clickable area of likes/dislikes. 2023-12-08 12:02:17 +01:00
Kelvin ab49d4749b Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-08 11:45:27 +01:00
Kelvin 507eed4f53 fix error message 2023-12-08 11:45:24 +01:00
Kelvin 23ca4addf9 Prevent dup queue items, handle toast more centrally 2023-12-08 11:40:06 +01:00
Koen 331ed09775 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-08 11:27:51 +01:00
Koen 85303b54bc Fixed bug in audio focus loss timers using the wrong time. 2023-12-08 11:27:42 +01:00
Kelvin f224cd1ca5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-08 11:26:07 +01:00
Kelvin d433d6e774 Elaboration on prev/next queue behavior 2023-12-08 11:25:35 +01:00
Koen 90de54ac5c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-08 11:24:25 +01:00
Koen 5ff8f1ba6d Connectivity loss fixes. 2023-12-08 11:24:17 +01:00
Kelvin bc00b12b8c Hide prev/next for single item queue, Fullscreen next/prev button show/hide, Bypass loop for next controls 2023-12-08 11:17:36 +01:00
Kelvin 1c0cfa89a3 Fixing Queue and hiding next/prev buttons 2023-12-08 10:44:20 +01:00
Kelvin efa1361fbe Remove (0/0) import, captcha delete update buttons 2023-12-07 22:04:24 +01:00
Kelvin 73918a8d76 refs 2023-12-07 20:18:39 +01:00
Kelvin a3c8bbb21f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-07 20:17:49 +01:00
Kelvin 53525cb365 Improved import flow, Empty pager view support, No subscriptions result view, LoginRequiredException support 2023-12-07 20:17:35 +01:00
Koen e4d39cbec4 Added stop all gestures flow. 2023-12-07 17:16:25 +01:00
Koen a15e4beafb Updated youtube ref for stable. 2023-12-07 17:02:19 +01:00
Koen d47298102e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-07 17:00:57 +01:00
Koen 280feea06e Default language fixes. 2023-12-07 17:00:47 +01:00
Kelvin f649d62e38 Logging and refs 2023-12-07 16:04:22 +01:00
Kelvin 0ae05e7cd4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-06 19:46:14 +01:00
Kelvin b284176072 Loop support, Improve add to queue behavior, home retry, fix history search pager, defaults progressbar 2023-12-06 19:46:09 +01:00
Koen 5fffaf2f4e Added next/previous skip buttons. 2023-12-06 19:40:05 +01:00
Koen 58da91eae8 Made history properly reload. 2023-12-06 16:47:22 +01:00
Koen 98d92d3fe2 Updated HistoryView to use pager. 2023-12-06 16:32:17 +01:00
Koen c5d35b27f0 Cleanup on store PR. 2023-12-06 13:41:07 +01:00
Koen aee5b75c2f Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-06 10:34:32 +01:00
Koen fe02197bd8 Fixes to Polycentric flows. 2023-12-06 10:34:20 +01:00
Kelvin a1060a15be Merge 2023-12-05 21:04:59 +01:00
Kelvin dc7b2f420b Refs 2023-12-05 21:03:58 +01:00
Kelvin b35390a4bb Merge branch 'db-store' into 'master'
WIP DBStore

See merge request videostreaming/grayjay!8
2023-12-05 19:44:19 +00:00
Kelvin 3b253ad2b6 Merge 2023-12-05 20:41:06 +01:00
Kelvin 06c39ce973 QueryIn support, channel cache query grouped 2023-12-05 17:04:09 +01:00
Koen 11b8914615 Fixed polycentric disable fallback. 2023-12-05 16:10:51 +01:00
Koen e45c8617df Cleanup. 2023-12-05 15:21:35 +01:00
Koen 9075a2599c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-05 15:20:05 +01:00
Koen dd8d50e0e2 Added disable polycentric setting. 2023-12-05 15:19:54 +01:00
Kelvin 55a11d82ac Creator search, toggle to disable bad rep comments fading 2023-12-05 14:15:39 +01:00
Kelvin 7ee4f411cb Import dialog 2023-12-04 22:18:07 +01:00
Kelvin c9d5508018 Refs 2023-12-04 20:08:29 +01:00
Kelvin bef8fc682c Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-12-04 20:07:32 +01:00
Kelvin c37d464403 Refs 2023-12-04 20:07:14 +01:00
Kelvin cbf2712654 ManagedDBSTore delete corrupted items, Fix serialized content serializer, Fix notifications wrong intent 2023-12-04 20:06:24 +01:00
Koen 08134b4427 If cache entry is expired, load it from cache, then reload it from live. 2023-12-04 17:09:27 +01:00
Koen f90290c4ec Added support for connecting to FCast via QR code. 2023-12-04 10:57:11 +01:00
Kelvin 7cde8ed538 Filler dev options 2023-12-01 16:45:17 +01:00
Kelvin 585cf090d6 DBStore improvements, query like support, more unittest, refactor into StateHistory, history indexes 2023-12-01 16:09:41 +01:00
Koen 23d1085755 Fixes to connectivity loss playback restart and fixes to added ensureEnoughContentVisible. 2023-12-01 15:01:41 +01:00
Koen fc5888d57e Added setting to allow restarting playback after connectivity loss behavior to be changed. 2023-12-01 14:11:15 +01:00
Kelvin c5541b1747 Working DBCache, test plugin 2023-11-30 20:58:37 +01:00
Koen 0fd8ba28bb Chromecast protobuf cleanup and fixed Odysee content-types being misrepresented causing casting to desktop to break. 2023-11-30 14:21:42 +01:00
Koen 6d9f4959e0 Removed Logger. 2023-11-30 11:50:52 +01:00
Koen 4be4bb631f Fixed gesture control issues causing wrong area to have gesture controls and disabled full screen gesture when casting. 2023-11-30 11:40:58 +01:00
Koen 948f5a2a6d Added FCast guide and other casting help options. 2023-11-30 09:56:40 +01:00
Koen baad342aec Fixed Rumble comments and show error in CommentList whenever an error happens. 2023-11-30 08:47:07 +01:00
Kelvin aeb29c54cd WIP Channel content cache 2023-11-30 00:12:46 +01:00
Koen a5dfa653ad Removed last mentions of FastCast and added backwards compatibility. 2023-11-29 16:02:58 +01:00
Koen 3387c727d1 Proper implementation for replies/likes/dislikes in the comment tab. 2023-11-29 15:48:34 +01:00
Koen c806ff2e33 Added support for comment deletion. 2023-11-29 13:54:26 +01:00
Koen 1db4d427fc Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-29 13:43:49 +01:00
Koen 3bf73ed5e8 Implemented delete comment support. Implemented Comments tab. Implemented replies overlay showing parent comment. 2023-11-29 13:43:40 +01:00
Gabe Rogan db44aa2c4d Merge branch 'quickstart-docs' into 'master'
Add quickstart docs

See merge request videostreaming/grayjay!7
2023-11-28 14:32:40 +00:00
Gabe Rogan 0e6e381800 Custom HTTP server wording 2023-11-28 09:31:46 -05:00
Gabe Rogan 69e43dc533 Split docs into multiple pages 2023-11-28 09:11:42 -05:00
Gabe Rogan ee4442d553 Add quickstart docs 2023-11-27 16:20:53 -05:00
Kelvin c49b9f7841 DBStore query support and tests 2023-11-27 17:38:55 +01:00
Koen 8a35cd0e82 Added settings to allow different behavior when audio focus is regained within 10 seconds. 2023-11-27 17:08:40 +01:00
Koen 0ae90ecf03 Updated Playstore flow for URL handling. 2023-11-27 16:27:56 +01:00
Koen 3d2840fe15 Merge branch 'hls-download' into 'master'
HLS download implementation

See merge request videostreaming/grayjay!6
2023-11-27 13:49:34 +00:00
Koen b6ad3fd991 HLS download implementation 2023-11-27 13:49:34 +00:00
Koen 2ee3c30b0e Better URL handling support. Prompt user to set Grayjay as a default handler for certain URLs. 2023-11-27 12:10:53 +01:00
Kelvin 662e94bcee Unittests and fixes for dbstore 2023-11-24 22:42:30 +01:00
Kelvin f3c9e0196e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store 2023-11-24 15:22:34 +01:00
Kelvin f15eb9bf9e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-24 15:22:08 +01:00
Kelvin 12b2552185 Settings search, Fix nested video events, Adding setting descriptions for metered 2023-11-24 15:22:03 +01:00
Koen d245e20b14 Chromecast socket crash fix. 2023-11-24 11:24:52 +01:00
Koen e47349d010 Added OPTIONS headers where necessary and further HLS spec implementations. 2023-11-24 10:37:18 +01:00
Kelvin eb3dd854d4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 17:28:23 +01:00
Kelvin c529446219 Attempt to fetch live videos for offline videos 2023-11-23 17:28:14 +01:00
Koen fa2f8c3447 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 16:45:09 +01:00
Koen 840d1ae534 Fixes to adhere closer to the HLS spec and Twitch VODs no longer start at end. 2023-11-23 16:44:58 +01:00
Kelvin 2530c6eb58 Live chat improvements and fixes 2023-11-23 16:35:13 +01:00
Kelvin 869789f0e2 WIP 2023-11-23 16:03:25 +01:00
Koen ee3761c780 Added full support for HLS casting to Airplay. 2023-11-23 13:18:09 +01:00
Koen e4c89e9aa9 Extended HLS spec, fixes to YES NO booleans, started on implementing HLS stream combiner. 2023-11-23 12:48:16 +01:00
Koen 9d5888ddf7 Fixed VODs not working properly for YouTube and Twitch. 2023-11-23 11:48:50 +01:00
Koen ecc94920d7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 22:33:05 +01:00
Koen 5cafbf243e Fixed channel contents long press and fixed a crash due to time bars. 2023-11-22 22:32:44 +01:00
Kelvin f3fa208680 Kick subs fix, dedup fix 2023-11-22 18:04:29 +01:00
Kelvin 502602e27a Reordering progress bar settings 2023-11-22 16:50:54 +01:00
Kelvin 5054b093a4 Stable refs 2023-11-22 16:15:05 +01:00
Kelvin 0ffaec6bc2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 16:05:39 +01:00
Kelvin ef8ea9eecf Fix whitelist checking for dev-portal 2023-11-22 16:05:27 +01:00
Koen b09d22e479 Added historical time bars to videos. 2023-11-22 14:49:34 +01:00
Koen 01787b6229 Added backfill exception printing to announcements. 2023-11-22 12:46:39 +01:00
Koen 4c022698d3 Quality selection overlay now properly closes when pressing the back button. 2023-11-22 11:32:51 +01:00
Koen bfdcab0e84 Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. 2023-11-22 11:21:18 +01:00
Koen aaea5cc963 Only close the app on closeSegment if there is no video playing. 2023-11-22 10:38:04 +01:00
Koen 23d9c33406 Added support for v6 Odysee URLs. 2023-11-22 10:27:35 +01:00
Koen fad1b216df Further extended HLS spec that is implemented. 2023-11-22 09:32:52 +01:00
Kelvin e221b508d3 Improved notifications, experimental scheduled notifications 2023-11-21 23:31:26 +01:00
Koen dfafac7d99 Merge branch 'hls-live-stream-proxy' into 'master'
Finished implementation of HLS proxying.

See merge request videostreaming/grayjay!5
2023-11-21 15:12:09 +00:00
Koen 2246f8cee2 Finished implementation of HLS proxying. 2023-11-21 15:12:09 +00:00
Kelvin b65fc594dc Working history DB implementation 2023-11-20 21:27:27 +01:00
Kelvin f52b731615 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into db-store 2023-11-20 14:24:48 +01:00
Koen 8661ff88c0 Another iteration on the HttpContext fix. 2023-11-20 12:14:03 +01:00
Kelvin 99c06c516f WIP Store/testing 2023-11-17 22:17:49 +01:00
Koen 0bba7fa373 Keep screen on fixes. 2023-11-17 16:44:16 +01:00
Kelvin 0c1822b118 Locked content support 2023-11-17 00:34:21 +01:00
Kelvin 10e3d2122f wip 2023-11-16 20:32:15 +01:00
Kelvin 6df8f84421 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-16 17:13:50 +01:00
Kelvin 7fa80ec048 Fix communication issues devportal 2023-11-16 17:13:23 +01:00
Koen b3f9b81984 Do not allow empty polycentric comments. 2023-11-16 15:51:34 +01:00
Kelvin 1393c489c1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-15 20:02:36 +01:00
Kelvin 640c2cbed0 Chapter accuracy now sub-second 2023-11-15 20:02:28 +01:00
Koen e55509f549 Merge branch 'casting-fixes' into 'master'
Content length is now set correctly for HttpConstantHandler.

See merge request videostreaming/grayjay!4
2023-11-15 16:18:38 +00:00
Koen 27c7fb0c12 Content length is now set correctly for HttpConstantHandler. 2023-11-15 16:18:38 +00:00
Koen 88f3815585 When clicking on a video it is added to queue instead of replacing queue. 2023-11-15 11:49:22 +01:00
Koen 2e9405cfdb Exit full screen swipe is now a down gesture. 2023-11-15 11:40:09 +01:00
Koen 9c1b543ed6 Added 'Add to new playlist' button in options menu. 2023-11-14 14:58:24 +01:00
Koen d34cb0f9c1 Added support for long-press gesture to open options menu. 2023-11-14 14:42:41 +01:00
Koen 116dc90d21 Changed the way the changelog is written. 2023-11-14 14:18:17 +01:00
Kelvin 17b9853bb6 Better subscription behavior reporting 2023-11-14 00:27:13 +01:00
Kelvin 8bfb8abd20 Fix notifications 2023-11-13 23:35:25 +01:00
Kelvin 9ee3f1f26e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-13 22:10:43 +01:00
Kelvin 5dcff29d8d Language on all activities, Fix plugin initial state, Allow rotation prevention bypass, Settings warning support 2023-11-13 22:10:31 +01:00
Koen 6cfbd0c8bf Added + Tax indicator 2023-11-13 15:58:01 +01:00
Koen 01d96cce16 Made export request folder to export to. 2023-11-13 15:49:55 +01:00
Koen 58c376f011 Fixed Rumble subscription import. 2023-11-13 12:06:46 +01:00
Kelvin 439d339330 Fix channel membership reset 2023-11-11 16:47:14 +01:00
Kelvin 44eacc2a47 Stable refs 2023-11-10 20:38:46 +01:00
Kelvin 8135d61398 Fix language issue 2023-11-10 20:34:43 +01:00
Kelvin 66208f8265 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 19:49:20 +01:00
Kelvin f52251e23a Hide creators, Fix hide video, 2023-11-10 19:49:09 +01:00
Koen dbea93efe5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 12:10:30 +01:00
Koen 3bf0740bd1 Fixed control cast on non-fullscreen. 2023-11-10 12:10:12 +01:00
Kelvin fa7f1b11f3 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 19:49:51 +01:00
Kelvin ff914bbdf4 Temporary subscription workarounds 2023-11-09 19:49:45 +01:00
Koen b822078d4b Fixed support tab falsely showing when a creator does not have a polycentric profile. 2023-11-09 19:12:54 +01:00
Kelvin 290d2ceb50 Polycentrif ref 2023-11-09 16:57:32 +01:00
Kelvin 8ec9025990 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 16:55:32 +01:00
Kelvin c4cf856dcd Stable submods 2023-11-09 16:55:26 +01:00
Koen 38bb4e25d3 Merge branch 'encryption-changes' into 'master'
Encryption changes, load more on import subscriptions.

See merge request videostreaming/grayjay!3
2023-11-09 15:04:55 +00:00
Koen 0de996d91c Encryption changes, load more on import subscriptions. 2023-11-09 15:04:54 +00:00
Kelvin 1f38c9b27d Logs 2023-11-09 15:46:22 +01:00
Kelvin 234f31b02d Logs and submods 2023-11-09 15:42:33 +01:00
Koen 00e40e8cd6 Merge branch 'encryption-provider-split' into 'master'
Encryption provider split.

See merge request videostreaming/grayjay!2
2023-11-08 15:06:29 +00:00
Koen 0bc6a43dc1 Encryption provider split. 2023-11-08 15:06:29 +00:00
Kelvin e7e0157fbc Stable refs 2023-11-08 16:06:07 +01:00
Kelvin 4cae1a41a5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-08 16:03:01 +01:00
Kelvin 4fa61e7f52 Additional plugin settings capabilities 2023-11-08 16:02:52 +01:00
Koen f02ac796f5 Updated YT stable. 2023-11-08 13:23:49 +01:00
Kelvin 22146a6bdc Playlists ui tweaks 2023-11-08 12:15:53 +01:00
Kelvin 5285eae01d Channel membership support and live chat donation support fix 2023-11-07 20:34:23 +01:00
Koen c47ca369e4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 17:16:19 +01:00
Koen f0b1f62bb1 Casting button no longer visible when disabled (may require restart). 2023-11-07 17:16:06 +01:00
Kelvin f7aa6d006e Add header to login activity with current url 2023-11-07 16:46:55 +01:00
Kelvin 6b67cd549f Fix chapters not getting cleared 2023-11-07 16:24:22 +01:00
Kelvin fc6bf85822 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:04:26 +01:00
Kelvin fbd9345cf8 Fix fallback to cache results 2023-11-07 16:04:19 +01:00
Koen 63137b4c4d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:03:05 +01:00
Koen e28dc7a3a6 Crash fix for bottom menu bar for specific screen dimensions. 2023-11-07 16:02:55 +01:00
Kelvin 6e14acc685 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 15:43:21 +01:00
Kelvin ba64153f1d Language setting, Preview setting, Background audio switch setting, No error on comments failed 2023-11-07 15:43:13 +01:00
Koen 72c04e7556 Added casting button in full screen. 2023-11-07 15:08:33 +01:00
Koen 54f37ee5b2 Fixed crash. 2023-11-07 15:01:22 +01:00
Koen 4fbb325313 Added fix for video player not filling height properly for audio only in a playlist. 2023-11-07 14:20:57 +01:00
Koen e1d3b95f73 Fixed crash on Android 9 when playing a video. 2023-11-07 13:35:13 +01:00
Koen 8f7b4b8257 Merge branch 'monetization' into 'master'
Monetization

See merge request videostreaming/grayjay!1
2023-11-07 12:10:40 +00:00
Koen 9d906025ea Monetization 2023-11-07 12:10:40 +00:00
Kelvin d7f4dd65e8 Stable refs 2023-11-06 14:58:11 +01:00
Kelvin 599b119e62 Remove plugin interaction on main thread for channels 2023-11-06 14:53:24 +01:00
Kelvin 41176464db Fix missing swipe to refresh on tab switch 2023-11-06 14:43:24 +01:00
Kelvin dd0ad19fb9 NewLine subs import, fix no-recent video subscriptions 2023-11-06 14:25:09 +01:00
Kelvin 430625d2fb Fix icon colors 2023-11-06 13:37:18 +01:00
Kelvin 796cd1a776 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-06 13:20:42 +01:00
Kelvin baa26af0c0 Only show sub toasts when on subs page, WIP import ui 2023-11-06 13:20:33 +01:00
Koen ea0c27936e Fixed videos not automatically going to next video in playlist when casting. 2023-11-05 15:13:57 +01:00
Kelvin 4aade35d19 Grayjay schema channel support 2023-11-04 18:42:04 +01:00
Kelvin 251a5701af Custom grayjay open video url handling 2023-11-04 18:31:01 +01:00
Kelvin 2da3116111 Fix initial selection of subscription settings 2023-11-03 20:07:08 +01:00
Kelvin 4c82fa1a4a Stable refs 2023-11-03 18:25:40 +01:00
Kelvin 7eef6eece2 Primary claim support, fix sub for clients without type 2023-11-03 18:17:04 +01:00
Kelvin 570f32e980 PlatformUrl support 2023-11-03 15:39:27 +01:00
Kelvin 16a0351125 Per-plugin ratelimit setting 2023-11-03 15:15:18 +01:00
Kelvin 2fa9005806 Keep plugin settings on update 2023-11-03 14:46:43 +01:00
Kelvin 25527997fa Fix channels updating while they shouldnt 2023-11-03 14:37:36 +01:00
Kelvin 4655d8369d Reduce subscription calls, Improve subs sorting, Improve view sorting 2023-11-03 13:34:23 +01:00
Kelvin aeaaace3a4 Subscription settings from creators tab 2023-11-02 23:42:51 +01:00
Kelvin e6997004ff Fix new user crash, show/hide subscription settings button on change, raise import limit to 90 2023-11-02 23:22:42 +01:00
Kelvin 5e1896b7f2 Stable ref 2023-11-02 22:52:29 +01:00
Kelvin 88ca90c13a Notification improvements, Polycentric subscription parallelization, Cache load parallelization 2023-11-02 22:23:24 +01:00
Kelvin f8ee340499 Creator sort options views and watchtime, subscription header ordered by views, view/watchtime tracking for subscriptions, optional view/watchtime metrics in creator tab, cache channel results if subscribed, update subs only if older than 5 min 2023-11-02 20:21:26 +01:00
Kelvin 93f5260e20 Working smart subscriptions, Direct url through search, channel content cache trimming, skippable and skip chapter support, reinstall button for embedded plugins 2023-11-01 20:32:51 +01:00
Kelvin 34ba44ffa4 WIP Subscription notifs 2023-11-01 00:36:01 +01:00
Kelvin b3a3e459a4 WIP Smart subscriptions 2023-11-01 00:09:05 +01:00
Kelvin f234564952 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-30 19:22:22 +01:00
Kelvin ffa5795cc9 WIP new subscription system and ui 2023-10-30 19:22:17 +01:00
Koen 4f50c51356 Fixed context crash. 2023-10-30 14:37:49 +01:00
Koen 9e9c8a0bec Fixed Polycentric not backfilling issue 2023-10-30 11:44:56 +01:00
Koen 1349358d7c Added QRCaptureActivity and AudioNoisyReceiver to manifest. 2023-10-30 11:21:51 +01:00
Koen 9c50f15be7 Processed community feedback on German translations. Thank you McIrco95. 2023-10-30 11:08:29 +01:00
Koen 31e771daca Processed community feedback on german translations. Thank you Allstreamer. 2023-10-30 10:59:24 +01:00
Koen 66ce156dea Processed community feedback on translations. Thank you jorpilo. 2023-10-30 10:47:40 +01:00
Koen db6756bc78 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-10-30 09:22:34 +01:00
Koen cab2581476 Added more logging related to backfill and made backfill properly throw. 2023-10-30 09:22:25 +01:00
Kelvin 4c0be35020 Fix ContentTypes docs 2023-10-29 18:28:52 +00:00
Koen 7114201c08 Translations 2023-10-27 20:01:22 +02:00
Kelvin d8aecd325b Basic chapter system working 2023-10-25 20:38:57 +02:00
Koen 1d18c13817 Updated submodule. 2023-10-25 20:05:03 +02:00
Koen f65eb0cd53 Finished moving view strings to strings.xml 2023-10-25 19:54:48 +02:00
Koen 206c3884e9 Finished moving strings to strings.xml for activities and fragments. 2023-10-25 14:53:50 +02:00
Koen 35f9173980 Started moving all strings to strings.xml 2023-10-25 12:16:58 +02:00
595 changed files with 29908 additions and 6602 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Thank you for your interest in contributing! This document outlines how you can
### License
The official plugins for this project are licensed under GPLv3. Any contributions you make will also fall under the GPLv3 license.
The official plugins for this project are licensed under AGPL. Any contributions you make will also fall under the AGPL license.
### How to Contribute
+47 -32
View File
@@ -1,10 +1,11 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
id 'org.ajoberstar.grgit' version '1.7.2'
id 'com.google.protobuf'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
}
ext {
@@ -23,7 +24,7 @@ if (keystorePropertiesFile.exists()) {
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.22.3'
artifact = 'com.google.protobuf:protoc:3.25.1'
}
generateProtoTasks {
all().each { task ->
@@ -38,7 +39,7 @@ protobuf {
android {
namespace 'com.futo.platformplayer'
compileSdk 33
compileSdk 34
flavorDimensions "buildType"
productFlavors {
stable {
@@ -96,11 +97,15 @@ android {
defaultConfig {
minSdk 28
targetSdk 33
targetSdk 34
versionCode gitVersionCode
versionName gitVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
signingConfigs {
@@ -136,43 +141,47 @@ android {
universalApk true
}
}
buildFeatures {
buildConfig true
}
}
dependencies {
//Core
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
//Images
annotationProcessor 'com.github.bumptech.glide:compiler:4.15.1'
implementation 'com.github.bumptech.glide:glide:4.15.1'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
//Async
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
//HTTP
implementation "com.squareup.okhttp3:okhttp:4.10.0"
implementation "com.squareup.okhttp3:okhttp:4.11.0"
//JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" //Used for structured json
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation("com.caoccao.javet:javet-android:2.2.1")
//Exoplayer
implementation 'com.google.android.exoplayer:exoplayer-core:2.18.7'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.18.7'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.7'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.18.7'
implementation 'com.google.android.exoplayer:exoplayer-rtsp:2.18.7'
implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.18.7'
implementation 'com.google.android.exoplayer:exoplayer-transformer:2.18.7'
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
implementation 'androidx.media3:media3-exoplayer:1.2.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.2.0'
implementation 'androidx.media3:media3-ui:1.2.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.2.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.0'
implementation 'androidx.media3:media3-transformer:1.2.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.5'
implementation 'androidx.media:media:1.7.0'
//Other
implementation 'org.jmdns:jmdns:3.5.1'
@@ -180,28 +189,34 @@ dependencies {
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.arthenica:ffmpeg-kit-full:5.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.7.20'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1'
implementation 'com.journeyapps:zxing-android-embedded:4.2.0'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:3.22.3'
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
implementation 'com.polycentric.core:app:1.0'
implementation 'com.futo.futopay:app:1.0'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.work:work-runtime-ktx:2.9.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
//Database
implementation("androidx.room:room-runtime:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
//Payment
implementation 'com.stripe:stripe-android:20.28.3'
implementation 'com.stripe:stripe-android:20.35.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.20"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
testImplementation "org.mockito:mockito-core:5.4.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
@@ -0,0 +1,94 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ffba56c2f572c25080ce8596e8bb8945",
"entities": [
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `url` TEXT NOT NULL, `position` INTEGER NOT NULL, `datetime` INTEGER NOT NULL, `name` TEXT NOT NULL, `serialized` BLOB)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "datetime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serialized",
"columnName": "serialized",
"affinity": "BLOB",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_history_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_history_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_history_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_history_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_history_datetime",
"unique": false,
"columnNames": [
"datetime"
],
"orders": [
"DESC"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_history_datetime` ON `${TABLE_NAME}` (`datetime` DESC)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffba56c2f572c25080ce8596e8bb8945')"
]
}
}
@@ -0,0 +1,88 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "eb813d54b9c44d29f1d7bb198a16d4d1",
"entities": [
{
"tableName": "subscription_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `url` TEXT, `channelUrl` TEXT, `datetime` INTEGER, `serialized` BLOB)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "channelUrl",
"columnName": "channelUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "datetime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "serialized",
"columnName": "serialized",
"affinity": "BLOB",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_subscription_cache_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_subscription_cache_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_subscription_cache_channelUrl",
"unique": false,
"columnNames": [
"channelUrl"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_subscription_cache_channelUrl` ON `${TABLE_NAME}` (`channelUrl`)"
},
{
"name": "index_subscription_cache_datetime",
"unique": false,
"columnNames": [
"datetime"
],
"orders": [
"DESC"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_subscription_cache_datetime` ON `${TABLE_NAME}` (`datetime` DESC)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb813d54b9c44d29f1d7bb198a16d4d1')"
]
}
}
@@ -0,0 +1,52 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "6e3b2d286325c4ea8a7a4c94c290daec",
"entities": [
{
"tableName": "testing",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`someString` TEXT NOT NULL, `someNum` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT, `serialized` BLOB)",
"fields": [
{
"fieldPath": "someString",
"columnName": "someString",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "someNum",
"columnName": "someNum",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "serialized",
"columnName": "serialized",
"affinity": "BLOB",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6e3b2d286325c4ea8a7a4c94c290daec')"
]
}
}
@@ -0,0 +1,111 @@
package com.futo.platformplayer
import android.util.Base64
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.futo.platformplayer.casting.FCastCastingDevice
import com.futo.platformplayer.casting.Opcode
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
import com.futo.platformplayer.casting.models.FCastPlayMessage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import java.security.KeyFactory
import java.security.spec.PKCS8EncodedKeySpec
import javax.crypto.spec.SecretKeySpec
@RunWith(AndroidJUnit4::class)
class FCastEncryptionTests {
@Test
fun testDHEncryptionSelf() {
val keyPair1 = FCastCastingDevice.generateKeyPair()
val keyPair2 = FCastCastingDevice.generateKeyPair()
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
val message = FCastPlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testAESKeyGeneration() {
val cases = listOf(
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
//AES
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
),
listOf(
//Public other
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
//Private self
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
//AES
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
)
)
for (case in cases) {
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
}
}
@Test
fun testDHEncryptionKnown() {
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
val keyFactory = KeyFactory.getInstance("DH")
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
val privateKey = keyFactory.generatePrivate(privateKeySpec)
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
val message = FCastPlayMessage("text/html")
val serializedBody = Json.encodeToString(message)
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals(serializedBody, decryptedMessage.message)
}
@Test
fun testDecryptMessageKnown() {
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
}
}
@@ -1,13 +1,14 @@
package com.futo.platformplayer
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import junit.framework.TestCase.assertEquals
import org.junit.Test
class EncryptionProviderTests {
class GEncryptionProviderTests {
@Test
fun testEncryptDecrypt() {
val encryptionProvider = EncryptionProvider.instance
fun testEncryptDecryptV1() {
val encryptionProvider = GEncryptionProviderV1.instance
val plaintext = "This is a test string."
// Encrypt the plaintext
@@ -22,8 +23,8 @@ class EncryptionProviderTests {
@Test
fun testEncryptDecryptBytes() {
val encryptionProvider = EncryptionProvider.instance
fun testEncryptDecryptBytesV1() {
val encryptionProvider = GEncryptionProviderV1.instance
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
@@ -36,21 +37,36 @@ class EncryptionProviderTests {
assertArrayEquals(bytes, decrypted);
}
@Test
fun testEncryptDecryptBytesPassword() {
val encryptionProvider = EncryptionProvider.instance
val bytes = "This is a test string.".toByteArray();
val password = "1234".padStart(32, '9');
fun testEncryptDecryptV0() {
val encryptionProvider = GEncryptionProviderV0.instance
val plaintext = "This is a test string."
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, password)
val ciphertext = encryptionProvider.encrypt(plaintext)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, password)
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertEquals(plaintext, decrypted)
}
@Test
fun testEncryptDecryptBytesV0() {
val encryptionProvider = GEncryptionProviderV0.instance
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
@@ -0,0 +1,45 @@
package com.futo.platformplayer
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV1
import junit.framework.TestCase.assertEquals
import org.junit.Test
class GPasswordEncryptionProviderTests {
@Test
fun testEncryptDecryptBytesPasswordV1() {
val encryptionProvider = GPasswordEncryptionProviderV1();
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, "1234")
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, "1234")
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
@Test
fun testEncryptDecryptBytesPasswordV0() {
val encryptionProvider = GPasswordEncryptionProviderV0("1234".padStart(32, '9'));
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
assertEquals(a.size, b.size);
for(i in 0 until a.size) {
assertEquals(a[i], b[i]);
}
}
}
@@ -0,0 +1,368 @@
package com.futo.platformplayer
import androidx.test.platform.app.InstrumentationRegistry
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.stores.db.ManagedDBDescriptor
import com.futo.platformplayer.stores.db.ManagedDBStore
import com.futo.platformplayer.testing.DBTOs
import org.junit.Assert
import org.junit.Test
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import kotlin.reflect.KClass
class ManagedDBStoreTests {
val context = InstrumentationRegistry.getInstrumentation().targetContext;
@Test
fun startup() {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
store.shutdown();
}
@Test
fun insert() {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
val testObj = DBTOs.TestObject();
createAndAssert(store, testObj);
store.shutdown();
}
@Test
fun update() {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
val testObj = DBTOs.TestObject();
val obj = createAndAssert(store, testObj);
testObj.someStr = "Testing";
store.update(obj.id!!, testObj);
val obj2 = store.get(obj.id!!);
assertIndexEquals(obj2, testObj);
store.shutdown();
}
@Test
fun delete() {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
val testObj = DBTOs.TestObject();
val obj = createAndAssert(store, testObj);
store.delete(obj.id!!);
Assert.assertEquals(store.count(), 0);
Assert.assertNull(store.getOrNull(obj.id!!));
store.shutdown();
}
@Test
fun withIndex() {
val index = ConcurrentHashMap<Any, DBTOs.TestIndex>();
val store = ManagedDBStore.create("test", Descriptor())
.withIndex({it.someString}, index, true)
.load(context, true);
store.deleteAll();
val testObj1 = DBTOs.TestObject();
val testObj2 = DBTOs.TestObject();
val testObj3 = DBTOs.TestObject();
val obj1 = createAndAssert(store, testObj1);
val obj2 = createAndAssert(store, testObj2);
val obj3 = createAndAssert(store, testObj3);
Assert.assertEquals(store.count(), 3);
Assert.assertTrue(index.containsKey(testObj1.someStr));
Assert.assertTrue(index.containsKey(testObj2.someStr));
Assert.assertTrue(index.containsKey(testObj3.someStr));
Assert.assertEquals(index.size, 3);
val oldStr = testObj1.someStr;
testObj1.someStr = UUID.randomUUID().toString();
store.update(obj1.id!!, testObj1);
Assert.assertEquals(index.size, 3);
Assert.assertFalse(index.containsKey(oldStr));
Assert.assertTrue(index.containsKey(testObj1.someStr));
Assert.assertTrue(index.containsKey(testObj2.someStr));
Assert.assertTrue(index.containsKey(testObj3.someStr));
store.delete(obj2.id!!);
Assert.assertEquals(index.size, 2);
Assert.assertFalse(index.containsKey(oldStr));
Assert.assertTrue(index.containsKey(testObj1.someStr));
Assert.assertFalse(index.containsKey(testObj2.someStr));
Assert.assertTrue(index.containsKey(testObj3.someStr));
store.shutdown();
}
@Test
fun withUnique() {
val index = ConcurrentHashMap<Any, DBTOs.TestIndex>();
val store = ManagedDBStore.create("test", Descriptor())
.withIndex({it.someString}, index, false, true)
.load(context, true);
store.deleteAll();
val testObj1 = DBTOs.TestObject();
val testObj2 = DBTOs.TestObject();
val testObj3 = DBTOs.TestObject();
val obj1 = createAndAssert(store, testObj1);
val obj2 = createAndAssert(store, testObj2);
testObj3.someStr = testObj2.someStr;
Assert.assertEquals(store.insert(testObj3), obj2.id!!);
Assert.assertEquals(store.count(), 2);
store.shutdown();
}
@Test
fun getPage() {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
val testObjs = createSequence(store, 25);
val page1 = store.getPage(0, 10);
val page2 = store.getPage(1, 10);
val page3 = store.getPage(2, 10);
Assert.assertEquals(10, page1.size);
Assert.assertEquals(10, page2.size);
Assert.assertEquals(5, page3.size);
store.shutdown();
}
@Test
fun query() {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
val testStr = UUID.randomUUID().toString();
val testObj1 = DBTOs.TestObject();
val testObj2 = DBTOs.TestObject();
val testObj3 = DBTOs.TestObject();
val testObj4 = DBTOs.TestObject();
testObj3.someStr = testStr;
testObj4.someStr = testStr;
val obj1 = createAndAssert(store, testObj1);
val obj2 = createAndAssert(store, testObj2);
val obj3 = createAndAssert(store, testObj3);
val obj4 = createAndAssert(store, testObj4);
val results = store.query(DBTOs.TestIndex::someString, testStr);
Assert.assertEquals(2, results.size);
for(result in results) {
if(result.someNum == obj3.someNum)
assertIndexEquals(obj3, result);
else
assertIndexEquals(obj4, result);
}
store.shutdown();
}
@Test
fun queryPage() {
val index = ConcurrentHashMap<Any, DBTOs.TestIndex>();
val store = ManagedDBStore.create("test", Descriptor())
.withIndex({ it.someNum }, index)
.load(context, true);
store.deleteAll();
val testStr = UUID.randomUUID().toString();
val testResults = createSequence(store, 40, { i, testObject ->
if(i % 2 == 0)
testObject.someStr = testStr;
});
val page1 = store.queryPage(DBTOs.TestIndex::someString, testStr, 0,10);
val page2 = store.queryPage(DBTOs.TestIndex::someString, testStr, 1,10);
val page3 = store.queryPage(DBTOs.TestIndex::someString, testStr, 2,10);
Assert.assertEquals(10, page1.size);
Assert.assertEquals(10, page2.size);
Assert.assertEquals(0, page3.size);
store.shutdown();
}
@Test
fun queryPager() {
val testStr = UUID.randomUUID().toString();
testQuery(100, { i, testObject ->
if(i % 2 == 0)
testObject.someStr = testStr;
}) {
val pager = it.queryPager(DBTOs.TestIndex::someString, testStr, 10);
val items = pager.getResults().toMutableList();
while(pager.hasMorePages()) {
pager.nextPage();
items.addAll(pager.getResults());
}
Assert.assertEquals(50, items.size);
for(i in 0 until 50) {
val k = i * 2;
Assert.assertEquals(k, items[i].someNum);
}
}
}
@Test
fun queryLike() {
val testStr = UUID.randomUUID().toString();
val testStrLike = testStr.substring(0, 8) + "Testing" + testStr.substring(8, testStr.length);
testQuery(100, { i, testObject ->
if(i % 2 == 0)
testObject.someStr = testStrLike;
}) {
val results = it.queryLike(DBTOs.TestIndex::someString, "%Testing%");
Assert.assertEquals(50, results.size);
}
}
@Test
fun queryLikePager() {
val testStr = UUID.randomUUID().toString();
val testStrLike = testStr.substring(0, 8) + "Testing" + testStr.substring(8, testStr.length);
testQuery(100, { i, testObject ->
if(i % 2 == 0)
testObject.someStr = testStrLike;
}) {
val pager = it.queryLikePager(DBTOs.TestIndex::someString, "%Testing%", 10);
val items = pager.getResults().toMutableList();
while(pager.hasMorePages()) {
pager.nextPage();
items.addAll(pager.getResults());
}
Assert.assertEquals(50, items.size);
for(i in 0 until 50) {
val k = i * 2;
Assert.assertEquals(k, items[i].someNum);
}
}
}
@Test
fun queryGreater() {
testQuery(100, { i, testObject ->
testObject.someNum = i;
}) {
val results = it.queryGreater(DBTOs.TestIndex::someNum, 51);
Assert.assertEquals(48, results.size);
}
}
@Test
fun querySmaller() {
testQuery(100, { i, testObject ->
testObject.someNum = i;
}) {
val results = it.querySmaller(DBTOs.TestIndex::someNum, 30);
Assert.assertEquals(30, results.size);
}
}
@Test
fun queryBetween() {
testQuery(100, { i, testObject ->
testObject.someNum = i;
}) {
val results = it.queryBetween(DBTOs.TestIndex::someNum, 30, 65);
Assert.assertEquals(34, results.size);
}
}
@Test
fun queryIn() {
val ids = mutableListOf<String>()
testQuery(1100, { i, testObject ->
testObject.someNum = i;
ids.add(testObject.someStr);
}) {
val pager = it.queryInPager(DBTOs.TestIndex::someString, ids.take(1000), 65);
val list = mutableListOf<Any>();
list.addAll(pager.getResults());
while(pager.hasMorePages())
{
pager.nextPage();
list.addAll(pager.getResults());
}
Assert.assertEquals(1000, list.size);
}
}
private fun testQuery(items: Int, modifier: (Int, DBTOs.TestObject)->Unit, testing: (ManagedDBStore<DBTOs.TestIndex, DBTOs.TestObject, DBTOs.DB, DBTOs.DBDAO>)->Unit) {
val store = ManagedDBStore.create("test", Descriptor())
.load(context, true);
store.deleteAll();
createSequence(store, items, modifier);
try {
testing(store);
}
finally {
store.shutdown();
}
}
private fun createSequence(store: ManagedDBStore<DBTOs.TestIndex, DBTOs.TestObject, DBTOs.DB, DBTOs.DBDAO>, count: Int, modifier: ((Int, DBTOs.TestObject)->Unit)? = null): List<DBTOs.TestIndex> {
val list = mutableListOf<DBTOs.TestIndex>();
for(i in 0 until count) {
val obj = DBTOs.TestObject();
obj.someNum = i;
modifier?.invoke(i, obj);
list.add(createAndAssert(store, obj));
}
return list;
}
private fun createAndAssert(store: ManagedDBStore<DBTOs.TestIndex, DBTOs.TestObject, DBTOs.DB, DBTOs.DBDAO>, obj: DBTOs.TestObject): DBTOs.TestIndex {
val id = store.insert(obj);
Assert.assertTrue(id > 0);
val dbObj = store.get(id);
assertIndexEquals(dbObj, obj);
return dbObj;
}
private fun assertObjectEquals(obj1: DBTOs.TestObject, obj2: DBTOs.TestObject) {
Assert.assertEquals(obj1.someStr, obj2.someStr);
Assert.assertEquals(obj1.someNum, obj2.someNum);
}
private fun assertIndexEquals(obj1: DBTOs.TestIndex, obj2: DBTOs.TestObject) {
Assert.assertEquals(obj1.someString, obj2.someStr);
Assert.assertEquals(obj1.someNum, obj2.someNum);
assertObjectEquals(obj1.obj, obj2);
}
private fun assertIndexEquals(obj1: DBTOs.TestIndex, obj2: DBTOs.TestIndex) {
Assert.assertEquals(obj1.someString, obj2.someString);
Assert.assertEquals(obj1.someNum, obj2.someNum);
assertIndexEquals(obj1, obj2.obj);
}
class Descriptor: ManagedDBDescriptor<DBTOs.TestObject, DBTOs.TestIndex, DBTOs.DB, DBTOs.DBDAO>() {
override val table_name: String = "testing";
override fun indexClass(): KClass<DBTOs.TestIndex> = DBTOs.TestIndex::class;
override fun dbClass(): KClass<DBTOs.DB> = DBTOs.DB::class;
override fun create(obj: DBTOs.TestObject): DBTOs.TestIndex = DBTOs.TestIndex(obj);
}
}
+45 -6
View File
@@ -7,8 +7,12 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<application
android:allowBackup="true"
@@ -33,11 +37,15 @@
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
<service android:name=".services.DownloadService"
android:enabled="true" />
android:enabled="true"
android:foregroundServiceType="dataSync" />
<service android:name=".services.ExportingService"
android:enabled="true" />
android:enabled="true"
android:foregroundServiceType="dataSync" />
<receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" />
<receiver android:name=".receivers.PlannedNotificationReceiver" />
<activity
android:name=".activities.MainActivity"
@@ -57,6 +65,14 @@
<data android:scheme="grayjay" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fcast" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -91,6 +107,26 @@
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/zip" />
</intent-filter>
<intent-filter android:autoVerify="true">
@@ -182,9 +218,12 @@
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceOptionsActivity$QRCaptureActivity"
android:name=".activities.QRCaptureActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.FCastGuideActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
+5 -2
View File
@@ -540,6 +540,8 @@
<script>
IS_TESTING = true;
let lastScriptTag = null;
let shouldDevLog = true;
let shouldLoginCheck = true;
new Vue({
el: '#app',
data: {
@@ -603,7 +605,7 @@
};
setInterval(()=>{
try{
if(!this.Plugin.currentPlugin)
if(!this.Plugin.currentPlugin || !shouldDevLog)
return;
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
@@ -638,7 +640,8 @@
}, 1000);
setInterval(()=>{
try{
this.isTestLoggedIn();
if(shouldLoginCheck)
this.isTestLoggedIn();
}catch(ex){}
}, 2500);
},
+52 -14
View File
@@ -10,7 +10,8 @@ let Type = {
Videos: "VIDEOS",
Streams: "STREAMS",
Mixed: "MIXED",
Live: "LIVE"
Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
},
Order: {
Chronological: "CHRONOLOGICAL"
@@ -31,23 +32,31 @@ let Type = {
RAW: 0,
HTML: 1,
MARKUP: 2
},
Chapter: {
NORMAL: 0,
SKIPPABLE: 5,
SKIP: 6,
SKIPONCE: 7
}
};
let Language = {
UNKNOWN: "Unknown",
ARABIC: "Arabic",
SPANISH: "Spanish",
FRENCH: "French",
HINDI: "Hindi",
INDONESIAN: "Indonesian",
KOREAN: "Korean",
PORTBRAZIL: "Portuguese Brazilian",
RUSSIAN: "Russian",
THAI: "Thai",
TURKISH: "Turkish",
VIETNAMESE: "Vietnamese",
ENGLISH: "English"
ARABIC: "ar",
SPANISH: "es",
FRENCH: "fr",
HINDI: "hi",
INDONESIAN: "id",
KOREAN: "ko",
PORTUGUESE: "pt",
PORTBRAZIL: "pt",
RUSSIAN: "ru",
THAI: "th",
TURKISH: "tr",
VIETNAMESE: "vi",
ENGLISH: "en"
}
class ScriptException extends Error {
@@ -64,6 +73,11 @@ class ScriptException extends Error {
}
}
}
class ScriptLoginRequiredException extends ScriptException {
constructor(msg) {
super("ScriptLoginRequiredException", msg);
}
}
class CaptchaRequiredException extends Error {
constructor(url, body) {
super(JSON.stringify({ 'plugin_type': 'CaptchaRequiredException', url, body }));
@@ -153,13 +167,27 @@ class FilterCapability {
class PlatformAuthorLink {
constructor(id, name, url, thumbnail, subscribers) {
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string
this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string
if(subscribers)
this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string (for backcompat)
}
}
class PlatformAuthorMembershipLink {
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string
this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string
if(subscribers)
this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string
}
}
class PlatformContent {
@@ -190,6 +218,16 @@ class PlatformNestedMediaContent extends PlatformContent {
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
}
}
class PlatformLockedContent extends PlatformContent {
constructor(obj) {
super(obj, 70);
obj = obj ?? {};
this.contentName = obj.contentName;
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
this.unlockUrl = obj.unlockUrl ?? "";
this.lockDescription = obj.lockDescription;
}
}
class PlatformVideo extends PlatformContent {
constructor(obj) {
super(obj, 1);
@@ -1,15 +1,28 @@
package com.futo.platformplayer
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
fun IPlatformVideoDetails.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IVideoSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
@UnstableApi
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
val requestModifier = getRequestModifier();
return if (requestModifier != null) {
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
} else {
DefaultHttpDataSource.Factory();
}
}
fun IVideoSourceDescriptor.hasAnySource(): Boolean = this.videoSources.any() || (this is VideoUnMuxedSourceDescriptor && this.audioSources.any());
@@ -13,7 +13,6 @@ import java.text.DecimalFormat
import java.time.OffsetDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.abs
import kotlin.time.toDuration
//Long
@@ -185,6 +184,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
return "${value} ${unit}";
};
fun Int.toHumanTimeIndicator(abs: Boolean = false) : String {
var value = this;
var unit = "s";
if(abs) value = abs(value);
if(value >= secondsInHour) {
value = (this / secondsInHour).toInt();
if(abs) value = abs(value);
unit = "hr" + (if(value > 1) "s" else "");
}
else if(value >= secondsInMinute) {
value = (this / secondsInMinute).toInt();
if(abs) value = abs(value);
unit = "min";
}
return "${value}${unit}";
}
fun Long.toHumanTime(isMs: Boolean): String {
var scaler = 1;
@@ -209,6 +227,18 @@ fun String.fixHtmlWhitespace(): Spanned {
return Html.fromHtml(replace("\n", "<br />"), HtmlCompat.FROM_HTML_MODE_LEGACY);
}
fun Long.formatDuration(): String {
val hours = this / 3600000
val minutes = (this % 3600000) / 60000
val seconds = (this % 60000) / 1000
return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
fun String.fixHtmlLinks(): Spanned {
//TODO: Properly fix whitespace handling.
val doc = Jsoup.parse(replace("\n", "<br />"));
@@ -1,11 +1,15 @@
package com.futo.platformplayer
import com.google.common.base.CharMatcher
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val IPV4_PART_COUNT = 4;
@@ -165,7 +169,7 @@ private fun parseHextet(ipString: String, start: Int, end: Int): Short {
var hextet = 0
for (i in start until end) {
hextet = hextet shl 4
hextet = hextet or ipString[i].digitToIntOrNull(16)!! ?: -1
hextet = hextet or ipString[i].digitToIntOrNull(16)!!
}
return hextet.toShort()
}
@@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
return connectedSocket;
}
fun InputStream.readHttpHeaderBytes() : ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = read()
if (b == -1) {
throw IOException("Unexpected end of stream while reading headers")
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
}
headerBytes.write(b)
}
return headerBytes.toByteArray()
}
fun InputStream.readLine() : String? {
val line = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 2) {
val b = read()
if (b == -1) {
return null
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
line.write(b)
}
}
return String(line.toByteArray(), Charsets.UTF_8)
}
@@ -1,6 +1,13 @@
package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.SystemState
import userpackage.Protocol
import kotlin.math.abs
import kotlin.math.min
@@ -39,4 +46,30 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
if (!systemState.servers.contains(PolycentricCache.STAGING_SERVER)) {
removeServer(PolycentricCache.STAGING_SERVER)
}
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
removeServer(PolycentricCache.SERVER)
}
val exceptions = fullyBackfillServers()
for (pair in exceptions) {
val server = pair.key
val exception = pair.value
StateAnnouncement.instance.registerAnnouncement(
"backfill-failed",
"Backfill failed",
"Failed to backfill server $server. $exception",
AnnouncementType.SESSION_RECURRING
);
Logger.e("Backfill", "Failed to backfill server $server.", exception)
}
}
@@ -1,5 +1,10 @@
package com.futo.platformplayer
import android.net.Uri
import java.net.URI
import java.net.URISyntaxException
import java.net.URLEncoder
//Syntax sugaring
inline fun <reified T> Any.assume(): T?{
if(this is T)
@@ -12,4 +17,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
if(result != null)
return cb(result);
return null;
}
fun String?.yesNoToBoolean(): Boolean {
return this?.uppercase() == "YES"
}
fun Boolean?.toYesNo(): String {
return if (this == true) "YES" else "NO"
}
@@ -27,7 +27,7 @@ fun <R> V8Value?.orDefault(default: R, handler: (V8Value)->R): R {
inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T {
if(this !is T)
throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}");
return this as T;
return this;
}
//Singles
@@ -109,11 +109,29 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else
return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T
};
Float::class -> {
if(this is V8ValueDouble)
return this.value.toFloat() as T;
else if(this is V8ValueInteger)
return this.value.toFloat() as T;
else if(this is V8ValueLong)
return this.value.toFloat() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
Double::class -> {
if(this is V8ValueDouble)
return this.value.toDouble() as T;
else if(this is V8ValueInteger)
return this.value.toDouble() as T;
else if(this is V8ValueLong)
return this.value.toDouble() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
@@ -0,0 +1,20 @@
package com.futo.platformplayer
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HorizontalSpaceItemDecoration(private val startSpace: Int, private val betweenSpace: Int, private val endSpace: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.left = betweenSpace
val position = parent.getChildAdapterPosition(view)
if (position == 0) {
outRect.left = startSpace
}
else if (position == state.itemCount - 1) {
outRect.right = endSpace
}
}
}
@@ -0,0 +1,20 @@
package com.futo.platformplayer
class PresetImages {
companion object {
val images = mapOf<String, Int>(
Pair("xp_book", R.drawable.xp_book),
Pair("xp_forest", R.drawable.xp_forest),
Pair("xp_code", R.drawable.xp_code),
Pair("xp_controller", R.drawable.xp_controller),
Pair("xp_laptop", R.drawable.xp_laptop)
);
fun getPresetResIdByName(name: String): Int {
return images[name] ?: -1;
}
fun getPresetNameByResId(id: Int): String? {
return images.entries.find { it.value == id }?.key;
}
}
}
@@ -4,30 +4,43 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.*
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.ManageTabsActivity
import com.futo.platformplayer.activities.PolycentricHomeActivity
import com.futo.platformplayer.activities.PolycentricProfileActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.*
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePayment
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateUpdate
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.OffsetDateTime
@@ -44,25 +57,23 @@ class Settings : FragmentedStorageFileJson() {
@Transient
val onTabsChanged = Event0();
@FormField(
"Manage Polycentric identity", FieldForm.BUTTON,
"Manage your Polycentric identity", -4
)
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -6)
@FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let {
if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
if (StatePolycentric.instance.enabled) {
if (StatePolycentric.instance.processHandle != null) {
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
} else {
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
}
} else {
it.startActivity(Intent(it, PolycentricHomeActivity::class.java));
UIDialogs.toast(it, "Polycentric is disabled")
}
}
}
@FormField(
"Show FAQ", FieldForm.BUTTON,
"Get answers to common questions", -3
)
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
@FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() {
try {
@@ -72,10 +83,7 @@ class Settings : FragmentedStorageFileJson() {
//Ignored
}
}
@FormField(
"Show Issues", FieldForm.BUTTON,
"A list of user-reported and self-reported issues", -2
)
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -4)
@FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() {
try {
@@ -86,9 +94,10 @@ class Settings : FragmentedStorageFileJson() {
}
}
/*
@FormField(
"Submit feedback", FieldForm.BUTTON,
"Give feedback on the application", -1
R.string.submit_feedback, FieldForm.BUTTON,
R.string.give_feedback_on_the_application, -1
)
@FormFieldButton(R.drawable.ic_bug)
fun submitFeedback() {
@@ -104,12 +113,9 @@ class Settings : FragmentedStorageFileJson() {
} catch (e: Throwable) {
//Ignored
}
}
}*/
@FormField(
"Manage Tabs", FieldForm.BUTTON,
"Change tabs visible on the home screen", -1
)
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -3)
@FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() {
try {
@@ -121,11 +127,58 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Home", "group", "Configure how your Home tab works and feels", 1)
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -2)
@FormFieldButton(R.drawable.ic_move_up)
fun import() {
val act = SettingsActivity.getActivity() ?: return;
val intent = MainActivity.getImportOptionsIntent(act);
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
act.startActivity(intent);
}
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -1)
@FormFieldButton(R.drawable.ic_link)
fun manageLinks() {
try {
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
} catch (e: Throwable) {
Logger.e(TAG, "Failed to show url handling prompt", e)
}
}
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@Serializable
class LanguageSettings {
@FormField(R.string.app_language, FieldForm.DROPDOWN, R.string.may_require_restart, 5, "app_language")
@DropdownFieldOptionsId(R.array.app_languages)
var appLanguage: Int = 0;
fun getAppLanguageLocaleString(): String? {
return when(appLanguage) {
0 -> null
1 -> "en";
2 -> "de";
3 -> "es";
4 -> "pt";
5 -> "fr"
6 -> "ja";
7 -> "ko";
8 -> "zh";
9 -> "ru";
10 -> "ar";
else -> null
}
}
}
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
var home = HomeSettings();
@Serializable
class HomeSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
@DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1;
@@ -135,21 +188,45 @@ class Settings : FragmentedStorageFileJson() {
else
return FeedStyle.THUMBNAIL;
}
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
@FormFieldButton(R.drawable.ic_visibility_off)
fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
}
@FormField("Search", "group", "", 2)
@FormField(R.string.search, "group", -1, 2)
var search = SearchSettings();
@Serializable
class SearchSettings {
@FormField("Search History", FieldForm.TOGGLE, "", 4)
@FormField(R.string.search_history, FieldForm.TOGGLE, R.string.may_require_restart, 3)
@Serializable(with = FlexibleBooleanSerializer::class)
var searchHistory: Boolean = true;
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 4)
@DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0)
@@ -159,11 +236,21 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Subscriptions", "group", "Configure how your Subscriptions works and feels", 3)
@FormField(R.string.channel, "group", -1, 3)
var channel = ChannelSettings();
@Serializable
class ChannelSettings {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = true;
}
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
var subscriptions = SubscriptionsSettings();
@Serializable
class SubscriptionsSettings {
@FormField("Feed Style", FieldForm.DROPDOWN, "", 5)
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 4)
@DropdownFieldOptionsId(R.array.feed_style)
var subscriptionsFeedStyle: Int = 1;
@@ -174,11 +261,23 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL;
}
@FormField("Fetch on app boot", FieldForm.TOGGLE, "Shortly after opening the app, start fetching subscriptions.", 6)
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
var showSubscriptionGroups: Boolean = true;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
var progressBar: Boolean = true;
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
@Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true;
@FormField("Background Update", FieldForm.DROPDOWN, "Experimental background update for subscriptions cache (requires restart)", 7)
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
var fetchOnTabOpen: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 10, "background_update")
@DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -194,26 +293,64 @@ class Settings : FragmentedStorageFileJson() {
};
@FormField("Subscription Concurrency", FieldForm.DROPDOWN, "Specify how many threads are used to fetch channels (requires restart)", 8)
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 11)
@DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3;
fun getSubscriptionsConcurrency() : Int {
return threadIndexToCount(subscriptionConcurrency);
}
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
var showWatchMetrics: Boolean = false;
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
var allowPlaytimeTracking: Boolean = true;
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
var alwaysReloadFromCache: Boolean = false;
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 15)
fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
StateCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
}
@FormField("Player", "group", "Change behavior of the player", 4)
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
var playback = PlaybackSettings();
@Serializable
class PlaybackSettings {
@FormField("Primary Language", FieldForm.DROPDOWN, "", 0)
@DropdownFieldOptionsId(R.array.languages)
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage];
fun getPrimaryLanguage(context: Context): String? {
return when(primaryLanguage) {
0 -> "en";
1 -> "es";
2 -> "de";
3 -> "fr";
4 -> "ja";
5 -> "ko";
6 -> "th";
7 -> "vi";
8 -> "id";
9 -> "hi";
10 -> "ar";
11 -> "tu";
12 -> "ru";
13 -> "pt";
14 -> "zh";
else -> null
}
}
@FormField("Default Playback Speed", FieldForm.DROPDOWN, "", 1)
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
@DropdownFieldOptionsId(R.array.playback_speeds)
var defaultPlaybackSpeed: Int = 3;
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
@@ -229,29 +366,29 @@ class Settings : FragmentedStorageFileJson() {
else -> 1.0f;
};
@FormField("Preferred Quality", FieldForm.DROPDOWN, "", 2)
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredQuality: Int = 0;
@FormField("Preferred Metered Quality", FieldForm.DROPDOWN, "", 2)
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
@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("Preferred Preview Quality", FieldForm.DROPDOWN, "", 3)
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
var preferredPreviewQuality: Int = 5;
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
@FormField("Auto-Rotate", FieldForm.DROPDOWN, "", 4)
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
var autoRotate: Int = 2;
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
@FormField("Auto-Rotate Dead Zone", FieldForm.DROPDOWN, "This prevents the device from rotating within the given amount of degrees.", 5)
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 6)
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
var autoRotateDeadZone: Int = 0;
@@ -259,21 +396,17 @@ class Settings : FragmentedStorageFileJson() {
return autoRotateDeadZone * 5;
}
@FormField("Background Behavior", FieldForm.DROPDOWN, "", 6)
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
@DropdownFieldOptionsId(R.array.player_background_behavior)
var backgroundPlay: Int = 2;
fun isBackgroundContinue() = backgroundPlay == 1;
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
@FormField("Resume After Preview", FieldForm.DROPDOWN, "When watching a video in preview mode, resume at the position when opening the video", 7)
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
@DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1;
@FormField("Live Chat Webview", FieldForm.TOGGLE, "Use the live chat web window when available over native implementation.", 8)
var useLiveChatWindow: Boolean = true;
fun shouldResumePreview(previewedPosition: Long): Boolean{
if(resumeAfterPreview == 2)
return true;
@@ -281,14 +414,59 @@ class Settings : FragmentedStorageFileJson() {
return true;
return false;
}
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
@DropdownFieldOptionsId(R.array.chapter_fps)
var chapterUpdateFPS: Int = 0;
fun getChapterUpdateFrames(): Int {
return when(chapterUpdateFPS) {
0 -> 24
1 -> 30
2 -> 60
3 -> 120
else -> 1
};
}
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterLoss: Int = 1;
@FormField(R.string.restart_after_connectivity_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_connectivity_after_a_loss, 12)
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
var restartPlaybackAfterConnectivityLoss: Int = 1;
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
var fullscreenPortrait: Boolean = false;
}
@FormField("Downloads", "group", "Configure downloading of videos", 5)
@FormField(R.string.comments, "group", R.string.comments_description, 6)
var comments = CommentSettings();
@Serializable
class CommentSettings {
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.comment_sections)
var defaultCommentSection: Int = 0;
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
var badReputationCommentsFading: Boolean = true;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
var downloads = Downloads();
@Serializable
class Downloads {
@FormField("Download when", FieldForm.DROPDOWN, "Configure when videos should be downloaded", 0)
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_videos_should_be_downloaded, 0)
@DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0;
@@ -301,21 +479,21 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Default Video Quality", FieldForm.DROPDOWN, "", 2)
@FormField(R.string.default_video_quality, FieldForm.DROPDOWN, -1, 2)
@DropdownFieldOptionsId(R.array.preferred_video_download)
var preferredVideoQuality: Int = 4;
fun getDefaultVideoQualityPixels(): Int = preferedQualityToPixels(preferredVideoQuality);
@FormField("Default Audio Quality", FieldForm.DROPDOWN, "", 3)
@FormField(R.string.default_audio_quality, FieldForm.DROPDOWN, -1, 3)
@DropdownFieldOptionsId(R.array.preferred_audio_download)
var preferredAudioQuality: Int = 1;
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
@FormField("ByteRange Download", FieldForm.TOGGLE, "Attempt to utilize byte ranges, this can be combined with concurrency to bypass throttling", 4)
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
@Serializable(with = FlexibleBooleanSerializer::class)
var byteRangeDownload: Boolean = true;
@FormField("ByteRange Concurrency", FieldForm.DROPDOWN, "Number of concurrent threads to multiply download speeds from throttled sources", 5)
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
@DropdownFieldOptionsId(R.array.thread_count)
var byteRangeConcurrency: Int = 3;
fun getByteRangeThreadCount(): Int {
@@ -323,23 +501,26 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Browsing", "group", "Configure browsing behavior", 6)
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
var browsing = Browsing();
@Serializable
class Browsing {
@FormField("Enable Video Cache", FieldForm.TOGGLE, "A cache to quickly load previously fetched videos", 0)
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var videoCache: Boolean = true;
}
@FormField("Casting", "group", "Configure casting", 7)
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
var casting = Casting();
@Serializable
class Casting {
@FormField("Enabled", FieldForm.TOGGLE, "Enable casting", 0)
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enable_casting, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var enabled: Boolean = true;
@FormField(R.string.keep_screen_on, FieldForm.TOGGLE, R.string.keep_screen_on_while_casting, 1)
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -357,25 +538,21 @@ class Settings : FragmentedStorageFileJson() {
}*/
}
@FormField("Logging", FieldForm.GROUP, "", 8)
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
var logging = Logging();
@Serializable
class Logging {
@FormField("Log Level", FieldForm.DROPDOWN, "", 0)
@FormField(R.string.log_level, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0;
@FormField(
"Submit logs", FieldForm.BUTTON,
"Submit logs to help us narrow down issues", 1
)
@FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
if (!Logger.submitLogs()) {
withContext(Dispatchers.Main) {
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Please enable logging to submit logs") }
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
}
}
} catch (e: Throwable) {
@@ -385,43 +562,40 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Announcement", FieldForm.GROUP, "", 10)
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
var announcementSettings = AnnouncementSettings();
@Serializable
class AnnouncementSettings {
@FormField(
"Reset announcements", FieldForm.BUTTON,
"Reset hidden announcements", 1
)
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements();
UIDialogs.toast("Announcements reset.");
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
}
}
@FormField("Plugins", FieldForm.GROUP, "", 11)
@FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
var notifications = NotificationSettings();
@Serializable
class NotificationSettings {
@FormField(R.string.planned_content_notifications, FieldForm.TOGGLE, R.string.planned_content_notifications_description, 1)
var plannedContentNotification: Boolean = true;
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient
var plugins = Plugins();
@Serializable
class Plugins {
@FormField("Clear Cookies on Logout", FieldForm.TOGGLE, "Clears cookies when you log out, allowing you to change account.", 0)
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true;
@FormField(
"Clear Cookies", FieldForm.BUTTON,
"Clears in-app browser cookies, especially useful for fully logging out of plugins.", 1
)
@FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null);
}
@FormField(
"Reinstall Embedded Plugins", FieldForm.BUTTON,
"Also removes any data related plugin like login or settings (may not clear browser cache)", 1
)
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
try {
@@ -429,7 +603,7 @@ class Settings : FragmentedStorageFileJson() {
withContext(Dispatchers.Main) {
StateApp.instance.contextOrNull?.let {
UIDialogs.toast(it, "Embedded plugins reinstalled, a reboot is recommended");
UIDialogs.toast(it, it.getString(R.string.embedded_plugins_reinstalled_a_reboot_is_recommended));
};
}
} catch (ex: Exception) {
@@ -440,11 +614,11 @@ class Settings : FragmentedStorageFileJson() {
}
}
}
}
}*/
}
@FormField("External Storage", FieldForm.GROUP, "", 12)
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
var storage = Storage();
@Serializable
class Storage {
@@ -456,34 +630,41 @@ class Settings : FragmentedStorageFileJson() {
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
fun changeStorageGeneral() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalGeneralDirectory(it);
}
}
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
fun changeStorageDownload() {
SettingsActivity.getActivity()?.let {
StateApp.instance.changeExternalDownloadDirectory(it);
}
}
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
}
@FormField("Auto Update", "group", "Configure the auto updater", 12)
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
var autoUpdate = AutoUpdate();
@Serializable
class AutoUpdate {
@FormField("Check", FieldForm.DROPDOWN, "", 0)
@FormField(R.string.check, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.auto_update_when_array)
var check: Int = 0;
@FormField("Background download", FieldForm.DROPDOWN, "Configure if background download should be used", 1)
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
@DropdownFieldOptionsId(R.array.background_download)
var backgroundDownload: Int = 0;
@FormField("Download when", FieldForm.DROPDOWN, "Configure when updates should be downloaded", 2)
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
@DropdownFieldOptionsId(R.array.when_download)
var whenDownload: Int = 0;
@@ -500,10 +681,7 @@ class Settings : FragmentedStorageFileJson() {
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
}
@FormField(
"Manual check", FieldForm.BUTTON,
"Manually check for updates", 3
)
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let {
@@ -514,19 +692,17 @@ class Settings : FragmentedStorageFileJson() {
try {
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
} catch (e: ActivityNotFoundException) {
UIDialogs.toast(it, "Failed to show store.");
UIDialogs.toast(it, it.getString(R.string.failed_to_show_store));
}
}
}
}
@FormField(
"View changelog", FieldForm.BUTTON,
"Review the current and past changelogs", 4
)
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
fun viewChangelog() {
UIDialogs.toast("Retrieving changelog");
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try {
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
@@ -542,10 +718,7 @@ class Settings : FragmentedStorageFileJson() {
};
}
@FormField(
"Remove Cached Version", FieldForm.BUTTON,
"Remove the last downloaded version", 5
)
@FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
fun removeCachedVersion() {
StateApp.withContext {
val outputDirectory = File(it.filesDir, "autoupdate");
@@ -561,7 +734,7 @@ class Settings : FragmentedStorageFileJson() {
}
}
@FormField("Backup", FieldForm.GROUP, "", 13)
@FormField(R.string.backup, FieldForm.GROUP, -1, 16)
var backup = Backup();
@Serializable
class Backup {
@@ -571,58 +744,78 @@ class Settings : FragmentedStorageFileJson() {
var autoBackupPassword: String? = null;
fun shouldAutomaticBackup() = autoBackupPassword != null;
@FormField("Automatic Backup", FieldForm.READONLYTEXT, "", 0)
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1)
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
fun configureAutomaticBackup() {
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
SettingsActivity.getActivity()?.reloadSettings();
};
}
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2)
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
fun restoreAutomaticBackup() {
val activity = SettingsActivity.getActivity()!!
if(!StateBackup.hasAutomaticBackup())
UIDialogs.toast(activity, "You don't have any automatic backups", false);
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
else
UIDialogs.showAutomaticRestoreDialog(activity, activity.lifecycleScope);
}
@FormField("Export Data", FieldForm.BUTTON, "Creates a zip file with your data which can be imported by opening it with Grayjay", 3)
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
fun export() {
StateBackup.startExternalBackup();
val activity = SettingsActivity.getActivity() ?: return;
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
StateBackup.shareExternalBackup();
}),
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
StateBackup.saveExternalBackup(activity);
})
)
}
}
@FormField("Payment", FieldForm.GROUP, "", 14)
@FormField(R.string.payment, FieldForm.GROUP, -1, 17)
var payment = Payment();
@Serializable
class Payment {
@FormField("Payment Status", FieldForm.READONLYTEXT, "", 1)
val paymentStatus: String get() = if (StatePayment.instance.hasPaid) "Paid" else "Not Paid";
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
@FormField("Clear Payment", FieldForm.BUTTON, "Deletes license keys from app", 2)
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
fun clearPayment() {
StatePayment.instance.clearLicenses();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Licenses cleared, might require app restart");
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
it.reloadSettings();
}
}
}
@FormField("Info", FieldForm.GROUP, "", 15)
@FormField(R.string.other, FieldForm.GROUP, -1, 18)
var other = Other();
@Serializable
class Other {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1)
var polycentricEnabled: Boolean = true;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
var info = Info();
@Serializable
class Info {
@FormField("Version Code", FieldForm.READONLYTEXT, "", 1, "code")
@FormField(R.string.version_code, FieldForm.READONLYTEXT, -1, 1, "code")
var versionCode = BuildConfig.VERSION_CODE;
@FormField("Version Name", FieldForm.READONLYTEXT, "", 2)
@FormField(R.string.version_name, FieldForm.READONLYTEXT, -1, 2)
var versionName = BuildConfig.VERSION_NAME;
@FormField("Version Type", FieldForm.READONLYTEXT, "", 3)
@FormField(R.string.version_type, FieldForm.READONLYTEXT, -1, 3)
var versionType = BuildConfig.BUILD_TYPE;
}
@@ -2,54 +2,69 @@ package com.futo.platformplayer
import android.content.Context
import android.webkit.CookieManager
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString
import com.futo.platformplayer.activities.DeveloperActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.FragmentedStorageFileJson
import com.futo.platformplayer.views.fields.ButtonField
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
import java.util.stream.IntStream.range
import kotlin.system.measureTimeMillis
@Serializable()
class SettingsDev : FragmentedStorageFileJson() {
@FormField("Developer Mode", FieldForm.TOGGLE, "", 0)
@FormField(R.string.developer_mode, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var developerMode: Boolean = false;
@FormField("Development Server", FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 1)
@FormField(R.string.development_server, FieldForm.GROUP,
R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 1)
val devServerSettings: DeveloperServerFields = DeveloperServerFields();
@Serializable
class DeveloperServerFields {
@FormField("Start Server on boot", FieldForm.TOGGLE, "", 0)
@FormField(R.string.start_server_on_boot, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var devServerOnBoot: Boolean = false;
@FormField("Start Server", FieldForm.BUTTON,
"Starts a DevServer on port 11337, may expose vulnerabilities.", 1)
@FormField(R.string.start_server, FieldForm.BUTTON,
R.string.starts_a_devServer_on_port_11337_may_expose_vulnerabilities, 1)
fun startServer() {
StateDeveloper.instance.runServer();
StateApp.instance.contextOrNull?.let {
@@ -58,45 +73,192 @@ class SettingsDev : FragmentedStorageFileJson() {
}
}
@FormField("Experimental", FieldForm.GROUP,
"Settings related to development server, be careful as it may open your phone to security vulnerabilities", 2)
@FormField(R.string.experimental, FieldForm.GROUP,
R.string.settings_related_to_development_server_be_careful_as_it_may_open_your_phone_to_security_vulnerabilities, 2)
val experimentalSettings: ExperimentalFields = ExperimentalFields();
@Serializable
class ExperimentalFields {
@FormField("Background Subscription Testing", FieldForm.TOGGLE, "", 0)
@FormField(R.string.background_subscription_testing, FieldForm.TOGGLE, -1, 0)
@Serializable(with = FlexibleBooleanSerializer::class)
var backgroundSubscriptionFetching: Boolean = false;
}
@FormField("Crash Me", FieldForm.BUTTON,
"Crashes the application on purpose", 2)
@FormField(R.string.cache, FieldForm.GROUP, -1, 3)
val cache: Cache = Cache();
@Serializable
class Cache {
@FormField(R.string.subscriptions_cache_5000, FieldForm.BUTTON, -1, 1, "subscription_cache_button")
fun subscriptionsCache5000() {
Logger.i("SettingsDev", "Started caching 5000 sub items");
UIDialogs.toast(
SettingsActivity.getActivity()!!,
"Started caching 5000 sub items"
);
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
if(button is ButtonField)
button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
val subsCache =
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(cacheScope = this).first;
var total = 0;
var page = 0;
var lastToast = System.currentTimeMillis();
while(subsCache.hasMorePages() && total < 5000) {
subsCache.nextPage();
total += subsCache.getResults().size;
page++;
if(page % 10 == 0)
withContext(Dispatchers.Main) {
val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis();
UIDialogs.toast(
SettingsActivity.getActivity()!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
);
}
Thread.sleep(250);
}
withContext(Dispatchers.Main) {
UIDialogs.toast(
SettingsActivity.getActivity()!!,
"FINISHED Page: ${page}, Total: ${total}"
);
}
}
catch(ex: Throwable) {
Logger.e("SettingsDev", ex.message, ex);
Logger.i("SettingsDev", "Failed: ${ex.message}");
}
finally {
withContext(Dispatchers.Main) {
if(button is ButtonField)
button.setButtonEnabled(true);
}
}
}
}
@FormField(R.string.history_cache_100, FieldForm.BUTTON, -1, 1, "history_cache_button")
fun historyCache100() {
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
UIDialogs.toast(
SettingsActivity.getActivity()!!,
"Started caching 100 history items (from home)"
);
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
if(button is ButtonField)
button.setButtonEnabled(false);
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
val subsCache = StatePlatform.instance.getHome();
var num = 0;
for(item in subsCache.getResults().filterIsInstance<IPlatformVideo>()) {
StateHistory.instance.getHistoryByVideo(item, true, OffsetDateTime.now().minusHours(num.toLong() * 4))
num++;
}
var total = 0;
var page = 0;
var lastToast = System.currentTimeMillis();
while(subsCache.hasMorePages() && total < 5000) {
subsCache.nextPage();
total += subsCache.getResults().size;
page++;
for(item in subsCache.getResults().filterIsInstance<IPlatformVideo>()) {
StateHistory.instance.getHistoryByVideo(item, true, OffsetDateTime.now().minusHours(num.toLong() * 4))
num++;
}
if(page % 4 == 0)
withContext(Dispatchers.Main) {
val diff = System.currentTimeMillis() - lastToast;
lastToast = System.currentTimeMillis();
UIDialogs.toast(
SettingsActivity.getActivity()!!,
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
);
}
Thread.sleep(500);
}
withContext(Dispatchers.Main) {
UIDialogs.toast(
SettingsActivity.getActivity()!!,
"FINISHED Page: ${page}, Total: ${total}"
);
}
}
catch(ex: Throwable) {
Logger.e("SettingsDev", ex.message, ex);
Logger.i("SettingsDev", "Failed: ${ex.message}");
}
finally {
withContext(Dispatchers.Main) {
if(button is ButtonField)
button.setButtonEnabled(true);
}
}
}
}
}
@FormField(R.string.crash_me, FieldForm.BUTTON,
R.string.crashes_the_application_on_purpose, 3)
fun crashMe() {
throw java.lang.IllegalStateException("This is an uncaught exception triggered on purpose!");
}
@FormField("Delete Announcements", FieldForm.BUTTON,
"Delete all announcements", 2)
@FormField(R.string.delete_announcements, FieldForm.BUTTON,
R.string.delete_all_announcements, 3)
fun deleteAnnouncements() {
StateAnnouncement.instance.deleteAllAnnouncements();
}
@FormField("Clear Cookies", FieldForm.BUTTON,
"Clear all cook from the CookieManager", 2)
@FormField(R.string.clear_cookies, FieldForm.BUTTON,
R.string.clear_all_cookies_from_the_cookieManager, 3)
fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null);
}
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
R.string.test_background_worker_description, 4)
fun triggerBackgroundUpdate() {
val act = SettingsActivity.getActivity()!!;
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
val wm = WorkManager.getInstance(act);
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
.build();
wm.enqueue(req);
}
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 4)
fun clearChannelContentCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
StateCache.instance.clearToday();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
}
@Contextual
@Transient
@FormField("V8 Benchmarks", FieldForm.GROUP,
"Various benchmarks using the integrated V8 engine", 3)
@FormField(R.string.v8_benchmarks, FieldForm.GROUP,
R.string.various_benchmarks_using_the_integrated_v8_engine, 4)
val v8Benchmarks: V8Benchmarks = V8Benchmarks();
class V8Benchmarks {
@FormField(
"Test V8 Creation speed", FieldForm.BUTTON,
"Tests V8 creation times and running", 1
R.string.test_v8_creation_speed, FieldForm.BUTTON,
R.string.tests_v8_creation_times_and_running, 1
)
fun testV8Creation() {
var plugin: V8Plugin? = null;
@@ -138,8 +300,8 @@ class SettingsDev : FragmentedStorageFileJson() {
}
@FormField(
"Test V8 Communication speed", FieldForm.BUTTON,
"Tests V8 communication speeds", 2
R.string.test_v8_communication_speed, FieldForm.BUTTON,
R.string.tests_v8_communication_speeds, 4
)
fun testV8RunSpeeds() {
var plugin: V8Plugin? = null;
@@ -183,12 +345,12 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual
@Transient
@FormField("V8 Script Testing", FieldForm.GROUP, "Various tests against a custom source", 4)
@FormField(R.string.v8_script_testing, FieldForm.GROUP, R.string.various_tests_against_a_custom_source, 4)
val v8ScriptTests: V8ScriptTests = V8ScriptTests();
class V8ScriptTests {
@Contextual
private var _currentPlugin : JSClient? = null;
@FormField("Inject", FieldForm.BUTTON, "Injects a test source config (local) into V8", 1)
@FormField(R.string.inject, FieldForm.BUTTON, R.string.injects_a_test_source_config_local_into_v8, 1)
fun testV8Init() {
StateApp.instance.scope.launch(Dispatchers.IO) {
try {
@@ -204,12 +366,12 @@ class SettingsDev : FragmentedStorageFileJson() {
}
}
}
@FormField("getHome", FieldForm.BUTTON, "Attempts to fetch 2 pages from getHome", 2)
@FormField(R.string.getHome, FieldForm.BUTTON, R.string.attempts_to_fetch_2_pages_from_getHome, 2)
fun testV8Home() {
runTestPlugin(_currentPlugin) {
var home: IPager<IPlatformContent>? = null;
var resultPage1: String = "";
var resultPage2: String = "";
var home: IPager<IPlatformContent>?;
val resultPage1: String;
val resultPage2: String;
val page1Time = measureTimeMillis {
home = it.getHome();
val results = home!!.getResults();
@@ -270,10 +432,10 @@ class SettingsDev : FragmentedStorageFileJson() {
@Contextual
@Transient
@FormField("Other", FieldForm.GROUP, "Others...", 5)
@FormField(R.string.other, FieldForm.GROUP, R.string.others_ellipsis, 5)
val otherTests: OtherTests = OtherTests();
class OtherTests {
@FormField("Unsubscribe all", FieldForm.BUTTON, "Removes all subscriptions", -1)
@FormField(R.string.unsubscribe_all, FieldForm.BUTTON, R.string.removes_all_subscriptions, -1)
fun unsubscribeAll() {
val toUnsub = StateSubscriptions.instance.getSubscriptions();
UIDialogs.toast("Started unsubbing.. (${toUnsub.size})")
@@ -282,24 +444,24 @@ class SettingsDev : FragmentedStorageFileJson() {
};
UIDialogs.toast("Finished unsubbing.. (${toUnsub.size})")
}
@FormField("Clear Downloads", FieldForm.BUTTON, "Deletes all ongoing downloads", 1)
@FormField(R.string.clear_downloads, FieldForm.BUTTON, R.string.deletes_all_ongoing_downloads, 1)
fun clearDownloads() {
StateDownloads.instance.getDownloading().forEach {
StateDownloads.instance.removeDownload(it);
};
}
@FormField("Clear All Downloaded", FieldForm.BUTTON, "Deletes all downloaded videos and related files", 2)
@FormField(R.string.clear_all_downloaded, FieldForm.BUTTON, R.string.deletes_all_downloaded_videos_and_related_files, 2)
fun clearDownloaded() {
StateDownloads.instance.getDownloadedVideos().forEach {
StateDownloads.instance.deleteCachedVideo(it.id);
};
}
@FormField("Delete Unresolved", FieldForm.BUTTON, "Deletes all unresolved source files", 3)
@FormField(R.string.delete_unresolved, FieldForm.BUTTON, R.string.deletes_all_unresolved_source_files, 3)
fun cleanupDownloads() {
StateDownloads.instance.cleanupDownloads();
}
@FormField("Fill storage till error", FieldForm.BUTTON, "Writes to disk till no space is left", 4)
@FormField(R.string.fill_storage_till_error, FieldForm.BUTTON, R.string.writes_to_disk_till_no_space_is_left, 4)
fun fillStorage(context: Context, scope: CoroutineScope?) {
val gigabuffer = ByteArray(1024 * 1024 * 128);
var count: Long = 0;
@@ -330,6 +492,17 @@ class SettingsDev : FragmentedStorageFileJson() {
}
}
@Contextual
@Transient
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
var info = Info();
@Serializable
class Info {
@FormField(R.string.dev_info_channel_cache_size, FieldForm.READONLYTEXT, -1, 1, "channelCacheSize")
var channelCacheStartupCount = StateCache.instance.channelCacheStartupCount;
}
//region BOILERPLATE
override fun encode(): String {
return Json.encodeToString(this);
@@ -1,21 +1,39 @@
package com.futo.platformplayer
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.*
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.dialogs.*
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.dialogs.AutomaticBackupDialog
import com.futo.platformplayer.dialogs.AutomaticRestoreDialog
import com.futo.platformplayer.dialogs.CastingAddDialog
import com.futo.platformplayer.dialogs.CastingHelpDialog
import com.futo.platformplayer.dialogs.ChangelogDialog
import com.futo.platformplayer.dialogs.CommentDialog
import com.futo.platformplayer.dialogs.ConnectCastingDialog
import com.futo.platformplayer.dialogs.ConnectedCastingDialog
import com.futo.platformplayer.dialogs.ImportDialog
import com.futo.platformplayer.dialogs.ImportOptionsDialog
import com.futo.platformplayer.dialogs.MigrateDialog
import com.futo.platformplayer.dialogs.ProgressDialog
import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateBackup
import com.futo.platformplayer.stores.v2.ManagedStore
@@ -91,6 +109,50 @@ class UIDialogs {
}.toTypedArray());
}
fun showUrlHandlingPrompt(context: Context, onYes: (() -> Unit)? = null) {
val builder = AlertDialog.Builder(context)
val view = LayoutInflater.from(context).inflate(R.layout.dialog_url_handling, null)
builder.setView(view)
val dialog = builder.create()
registerDialogOpened(dialog)
view.findViewById<TextView>(R.id.button_no).apply {
this.setOnClickListener {
dialog.dismiss()
}
}
view.findViewById<LinearLayout>(R.id.button_yes).apply {
this.setOnClickListener {
if (BuildConfig.IS_PLAYSTORE_BUILD) {
dialog.dismiss()
showDialogOk(context, R.drawable.ic_error_pred, context.getString(R.string.play_store_version_does_not_support_default_url_handling)) {
onYes?.invoke()
}
} else {
try {
val intent =
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", context.packageName, null)
intent.data = uri
context.startActivity(intent)
} catch (e: Throwable) {
toast(context, context.getString(R.string.failed_to_show_settings))
}
onYes?.invoke()
dialog.dismiss()
}
}
}
dialog.setOnDismissListener {
registerDialogClosed(dialog)
}
dialog.show()
}
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
val dialogAction: ()->Unit = {
@@ -100,14 +162,15 @@ class UIDialogs {
dialog.show();
};
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0,
UIDialogs.Action("Cancel", {}), //To nothing
UIDialogs.Action("Override", {
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
UIDialogs.Action(context.getString(R.string.override), {
dialogAction();
}, UIDialogs.ActionStyle.DANGEROUS),
UIDialogs.Action("Restore", {
UIDialogs.Action(context.getString(R.string.restore), {
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
}, UIDialogs.ActionStyle.PRIMARY));
}, UIDialogs.ActionStyle.PRIMARY)
);
else {
dialogAction();
}
@@ -142,8 +205,10 @@ class UIDialogs {
view.findViewById<TextView>(R.id.dialog_text_code).apply {
if(code == null)
this.visibility = View.GONE;
else
else {
this.text = code;
this.visibility = View.VISIBLE;
}
};
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
val buttons = actions.map<Action, TextView> { act ->
@@ -211,10 +276,10 @@ class UIDialogs {
(if(ex != null ) "${ex.message}" else ""),
if(ex is PluginException) ex.code else null,
0,
UIDialogs.Action("Retry", {
UIDialogs.Action(context.getString(R.string.retry), {
retryAction?.invoke();
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Close", {
UIDialogs.Action(context.getString(R.string.close), {
closeAction?.invoke()
}, UIDialogs.ActionStyle.NONE)
);
@@ -226,15 +291,15 @@ class UIDialogs {
}
fun showDataRetryDialog(context: Context, reason: String? = null, retryAction: (() -> Unit)? = null, closeAction: (() -> Unit)? = null) {
val retryButtonAction = Action("Retry", retryAction ?: {}, ActionStyle.PRIMARY)
val closeButtonAction = Action("Close", closeAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_no_internet_86dp, "Data Retry", reason, null, 0, closeButtonAction, retryButtonAction)
val retryButtonAction = Action(context.getString(R.string.retry), retryAction ?: {}, ActionStyle.PRIMARY)
val closeButtonAction = Action(context.getString(R.string.close), closeAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_no_internet_86dp, context.getString(R.string.data_retry), reason, null, 0, closeButtonAction, retryButtonAction)
}
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
val confirmButtonAction = Action("Confirm", action, ActionStyle.PRIMARY)
val cancelButtonAction = Action("Cancel", cancelAction ?: {}, ActionStyle.ACCENT)
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
}
@@ -279,23 +344,46 @@ class UIDialogs {
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showImportOptionsDialog(context: MainActivity) {
val dialog = ImportOptionsDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showCastingDialog(context: Context) {
val d = StateCasting.instance.activeDevice;
if (d != null) {
val dialog = ConnectedCastingDialog(context);
if (context is Activity) {
dialog.setOwnerActivity(context)
}
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
} else {
val dialog = ConnectCastingDialog(context);
if (context is Activity) {
dialog.setOwnerActivity(context)
}
registerDialogOpened(dialog);
val c = context
if (c is Activity) {
dialog.setOwnerActivity(c);
}
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
}
fun showCastingTutorialDialog(context: Context) {
val dialog = CastingHelpDialog(context);
registerDialogOpened(dialog);
dialog.setOnDismissListener { registerDialogClosed(dialog) };
dialog.show();
}
fun showCastingAddDialog(context: Context) {
val dialog = CastingAddDialog(context);
registerDialogOpened(dialog);
@@ -1,41 +1,63 @@
package com.futo.platformplayer
import android.app.Activity
import android.app.NotificationManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.view.View
import android.view.ViewGroup
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.*
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuFilters
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
import com.futo.platformplayer.views.pills.RoundButton
import com.futo.platformplayer.views.pills.RoundButtonGroup
import com.futo.platformplayer.views.overlays.slideup.*
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import isDownloadable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
class UISlideOverlays {
companion object {
private const val TAG = "UISlideOverlays";
fun showOverlay(container: ViewGroup, title: String, okButton: String?, onOk: ()->Unit, vararg views: View) {
fun showOverlay(container: ViewGroup, title: String, okButton: String?, onOk: ()->Unit, vararg views: View): SlideUpMenuOverlay {
var menu = SlideUpMenuOverlay(container.context, container, title, okButton, true, *views);
menu.onOK.subscribe {
@@ -43,6 +65,214 @@ class UISlideOverlays {
onOk.invoke();
};
menu.show();
return menu;
}
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
val items = arrayListOf<View>();
val originalNotif = subscription.doNotifications;
val originalLive = subscription.doFetchLive;
val originalStream = subscription.doFetchStreams;
val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts;
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
val capabilities = plugin.getChannelCapabilities();
withContext(Dispatchers.Main) {
var menu: SlideUpMenuOverlay? = null;
items.addAll(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false),
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_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/*,,
SlideUpMenuGroup(container.context, "Actions",
"Various things you can do with this subscription",
-1, listOf())
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
showCreateSubscriptionGroup(container, subscription.channel);
}, false)*/
).filterNotNull());
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
if(subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
if(subscription.doNotifications && !originalNotif) {
val mainContext = StateApp.instance.contextOrNull;
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
if(mainContext is MainActivity) {
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
"You need to set a Background Updating interval for notifications", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Configure", {
val intent = Intent(mainContext, SettingsActivity::class.java);
intent.putExtra("query", mainContext.getString(R.string.background_update));
mainContext.startActivity(intent);
}, UIDialogs.ActionStyle.PRIMARY));
}
return@subscribe;
}
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
UIDialogs.toast(container.context, "Android notifications are disabled");
if(mainContext is MainActivity) {
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
}
}
}
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
menu.setOk("Save");
menu.show();
}
}
}
fun showAddToGroupOverlay(channel: IPlatformVideo, container: ViewGroup) {
}
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty")
val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles
//val subtitleButtons = arrayListOf<SlideUpMenuItem>()
var selectedVideoVariant: HLSVariantVideoUrlSource? = null
var selectedAudioVariant: HLSVariantAudioUrlSource? = null
//TODO: Implement subtitles
//var selectedSubtitleVariant: HLSVariantSubtitleUrlSource? = null
val masterPlaylist: HLS.MasterPlaylist
try {
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))
}
/*masterPlaylist.getSubtitleSources().forEach { it ->
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
selectedSubtitleVariant = it
slideUpMenuOverlay.selectOption(subtitleButtons, it)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}, false))
}*/
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 newItems = arrayListOf<View>()
if (videoButtons.isNotEmpty()) {
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoButtons, videoButtons))
}
if (audioButtons.isNotEmpty()) {
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioButtons, audioButtons))
}
//TODO: Implement subtitles
/*if (subtitleButtons.isNotEmpty()) {
newItems.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleButtons, subtitleButtons))
}*/
slideUpMenuOverlay.onOK.subscribe {
//TODO: Fix SubtitleRawSource issue
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
slideUpMenuOverlay.hide()
}
withContext(Dispatchers.Main) {
slideUpMenuOverlay.setItems(newItems)
}
} catch (e: Throwable) {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, sourceUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide()
} else {
throw NotImplementedError()
}
}
} else {
throw e
}
}
}
return slideUpMenuOverlay.apply { show() }
}
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
@@ -63,70 +293,93 @@ class UISlideOverlays {
val audioSources = if(descriptor is VideoUnMuxedSourceDescriptor) descriptor.audioSources else null;
val subtitleSources = video.subtitles;
if(videoSources.size == 0 && (audioSources?.size ?: 0) == 0) {
UIDialogs.toast("No downloads available", false);
if(videoSources.isEmpty() && (audioSources?.size ?: 0) == 0) {
UIDialogs.toast(container.context.getString(R.string.no_downloads_available), false);
return null;
}
if(!VideoHelper.isDownloadable(video)) {
Logger.i(TAG, "Attempted to open downloads without valid sources for [${video.name}]: ${video.url}");
UIDialogs.toast( "No downloadable sources (yet)");
UIDialogs.toast( container.context.getString(R.string.no_downloadable_sources_yet));
return null;
}
items.add(SlideUpMenuGroup(container.context, "Video", videoSources,
listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, "None", "Audio Only", "none", {
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("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false)) +
videoSources
.filter { it.isDownloadable() }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
selectedVideo = it as IVideoUrlSource;
menu?.selectOption(videoSources, it);
if(selectedAudio != null || !requiresAudio)
menu?.setOk("Download");
}, false)
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)
}
is IHLSManifestSource -> {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
showHlsPicker(video, it, it.url, container)
}, false)
}
else -> {
throw Exception("Unhandled source type")
}
}
}).flatten().toList()
));
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.size > 0)
selectedVideo = VideoHelper.selectBestVideoSource(videoSources.filter { it.isDownloadable() }.asIterable(),
if(Settings.instance.downloads.getDefaultVideoQualityPixels() > 0 && videoSources.isNotEmpty()) {
//TODO: Add HLS support here
selectedVideo = VideoHelper.selectBestVideoSource(
videoSources.filter { it is IVideoUrlSource && it.isDownloadable() }.asIterable(),
Settings.instance.downloads.getDefaultVideoQualityPixels(),
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) as IVideoUrlSource;
FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS
) as IVideoUrlSource;
}
audioSources?.let { audioSources ->
items.add(SlideUpMenuGroup(container.context, "Audio", audioSources, audioSources
if (audioSources != null) {
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.audio), audioSources, audioSources
.filter { VideoHelper.isDownloadable(it) }
.map {
SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
selectedAudio = it as IAudioUrlSource;
menu?.selectOption(audioSources, it);
menu?.setOk("Download");
}, false);
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);
}
is IHLSManifestAudioSource -> {
SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
showHlsPicker(video, it, it.url, container)
}, false)
}
else -> {
throw Exception("Unhandled source type")
}
}
}));
val asources = audioSources;
val preferredAudioSource = VideoHelper.selectBestAudioSource(asources.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 99999999 else 1);
menu?.selectOption(asources, preferredAudioSource);
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it.isDownloadable() }.asIterable(),
//TODO: Add HLS support here
selectedAudio = VideoHelper.selectBestAudioSource(audioSources.filter { it is IAudioUrlSource && it.isDownloadable() }.asIterable(),
FutoVideoPlayerBase.PREFERED_AUDIO_CONTAINERS,
Settings.instance.playback.getPrimaryLanguage(container.context),
if(Settings.instance.downloads.isHighBitrateDefault()) 9999999 else 1) as IAudioUrlSource?;
}
//ContentResolver is required for subtitles..
if(contentResolver != null) {
items.add(SlideUpMenuGroup(container.context, "Subtitles", subtitleSources, subtitleSources
.map {
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;
@@ -136,10 +389,11 @@ class UISlideOverlays {
menu?.selectOption(subtitleSources, it);
}
}, false);
}));
})
);
}
menu = SlideUpMenuOverlay(container.context, container, "Download Video", null, true, items);
menu = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items);
if(selectedVideo != null) {
menu.selectOption(videoSources, selectedVideo);
@@ -148,7 +402,7 @@ class UISlideOverlays {
audioSources?.let { audioSources -> menu.selectOption(audioSources, selectedAudio); };
}
if(selectedAudio != null || (!requiresAudio && selectedVideo != null)) {
menu.setOk("Download");
menu.setOk(container.context.getString(R.string.download));
}
menu.onOK.subscribe {
@@ -185,7 +439,7 @@ class UISlideOverlays {
}
fun showDownloadVideoOverlay(video: IPlatformVideo, container: ViewGroup, useDetails: Boolean = false) {
val handleUnknownDownload: ()->Unit = {
showUnknownVideoDownload("Video", container) { px, bitrate ->
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(video, px, bitrate)
};
};
@@ -195,7 +449,7 @@ class UISlideOverlays {
val scope = StateApp.instance.scopeOrNull;
if(scope != null) {
val loader = showLoaderOverlay("Fetching video details", container);
val loader = showLoaderOverlay(container.context.getString(R.string.fetching_video_details), container);
scope.launch(Dispatchers.IO) {
try {
val videoDetails = StatePlatform.instance.getContentDetails(video.url, false).await();
@@ -209,7 +463,7 @@ class UISlideOverlays {
}
catch(ex: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast("Failed to fetch details for download");
UIDialogs.toast(container.context.getString(R.string.failed_to_fetch_details_for_download));
handleUnknownDownload();
loader.hide(true);
}
@@ -220,7 +474,7 @@ class UISlideOverlays {
}
}
fun showDownloadPlaylistOverlay(playlist: Playlist, container: ViewGroup) {
showUnknownVideoDownload("Video", container) { px, bitrate ->
showUnknownVideoDownload(container.context.getString(R.string.video), container) { px, bitrate ->
StateDownloads.instance.download(playlist, px, bitrate);
};
}
@@ -232,7 +486,7 @@ class UISlideOverlays {
var targetBitrate: Long = 0;
val resolutions = listOf(
Triple<String, String, Long>("None", "None", -1),
Triple<String, String, Long>(container.context.getString(R.string.none), container.context.getString(R.string.none), -1),
Triple<String, String, Long>("480P", "720x480", 720*480),
Triple<String, String, Long>("720P", "1280x720", 1280*720),
Triple<String, String, Long>("1080P", "1920x1080", 1920*1080),
@@ -240,23 +494,23 @@ class UISlideOverlays {
Triple<String, String, Long>("2160P", "3840x2160", 3840*2160)
);
items.add(SlideUpMenuGroup(container.context, "Target Resolution", "Video", resolutions.map {
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)
}));
items.add(SlideUpMenuGroup(container.context, "Target Bitrate", "Bitrate", listOf(
SlideUpMenuItem(container.context, R.drawable.ic_movie, "Low Bitrate", "", 1, {
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("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_movie, "High Bitrate", "", 9999999, {
SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
targetBitrate = 9999999;
menu?.selectOption("Bitrate", 9999999);
menu?.setOk("Download");
menu?.setOk(container.context.getString(R.string.download));
}, false)
)));
@@ -277,12 +531,12 @@ class UISlideOverlays {
if(Settings.instance.downloads.isHighBitrateDefault()) {
targetBitrate = 9999999;
menu.selectOption("Bitrate", 9999999);
menu.setOk("Download");
menu.setOk(container.context.getString(R.string.download));
}
else {
targetBitrate = 1;
menu.selectOption("Bitrate", 1);
menu.setOk("Download");
menu.setOk(container.context.getString(R.string.download));
}
menu.onOK.subscribe {
@@ -296,7 +550,7 @@ class UISlideOverlays {
val dp70 = 70.dp(container.context.resources);
val dp15 = 15.dp(container.context.resources);
val overlay = SlideUpMenuOverlay(container.context, container, text, null, true, listOf(
Loader(container.context, true, dp70).apply {
LoaderView(container.context, true, dp70).apply {
this.setPadding(0, dp15, 0, dp15);
}
), true);
@@ -304,14 +558,83 @@ class UISlideOverlays {
return overlay;
}
fun showCreateSubscriptionGroup(container: ViewGroup, initialChannel: IPlatformChannel? = null, onCreate: ((String) -> Unit)? = null): SlideUpMenuOverlay {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addSubGroupOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_subgroup), container.context.getString(R.string.ok), false, nameInput);
addSubGroupOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
addSubGroupOverlay.hide();
nameInput.deactivate();
nameInput.clear();
if(onCreate == null)
{
//TODO: Do this better, temp
StateApp.instance.contextOrNull?.let {
if(it is MainActivity) {
val subGroup = SubscriptionGroup(text);
if(initialChannel != null) {
subGroup.urls.add(initialChannel.url);
if(initialChannel.thumbnail != null)
subGroup.image = ImageVariable(initialChannel.thumbnail);
}
it.navigate(it.getFragment<SubscriptionGroupFragment>(), subGroup);
}
}
}
else
onCreate(text)
};
addSubGroupOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addSubGroupOverlay.show();
nameInput.activate();
return addSubGroupOverlay
}
fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
onCreate(text)
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
return addPlaylistOverlay
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "",
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), "",
{
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -322,23 +645,35 @@ class UISlideOverlays {
val allPlaylists = StatePlaylists.instance.getPlaylists();
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, "Actions", "actions",
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, "Download", "Download the video", "download",
{ showDownloadVideoOverlay(video, container, true); }, false))
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_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");
}))
+ actions)
));
items.add(
SlideUpMenuGroup(container.context, "Add To", "addto",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Add to Queue", "${queue.size} videos", "queue",
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, "Add to " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} videos", "watch later",
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)); })
));
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))
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "Add to " + playlist.name + "", "${playlist.videos.size} 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), "",
{
StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -346,9 +681,9 @@ class UISlideOverlays {
}
if(playlistItems.size > 0)
items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems));
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.playlists), "", playlistItems));
return SlideUpMenuOverlay(container.context, container, "Video Options", null, true, items).apply { show() };
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.video_options), null, true, items).apply { show() };
}
@@ -360,8 +695,8 @@ class UISlideOverlays {
if (lastUpdated != null) {
items.add(
SlideUpMenuGroup(container.context, "Recently Used Playlist", "recentlyusedplaylist",
SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} videos", "",
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), "",
{
StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -373,18 +708,18 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater();
items.add(
SlideUpMenuGroup(container.context, "Other", "other",
SlideUpMenuItem(container.context, R.drawable.ic_queue_add, "Queue", "${queue.size} videos", "queue",
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} videos", "watch later",
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, "Download", "Download the video", "download",
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))
);
val playlistItems = arrayListOf<SlideUpMenuItem>();
for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} videos", "",
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{
StatePlaylists.instance.addToPlaylist(playlist.id, video);
StateDownloads.instance.checkForOutdatedPlaylists();
@@ -392,9 +727,9 @@ class UISlideOverlays {
}
if(playlistItems.size > 0)
items.add(SlideUpMenuGroup(container.context, "Playlists", "", playlistItems));
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.playlists), "", playlistItems));
return SlideUpMenuOverlay(container.context, container, "Add to", null, true, items).apply { show() };
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
}
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
@@ -404,16 +739,17 @@ class UISlideOverlays {
}
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
fun showMoreButtonOverlay(container: ViewGroup, buttonGroup: RoundButtonGroup, ignoreTags: List<Any> = listOf(), invokeParents: Boolean = true, onPinnedbuttons: ((List<RoundButton>)->Unit)? = null): SlideUpMenuOverlay {
val visible = buttonGroup.getVisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
val hidden = buttonGroup.getInvisibleButtons().filter { !ignoreTags.contains(it.tagRef) };
val views = arrayOf(hidden
.map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
btn.handler?.invoke(btn);
}, true) as View }.toTypedArray() ?: arrayOf(),
arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, "Change Pins", "Decide which buttons should be pinned", "", {
showOrderOverlay(container, "Select your pins in order", (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
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 }
@@ -425,7 +761,7 @@ class UISlideOverlays {
}, false))
).flatten().toTypedArray();
return SlideUpMenuOverlay(container.context, container, "More Options", null, true, *views).apply { show() };
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
}
fun showOrderOverlay(container: ViewGroup, title: String, options: List<Pair<String, Any>>, onOrdered: (List<Any>)->Unit) {
@@ -433,7 +769,7 @@ class UISlideOverlays {
var overlay: SlideUpMenuOverlay? = null;
overlay = SlideUpMenuOverlay(container.context, container, title, "Save", true,
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, {
if(overlay!!.selectOption(null, it.second, true, true)) {
if(!selection.contains(it.second))
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
}
}
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
fun String.isHttpUrl(): Boolean {
return _regexUrl.matchEntire(this) != null;
}
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
fun String.isHexColor(): Boolean {
return _regexHexColor.matches(this);
@@ -137,6 +143,7 @@ fun InputStream.copyToOutputStream(inputStreamLength: Long, outputStream: Output
}
}
@Suppress("DEPRECATION")
fun Activity.setNavigationBarColorAndIcons() {
window.navigationBarColor = ContextCompat.getColor(this, android.R.color.black);
@@ -158,9 +165,7 @@ fun Int.sp(resources: Resources): Int {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
}
fun File.share(context: Context) {
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
fun DocumentFile.share(context: Context) {
val shareIntent = Intent();
shareIntent.action = Intent.ACTION_SEND;
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -1,16 +1,24 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.os.Bundle
import android.view.View
import android.widget.*
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
@@ -45,6 +53,10 @@ class AddSourceActivity : AppCompatActivity() {
private var _config: SourcePluginConfig? = null;
private var _script: String? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
@@ -96,8 +108,8 @@ class AddSourceActivity : AppCompatActivity() {
var url = intent?.dataString;
if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, "No valid URL provided..", null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
else {
if(url.startsWith("vfuto://"))
url = "https://" + url.substring("vfuto://".length);
@@ -129,14 +141,14 @@ class AddSourceActivity : AppCompatActivity() {
Logger.e(TAG, "Failed decode config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showDialog(this@AddSourceActivity, R.drawable.ic_error,
"Invalid Config Format", null, null,
getString(R.string.invalid_config_format), null, null,
0, UIDialogs.Action("Ok", { finish() }, UIDialogs.ActionStyle.PRIMARY));
};
return@launch;
} catch(ex: Exception) {
Logger.e(TAG, "Failed fetch config", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch configuration", ex);
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_configuration), ex);
};
return@launch;
}
@@ -152,7 +164,7 @@ class AddSourceActivity : AppCompatActivity() {
} catch (ex: Exception) {
Logger.e(TAG, "Failed fetch script", ex);
withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, "Failed to fetch script", ex);
UIDialogs.showGeneralErrorDialog(this@AddSourceActivity, getString(R.string.failed_to_fetch_script), ex);
};
return@launch;
}
@@ -175,8 +187,8 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView(
SourceInfoView(this,
R.drawable.ic_language,
"URL Access",
"The plugin will have access to the following domains",
getString(R.string.url_access),
getString(R.string.the_plugin_will_have_access_to_the_following_domains),
config.allowUrls, true)
)
@@ -184,12 +196,12 @@ class AddSourceActivity : AppCompatActivity() {
_sourcePermissions.addView(
SourceInfoView(this,
R.drawable.ic_code,
"Eval Access",
"The plugin will have access to eval capability (remote injection)",
getString(R.string.eval_access),
getString(R.string.the_plugin_will_have_access_to_eval_capability_remote_injection),
config.allowUrls, true)
)
val pastelRed = resources.getColor(R.color.pastel_red);
val pastelRed = ContextCompat.getColor(this, R.color.pastel_red);
for(warning in config.getWarnings(script))
_sourceWarnings.addView(
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -7,22 +8,23 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
class AddSourceOptionsActivity : AppCompatActivity() {
lateinit var _buttonBack: ImageButton;
lateinit var _buttonQR: BigButton;
lateinit var _buttonURL: BigButton;
lateinit var _buttonPlugins: BigButton;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, "Failed to scan QR code")
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let
}
@@ -31,7 +33,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} else if (content.startsWith("grayjay://plugin/")) {
content.substring("grayjay://plugin/".length)
} else {
UIDialogs.toast(this, "Not a plugin URL")
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
return@let;
}
@@ -42,6 +44,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options);
@@ -51,6 +57,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR = findViewById(R.id.option_qr);
_buttonURL = findViewById(R.id.option_url);
_buttonPlugins = findViewById(R.id.option_plugins);
_buttonBack.setOnClickListener {
finish();
@@ -59,7 +66,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
_buttonQR.onClick.subscribe {
val integrator = IntentIntegrator(this);
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR Code")
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
@@ -69,12 +76,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
}
_buttonURL.onClick.subscribe {
UIDialogs.toast(this, "Not implemented yet..");
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
}
}
class QRCaptureActivity: CaptureActivity() {
}
}
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha);
@@ -1,17 +1,24 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.*
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperActivity : AppCompatActivity() {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
fun getField(id: String): IField? {
return _form.findField(id);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
DeveloperActivity._lastActivity = this;
setContentView(R.layout.activity_dev);
setNavigationBarColorAndIcons();
@@ -19,7 +26,7 @@ class DeveloperActivity : AppCompatActivity() {
_form = findViewById(R.id.settings_form);
_form.fromObject(SettingsDev.instance);
_form.onChanged.subscribe { field, value ->
_form.onChanged.subscribe { _, _ ->
_form.setObjectValues();
SettingsDev.instance.save();
};
@@ -33,4 +40,19 @@ class DeveloperActivity : AppCompatActivity() {
super.finish()
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
private var _lastActivity: DeveloperActivity? = null;
fun getActivity(): DeveloperActivity? {
val act = _lastActivity;
if(act != null)
return act;
return null;
}
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
private var _file: File? = null;
private var _submitted = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_exception);
@@ -38,8 +44,8 @@ class ExceptionActivity : AppCompatActivity() {
_buttonRestart = findViewById(R.id.button_restart);
_buttonClose = findViewById(R.id.button_close);
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: "Unknown Context";
val stack = intent.getStringExtra(EXTRA_STACK) ?: "Something went wrong... missing stack trace?";
val context = intent.getStringExtra(EXTRA_CONTEXT) ?: getString(R.string.unknown_context);
val stack = intent.getStringExtra(EXTRA_STACK) ?: getString(R.string.something_went_wrong_missing_stack_trace);
val exceptionString = "Version information (version_name = ${BuildConfig.VERSION_NAME}, version_code = ${BuildConfig.VERSION_CODE}, flavor = ${BuildConfig.FLAVOR}, build_type = ${BuildConfig.BUILD_TYPE})\n" +
"Device information (brand= ${Build.BRAND}, manufacturer = ${Build.MANUFACTURER}, device = ${Build.DEVICE}, version-sdk = ${Build.VERSION.SDK_INT}, version-os = ${Build.VERSION.BASE_OS})\n\n" +
@@ -79,13 +85,13 @@ class ExceptionActivity : AppCompatActivity() {
private fun submitFile() {
if (_submitted) {
Toast.makeText(this, "Logs already submitted.", Toast.LENGTH_LONG).show();
Toast.makeText(this, getString(R.string.logs_already_submitted), Toast.LENGTH_LONG).show();
return;
}
val file = _file;
if (file == null) {
Toast.makeText(this, "No logs found.", Toast.LENGTH_LONG).show();
Toast.makeText(this, getString(R.string.no_logs_found), Toast.LENGTH_LONG).show();
return;
}
@@ -101,14 +107,14 @@ class ExceptionActivity : AppCompatActivity() {
withContext(Dispatchers.Main) {
if (id == null) {
try {
Toast.makeText(this@ExceptionActivity, "Failed automated share, share manually?", Toast.LENGTH_LONG).show();
Toast.makeText(this@ExceptionActivity, getString(R.string.failed_automated_share_share_manually), Toast.LENGTH_LONG).show();
} catch (e: Throwable) {
//Ignored
}
} else {
_submitted = true;
file.delete();
Toast.makeText(this@ExceptionActivity, "Shared $id", Toast.LENGTH_LONG).show();
Toast.makeText(this@ExceptionActivity, getString(R.string.shared_id).replace("{id}", id), Toast.LENGTH_LONG).show();
}
}
}
@@ -119,10 +125,10 @@ class ExceptionActivity : AppCompatActivity() {
val i = Intent(Intent.ACTION_SEND);
i.type = "text/plain";
i.putExtra(Intent.EXTRA_EMAIL, arrayOf("grayjay@futo.org"));
i.putExtra(Intent.EXTRA_SUBJECT, "Unhandled exception in VS");
i.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.unhandled_exception_in_vs));
i.putExtra(Intent.EXTRA_TEXT, exceptionString);
startActivity(Intent.createChooser(i, "Send exception to developers..."));
startActivity(Intent.createChooser(i, getString(R.string.send_exception_to_developers)));
} catch (e: Throwable) {
//Ignored
@@ -0,0 +1,108 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Html
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dialogs.CastingHelpDialog
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
class FCastGuideActivity : AppCompatActivity() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_fcast_guide);
setNavigationBarColorAndIcons();
findViewById<TextView>(R.id.text_explanation).apply {
val guideText = """
<h3>1. Install FCast Receiver:</h3>
<p>- Open Play Store, FireStore, or FCast website on your TV/desktop.<br>
- Search for "FCast Receiver", install and open it.</p>
<br>
<h3>2. Prepare the Grayjay App:</h3>
<p>- Ensure it's connected to the same network as the FCast Receiver.</p>
<br>
<h3>3. Initiate Casting from Grayjay:</h3>
<p>- Click the cast button in Grayjay.</p>
<br>
<h3>4. Connect to FCast Receiver:</h3>
<p>- Wait for your device to show in the list or add it manually with its IP address.</p>
<br>
<h3>5. Confirm Connection:</h3>
<p>- Click "OK" to confirm your device selection.</p>
<br>
<h3>6. Start Casting:</h3>
<p>- Press "start" next to the device you've added.</p>
<br>
<h3>7. Play Your Video:</h3>
<p>- Start any video in Grayjay to cast.</p>
<br>
<h3>Finding Your IP Address:</h3>
<p><b>On FCast Receiver (Android):</b> Displayed on the main screen.<br>
<b>On Windows:</b> Use 'ipconfig' in Command Prompt.<br>
<b>On Linux:</b> Use 'hostname -I' or 'ip addr' in Terminal.<br>
<b>On MacOS:</b> System Preferences > Network.</p>
""".trimIndent()
text = Html.fromHtml(guideText, Html.FROM_HTML_MODE_COMPACT)
}
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
UIDialogs.showCastingTutorialDialog(this)
finish()
}
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
UIDialogs.showCastingTutorialDialog(this)
finish()
}
findViewById<BigButton>(R.id.button_website).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://fcast.org/"))
startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
findViewById<BigButton>(R.id.button_technical).onClick.subscribe {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1"))
startActivity(browserIntent);
} catch (e: Throwable) {
Logger.i(TAG, "Failed to open browser.", e)
}
}
}
override fun onBackPressed() {
UIDialogs.showCastingTutorialDialog(this)
finish()
}
companion object {
private const val TAG = "FCastGuideActivity";
}
}
@@ -2,7 +2,6 @@ package com.futo.platformplayer.activities
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
interface IWithResultLauncher {
fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit);
@@ -3,33 +3,46 @@ package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class LoginActivity : AppCompatActivity() {
private lateinit var _webView: WebView;
private lateinit var _textUrl: TextView;
private lateinit var _buttonClose: ImageButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
setNavigationBarColorAndIcons();
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
finish();
}
_webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true);
@@ -60,6 +73,8 @@ class LoginActivity : AppCompatActivity() {
};
var isFirstLoad = true;
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
@@ -85,7 +100,7 @@ class LoginActivity : AppCompatActivity() {
override fun finish() {
lifecycleScope.launch(Dispatchers.Main) {
_webView?.loadUrl("about:blank");
_webView.loadUrl("about:blank");
}
_callback?.let {
_callback = null;
@@ -1,12 +1,16 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.widget.FrameLayout
@@ -15,6 +19,8 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -23,11 +29,8 @@ import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
@@ -43,8 +46,8 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.SubscriptionStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.google.gson.JsonParser
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.PrintWriter
@@ -88,9 +91,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
lateinit var _fragMainSuggestions: SuggestionsFragment;
lateinit var _fragMainSubscriptions: CreatorsFragment;
lateinit var _fragMainComments: CommentsFragment;
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment;
lateinit var _fragMainTutorial: TutorialFragment;
lateinit var _fragMainPlaylists: PlaylistsFragment;
lateinit var _fragMainPlaylist: PlaylistFragment;
lateinit var _fragWatchlist: WatchLaterFragment;
@@ -100,6 +105,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
lateinit var _fragImportPlaylists: ImportPlaylistsFragment;
lateinit var _fragBuy: BuyFragment;
lateinit var _fragSubGroup: SubscriptionGroupFragment;
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
lateinit var _fragBrowser: BrowserFragment;
@@ -121,6 +128,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
private var _isVisible = true;
private var _wasStopped = false;
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
scanResult?.let {
val content = it.contents
if (content == null) {
UIDialogs.toast(this, getString(R.string.failed_to_scan_qr_code))
return@let
}
try {
handleUrlAll(content)
} catch (e: Throwable) {
Logger.i(TAG, "Failed to handle URL.", e)
UIDialogs.toast(this, "Failed to handle URL: ${e.message}")
}
}
}
constructor() : super() {
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
val writer = StringWriter();
@@ -154,6 +179,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
override fun attachBaseContext(newBase: Context?) {
Logger.i(TAG, "MainActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
StateApp.instance.setGlobalContext(this, lifecycleScope);
StateApp.instance.mainAppStarting(this);
@@ -193,11 +223,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Main
_fragMainHome = HomeFragment.newInstance();
_fragMainTutorial = TutorialFragment.newInstance()
_fragMainSuggestions = SuggestionsFragment.newInstance();
_fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance();
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
_fragMainPlaylistSearchResults = PlaylistSearchResultsFragment.newInstance();
_fragMainSubscriptions = CreatorsFragment.newInstance();
_fragMainComments = CommentsFragment.newInstance();
_fragMainChannel = ChannelFragment.newInstance();
_fragMainSubscriptionsFeed = SubscriptionsFeedFragment.newInstance();
_fragMainSources = SourcesFragment.newInstance();
@@ -211,6 +243,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
_fragImportPlaylists = ImportPlaylistsFragment.newInstance();
_fragBuy = BuyFragment.newInstance();
_fragSubGroup = SubscriptionGroupFragment.newInstance();
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance();
@@ -275,11 +309,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Set top bars
_fragMainHome.topBar = _fragTopBarGeneral;
_fragMainSubscriptions.topBar = _fragTopBarGeneral;
_fragMainComments.topBar = _fragTopBarGeneral;
_fragMainSuggestions.topBar = _fragTopBarSearch;
_fragMainVideoSearchResults.topBar = _fragTopBarSearch;
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
_fragMainPlaylistSearchResults.topBar = _fragTopBarSearch;
_fragMainChannel.topBar = _fragTopBarNavigation;
_fragMainTutorial.topBar = _fragTopBarNavigation;
_fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral;
_fragMainSources.topBar = _fragTopBarAdd;
_fragMainPlaylists.topBar = _fragTopBarGeneral;
@@ -291,9 +327,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragDownloads.topBar = _fragTopBarGeneral;
_fragImportSubscriptions.topBar = _fragTopBarImport;
_fragImportPlaylists.topBar = _fragTopBarImport;
_fragSubGroupList.topBar = _fragTopBarAdd;
_fragBrowser.topBar = _fragTopBarNavigation;
fragCurrent = _fragMainHome;
val defaultTab = Settings.instance.tabs.mapNotNull {
@@ -321,6 +358,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fragCurrent.onOrientationChanged(it);
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
_fragVideoDetail.onOrientationChanged(it);
else if(Settings.instance.other.bypassRotationPrevention)
{
requestedOrientation = when(orientation) {
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
}
};
_orientationManager.enable();
@@ -366,6 +412,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateApp.instance.mainAppStartedWithExternalFiles(this);
//startActivity(Intent(this, TestActivity::class.java));
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
if (isFirstBoot) {
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
navigate(_fragMainTutorial)
})
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
}
@@ -390,6 +446,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
}*/
fun showUrlQrCodeScanner() {
try {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
integrator.setBarcodeImageEnabled(true)
integrator.captureActivity = QRCaptureActivity::class.java
_urlQrCodeResultLauncher.launch(integrator.createScanIntent())
} catch (e: Throwable) {
Logger.i(TAG, "Failed to handle show QR scanner.", e)
UIDialogs.toast(this, "Failed to show QR scanner: ${e.message}")
}
}
override fun onResume() {
super.onResume();
Logger.v(TAG, "onResume")
@@ -459,6 +532,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "View Received: " + targetData);
}
}
"VIDEO" -> {
val url = intent.getStringExtra("VIDEO");
navigate(_fragVideoDetail, url);
}
"IMPORT_OPTIONS" -> {
UIDialogs.showImportOptionsDialog(this);
}
"TAB" -> {
when(intent.getStringExtra("TAB")){
"Sources" -> {
@@ -473,72 +553,95 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
try {
if (targetData != null) {
when(intent.scheme) {
"grayjay" -> {
if(targetData.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(targetData))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, "Your license key has been set!\nAn app restart might be required.");
if(fragCurrent is BuyFragment)
closeSegment(fragCurrent);
}
else
UIDialogs.toast("Invalid license format");
}
else if(targetData.startsWith("grayjay://plugin/")) {
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(targetData.substring("grayjay://plugin/".length));
};
startActivity(intent);
}
}
"content" -> {
if(!handleContent(targetData, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown content format [${targetData}]",
"Ok",
{ });
}
}
"file" -> {
if(!handleFile(targetData)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown file format [${targetData}]",
"Ok",
{ });
}
}
"polycentric" -> {
if(!handlePolycentric(targetData)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown Polycentric format [${targetData}]",
"Ok",
{ });
}
}
else -> {
if (!handleUrl(targetData)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
"Unknown url format [${targetData}]",
"Ok",
{ });
}
}
}
handleUrlAll(targetData)
}
}
catch(ex: Throwable) {
UIDialogs.showGeneralErrorDialog(this, "Failed to handle file", ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_handle_file), ex);
}
}
fun handleUrlAll(url: String) {
val uri = Uri.parse(url)
when (uri.scheme) {
"grayjay" -> {
if(url.startsWith("grayjay://license/")) {
if(StatePayment.instance.setPaymentLicenseUrl(url))
{
UIDialogs.showDialogOk(this, R.drawable.ic_check, getString(R.string.your_license_key_has_been_set_an_app_restart_might_be_required));
if(fragCurrent is BuyFragment)
closeSegment(fragCurrent);
}
else
UIDialogs.toast(getString(R.string.invalid_license_format));
}
else if(url.startsWith("grayjay://plugin/")) {
val intent = Intent(this, AddSourceActivity::class.java).apply {
data = Uri.parse(url.substring("grayjay://plugin/".length));
};
startActivity(intent);
}
else if(url.startsWith("grayjay://video/")) {
val videoUrl = url.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(url.startsWith("grayjay://channel/")) {
val channelUrl = url.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
}
"content" -> {
if(!handleContent(url, intent.type)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_content_format) + " [${url}]",
"Ok",
{ });
}
}
"file" -> {
if(!handleFile(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_file_format) + " [${url}]",
"Ok",
{ });
}
}
"polycentric" -> {
if(!handlePolycentric(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_polycentric_format) + " [${url}]",
"Ok",
{ });
}
}
"fcast" -> {
if(!handleFCast(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_cast,
"Unknown FCast format [${url}]",
"Ok",
{ });
}
}
else -> {
if (!handleUrl(url)) {
UIDialogs.showSingleButtonDialog(
this,
R.drawable.ic_play,
getString(R.string.unknown_url_format) + " [${url}]",
"Ok",
{ });
}
}
}
}
@@ -567,7 +670,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(file.lowercase().endsWith(".json") || mime == "application/json") {
var recon = String(data);
if(!recon.trim().startsWith("["))
return handleUnknownJson(file, recon);
return handleUnknownJson(recon);
val reconLines = Json.decodeFromString<List<String>>(recon);
recon = reconLines.joinToString("\n");
@@ -579,6 +682,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, data);
return true;
}
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false;
}
fun handleFile(file: String): Boolean {
@@ -586,7 +692,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if(file.lowercase().endsWith(".json")) {
val recon = String(readSharedFile(file));
if(!recon.startsWith("["))
return handleUnknownJson(file, recon);
return handleUnknownJson(recon);
Logger.i(TAG, "Opened shared playlist reconstruction\n${recon}");
handleReconstruction(recon);
@@ -596,6 +702,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true;
}
else if(file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false;
}
fun handleReconstruction(recon: String) {
@@ -603,7 +712,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val store: ManagedStore<*> = when(type) {
"Playlist" -> StatePlaylists.instance.playlistStore
else -> {
UIDialogs.toast("Unknown reconstruction type ${type}", false);
UIDialogs.toast(getString(R.string.unknown_reconstruction_type) + " ${type}", false);
return;
};
};
@@ -621,7 +730,21 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
fun handleUnknownJson(name: String?, json: String): Boolean {
fun handleUnknownText(text: String): Boolean {
try {
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
return true;
}
}
catch(ex: Throwable) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(json: String): Boolean {
val context = this;
@@ -631,22 +754,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (!newPipeSubsParsed.has("subscriptions") || !newPipeSubsParsed["subscriptions"].isJsonArray)
return false;//throw IllegalArgumentException("Invalid NewPipe json structure found");
val jsonSubs = newPipeSubsParsed["subscriptions"]
val jsonSubsArray = jsonSubs.asJsonArray;
val jsonSubsArrayItt = jsonSubsArray.iterator();
val subs = mutableListOf<String>()
while(jsonSubsArrayItt.hasNext()) {
val jsonSubObj = jsonSubsArrayItt.next().asJsonObject;
if(jsonSubObj.has("url"))
subs.add(jsonSubObj["url"].asString);
}
navigate(_fragImportSubscriptions, subs);
StateBackup.importNewPipeSubs(this, newPipeSubsParsed);
}
catch(ex: Exception) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(context, "Failed to parse NewPipe Subscriptions", ex);
UIDialogs.showGeneralErrorDialog(context, getString(R.string.failed_to_parse_newpipe_subscriptions), ex);
}
/*
@@ -668,6 +780,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
startActivity(Intent(this, PolycentricImportProfileActivity::class.java).apply { putExtra("url", url) })
return true;
}
fun handleFCast(url: String): Boolean {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(this, url)
return true;
} catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
}
return false
}
private fun readSharedContent(contentPath: String): ByteArray {
return contentResolver.openInputStream(Uri.parse(contentPath))?.use {
return it.readBytes();
@@ -727,7 +853,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED;
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
_fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
}
@@ -741,11 +867,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateSaved.instance.setVideoToOpenBlocking(null);
}
inline fun <reified T> isFragmentActive(): Boolean {
return fragCurrent is T;
}
/**
* Navigate takes a MainFragment, and makes them the current main visible view
* A parameter can be provided which becomes available in the onShow of said fragment
*/
@SuppressLint("CommitTransaction")
fun navigate(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
Logger.i(TAG, "Navigate to $segment (parameter=$parameter, withHistory=$withHistory, isBack=$isBack)")
@@ -781,7 +911,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
transaction = transaction.replace(R.id.fragment_main, segment);
val extraBottomDP = if(_fragVideoDetail.state == VideoDetailFragment.State.MINIMIZED) HEIGHT_VIDEO_MINIMIZED_DP else 0f
if (segment.hasBottomBar) {
if (!fragCurrent.hasBottomBar)
transaction = transaction.show(_fragBotBarMenu);
@@ -791,13 +920,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
transaction = transaction.hide(_fragBotBarMenu);
}
transaction.commitNow();
}
else {
//Special cases
if(segment is VideoDetailFragment) {
_fragContainerVideoDetail.visibility = View.VISIBLE;
_fragVideoDetail.maximizeVideoDetail();
}
} else {
if(!segment.hasBottomBar) {
supportFragmentManager.beginTransaction()
@@ -834,15 +957,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
navigate(fragBeforeOverlay!!, null, false, true);
}
else {
} else {
val last = _queue.lastOrNull();
if (last != null) {
_queue.remove(last);
navigate(last.first, last.second, false, true);
} else
finish();
} else {
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
finish();
} else {
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish();
})
}
}
}
}
@@ -852,6 +980,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
inline fun <reified T : Fragment> getFragment() : T {
return when(T::class) {
HomeFragment::class -> _fragMainHome as T;
TutorialFragment::class -> _fragMainTutorial as T;
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T;
SuggestionsFragment::class -> _fragMainSuggestions as T;
@@ -860,6 +989,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
SearchTopBarFragment::class -> _fragTopBarSearch as T;
CreatorsFragment::class -> _fragMainSubscriptions as T;
CommentsFragment::class -> _fragMainComments as T;
SubscriptionsFeedFragment::class -> _fragMainSubscriptionsFeed as T;
PlaylistSearchResultsFragment::class -> _fragMainPlaylistSearchResults as T;
ChannelFragment::class -> _fragMainChannel as T;
@@ -875,6 +1005,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
ImportPlaylistsFragment::class -> _fragImportPlaylists as T;
BrowserFragment::class -> _fragBrowser as T;
BuyFragment::class -> _fragBuy as T;
SubscriptionGroupFragment::class -> _fragSubGroup as T;
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
@@ -894,6 +1026,33 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
fun requestNotificationPermissions(reason: String) {
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
reason, null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
//TODO: Only calls last handler due to missing request codes on ActivityResultLaunchers.
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
@@ -925,5 +1084,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getVideoIntent(context: Context, videoUrl: String) : Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "VIDEO";
sourcesIntent.putExtra("VIDEO", videoUrl);
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
fun getImportOptionsIntent(context: Context): Intent {
val sourcesIntent = Intent(context, MainActivity::class.java);
sourcesIntent.action = "IMPORT_OPTIONS";
sourcesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
return sourcesIntent;
}
}
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.ItemMoveCallback
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
private lateinit var _recyclerTabs: RecyclerView;
private lateinit var _touchHelper: ItemTouchHelper;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_manage_tabs);
@@ -49,10 +55,10 @@ class ManageTabsActivity : AppCompatActivity() {
Settings.instance.save()
}
val items = Settings.instance.tabs.mapNotNull {
val items = ArrayList(Settings.instance.tabs.mapNotNull {
val buttonDefinition = MenuBottomBarFragment.buttonDefinitions.find { d -> it.id == d.id } ?: return@mapNotNull null
TabViewHolderData(buttonDefinition, it.enabled)
};
});
_listTabs = _recyclerTabs.asAny(items) {
it.onDragDrop.subscribe { vh ->
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.*
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_backup);
@@ -53,7 +58,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
_imageQR.setImageBitmap(qrCodeBitmap);
} catch (e: Exception) {
Logger.e(TAG, "Failed to generate QR code", e);
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
_imageQR.visibility = View.INVISIBLE;
_textQR.visibility = View.INVISIBLE;
}
@@ -63,12 +68,12 @@ class PolycentricBackupActivity : AppCompatActivity() {
type = "text/plain";
putExtra(Intent.EXTRA_TEXT, _exportBundle);
}
startActivity(Intent.createChooser(shareIntent, "Share Text"));
startActivity(Intent.createChooser(shareIntent, getString(R.string.share_text)));
};
_buttonCopy.onClick.subscribe {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
val clip = ClipData.newPlainText("Copied Text", _exportBundle);
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
clipboard.setPrimaryClip(clip);
};
}
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
@@ -9,8 +10,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store
@@ -28,6 +31,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
private var _creating = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_create_profile);
@@ -54,7 +61,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
try {
val username = _profileName.text.toString();
if (username.length < 3) {
UIDialogs.toast(this@PolycentricCreateProfileActivity, "Must be at least 3 characters long.");
UIDialogs.toast(this@PolycentricCreateProfileActivity, getString(R.string.must_be_at_least_3_characters_long));
return@setOnClickListener;
}
@@ -68,16 +75,18 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
processHandle.setUsername(username);
StatePolycentric.instance.setProcessHandle(processHandle);
} catch (e: Throwable) {
Logger.e(TAG, "Failed to create profile .", e);
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
return@launch;
} finally {
_creating = false;
}
try {
processHandle.fullyBackfillServers();
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) {
Logger.e(TAG, "Failed to fully backfill servers.");
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
}
withContext(Dispatchers.Main) {
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.dp
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.Store
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: BigButton;
private lateinit var _layoutButtons: LinearLayout;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_home);
@@ -47,7 +53,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
this.setMargins(0, 0, 0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt());
};
profileButton.withPrimaryText(systemState.username);
profileButton.withSecondaryText("Sign in to this identity");
profileButton.withSecondaryText(getString(R.string.sign_in_to_this_identity));
profileButton.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricHomeActivity, PolycentricProfileActivity::class.java));
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.EditText
@@ -11,13 +12,19 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.*
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.KeyPair
import com.futo.polycentric.core.Process
import com.futo.polycentric.core.ProcessSecret
import com.futo.polycentric.core.SignedEvent
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.base64UrlToByteArray
import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import userpackage.Protocol
@@ -28,6 +35,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private lateinit var _buttonScanProfile: LinearLayout;
private lateinit var _buttonImportProfile: LinearLayout;
private lateinit var _editProfile: EditText;
private lateinit var _loaderOverlay: LoaderOverlay;
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
@@ -39,6 +47,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
}
}
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile);
@@ -47,6 +59,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonHelp = findViewById(R.id.button_help);
_buttonScanProfile = findViewById(R.id.button_scan_profile);
_buttonImportProfile = findViewById(R.id.button_import_profile);
_loaderOverlay = findViewById(R.id.loader_overlay);
_editProfile = findViewById(R.id.edit_profile);
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
finish();
@@ -59,7 +72,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonScanProfile.setOnClickListener {
val integrator = IntentIntegrator(this)
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
integrator.setPrompt("Scan a QR code")
integrator.setPrompt(getString(R.string.scan_a_qr_code))
integrator.setOrientationLocked(true);
integrator.setCameraId(0)
integrator.setBeepEnabled(false)
@@ -70,7 +83,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
_buttonImportProfile.setOnClickListener {
if (_editProfile.text.isEmpty()) {
UIDialogs.toast(this, "Text field does not contain any data");
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
return@setOnClickListener;
}
@@ -85,54 +98,65 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
private fun import(url: String) {
if (!url.startsWith("polycentric://")) {
UIDialogs.toast(this, "Not a valid URL");
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
return;
}
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
_loaderOverlay.show()
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
lifecycleScope.launch(Dispatchers.IO) {
try {
val data = url.substring("polycentric://".length).base64UrlToByteArray();
val urlInfo = Protocol.URLInfo.parseFrom(data);
if (urlInfo.urlType != 3L) {
throw Exception("Expected urlInfo struct of type ExportBundle")
}
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) {
UIDialogs.toast(this, "This profile is already imported");
return;
}
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
if (existingProcessSecret != null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
}
return@launch;
}
val processHandle = processSecret.toProcessHandle();
val processSecret = ProcessSecret(keyPair, Process.random());
Store.instance.addProcessSecret(processSecret);
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
val processHandle = processSecret.toProcessHandle();
for (e in exportBundle.events.eventsList) {
try {
val se = SignedEvent.fromProto(e);
Store.instance.putSignedEvent(se);
} catch (e: Throwable) {
Logger.w(TAG, "Ignored invalid event", e);
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
withContext(Dispatchers.Main) {
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide();
}
}
StatePolycentric.instance.setProcessHandle(processHandle);
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
finish();
} catch (e: Throwable) {
Logger.w(TAG, "Failed to import profile", e);
UIDialogs.toast(this, "Failed to import profile: '${e.message}'");
}
}
companion object {
private const val TAG = "PolycentricImportProfileActivity";
}
class QRCaptureActivity: CaptureActivity() {
}
}
@@ -1,7 +1,10 @@
package com.futo.platformplayer.activities
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -11,23 +14,27 @@ import android.webkit.MimeTypeMap
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.overlays.LoaderOverlay
import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toURLInfoDataLink
import com.futo.polycentric.core.toBase64Url
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.github.dhaval2404.imagepicker.ImagePicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -44,8 +51,14 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _buttonDelete: BigButton;
private lateinit var _username: String;
private lateinit var _imagePolycentric: ImageView;
private lateinit var _loaderOverlay: LoaderOverlay;
private lateinit var _textSystem: TextView;
private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_profile);
@@ -57,28 +70,13 @@ class PolycentricProfileActivity : AppCompatActivity() {
_buttonExport = findViewById(R.id.button_export);
_buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
_textSystem = findViewById(R.id.text_system)
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
saveIfRequired();
finish();
};
lifecycleScope.launch(Dispatchers.IO) {
try {
val processHandle = StatePolycentric.instance.processHandle!!;
Synchronization.fullyBackFillClient(processHandle, processHandle.system, "https://srv1-stg.polycentric.io");
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to backfill client");
}
}
}
updateUI();
_imagePolycentric.setOnClickListener {
ImagePicker.with(this)
.cropSquare()
@@ -101,10 +99,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
}
_buttonDelete.onClick.subscribe {
UIDialogs.showConfirmationDialog(this, "Are you sure you want to remove this profile?", {
UIDialogs.showConfirmationDialog(this, getString(R.string.are_you_sure_you_want_to_remove_this_profile), {
val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) {
UIDialogs.toast(this, "No process handle set");
UIDialogs.toast(this, getString(R.string.no_process_handle_set));
return@showConfirmationDialog;
}
@@ -114,6 +112,37 @@ class PolycentricProfileActivity : AppCompatActivity() {
finish();
});
}
_textSystem.setOnLongClickListener {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("system", _textSystem.text)
clipboard.setPrimaryClip(clip)
return@setOnLongClickListener true
}
updateUI()
StatePolycentric.instance.processHandle?.let { processHandle ->
_loaderOverlay.show()
lifecycleScope.launch(Dispatchers.IO) {
try {
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
withContext(Dispatchers.Main) {
updateUI();
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_backfill_client));
}
} finally {
withContext(Dispatchers.Main) {
_loaderOverlay.hide()
}
}
}
}
}
private fun saveIfRequired() {
@@ -122,13 +151,17 @@ class PolycentricProfileActivity : AppCompatActivity() {
var hasChanges = false;
val username = _editName.text.toString();
if (username.length < 3) {
UIDialogs.toast(this@PolycentricProfileActivity, "Name must be at least 3 characters long");
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.name_must_be_at_least_3_characters_long));
}
return@launch;
}
val processHandle = StatePolycentric.instance.processHandle;
if (processHandle == null) {
UIDialogs.toast(this@PolycentricProfileActivity, "Process handle unset");
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.process_handle_unset));
}
return@launch;
}
@@ -143,7 +176,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val bytes = readBytesFromUri(applicationContext.contentResolver, avatarUri);
if (bytes == null) {
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to read image");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_read_image));
}
return@launch;
@@ -186,14 +219,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
if (hasChanges) {
try {
processHandle.fullyBackfillServers();
Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill");
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Changes have been saved");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
}
} catch (e: Throwable) {
Logger.w(TAG, "Failed to synchronize changes", e);
withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, "Failed to synchronize changes");
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.failed_to_synchronize_changes));
}
}
}
@@ -211,6 +246,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
private fun updateUI() {
val processHandle = StatePolycentric.instance.processHandle!!;
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(processHandle.system))
_textSystem.text = processHandle.system.key.toBase64Url()
_username = systemState.username;
_editName.text.clear();
_editName.text.append(_username);
@@ -219,7 +255,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80);
Glide.with(_imagePolycentric)
.load(avatar?.toURLInfoDataLink(processHandle.system.toProto(), processHandle.processSecret.process.toProto(), systemState.servers.toList()))
.load(avatar?.toURLInfoSystemLinkUrl(processHandle.system.toProto(), avatar.process, systemState.servers.toList()))
.placeholder(R.drawable.placeholder_profile)
.crossfade()
.into(_imagePolycentric)
@@ -235,12 +271,12 @@ class PolycentricProfileActivity : AppCompatActivity() {
} else if (resultCode == ImagePicker.RESULT_ERROR) {
UIDialogs.toast(this, ImagePicker.getError(data));
} else {
UIDialogs.toast(this, "Image picker cancelled");
UIDialogs.toast(this, getString(R.string.image_picker_cancelled));
}
}
private fun getMimeType(contentResolver: ContentResolver, uri: Uri): String? {
var mimeType: String? = null;
var mimeType: String?;
// Try to get MIME type from the content URI
mimeType = contentResolver.getType(uri);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -7,12 +8,17 @@ import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton
class PolycentricWhyActivity : AppCompatActivity() {
private lateinit var _buttonVideo: BigButton;
private lateinit var _buttonTechnical: BigButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_why);
@@ -0,0 +1,7 @@
package com.futo.platformplayer.activities
import com.journeyapps.barcodescanner.CaptureActivity
class QRCaptureActivity : CaptureActivity() {
}
@@ -1,19 +1,26 @@
package com.futo.platformplayer.activities
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.LoaderView
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField
import com.google.android.material.button.MaterialButton
@@ -21,13 +28,27 @@ import com.google.android.material.button.MaterialButton
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private lateinit var _loader: Loader;
private lateinit var _loaderView: LoaderView;
private lateinit var _devSets: LinearLayout;
private lateinit var _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted)
UIDialogs.toast(this, "Notification permission granted");
else
UIDialogs.toast(this, "Notification permission denied");
}
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
@@ -37,12 +58,45 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
_buttonBack = findViewById(R.id.button_back);
_buttonDev = findViewById(R.id.button_dev);
_devSets = findViewById(R.id.dev_settings);
_loader = findViewById(R.id.loader);
_loaderView = findViewById(R.id.loader);
overlay = findViewById(R.id.overlay_container);
_form.onChanged.subscribe { field, value ->
_form.onChanged.subscribe { field, _ ->
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues();
Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update") {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(this, "Notifications aren't enabled");
when {
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
}
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
"Notifications need to be enabled for background updating to function", null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Enable", {
requestPermissionLauncher.launch(notifPermission);
}, UIDialogs.ActionStyle.PRIMARY));
}
else -> {
requestPermissionLauncher.launch(notifPermission);
}
}
}
}
}
};
_buttonBack.setOnClickListener {
finish();
@@ -57,10 +111,15 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
_loader.start();
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(lifecycleScope, Settings.instance) {
_loader.stop();
_loaderView.stop();
_form.setSearchVisible(true);
var devCounter = 0;
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
@@ -70,9 +129,16 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
SettingsDev.instance.developerMode = true;
SettingsDev.instance.save();
updateDevMode();
UIDialogs.toast(this, "You are now in developer mode");
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
}
};
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}
};
}
@@ -118,6 +184,7 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
resultLauncher.launch(intent);
}
companion object {
//TODO: Temporary for solving Settings issues
@SuppressLint("StaticFieldLeak")
@@ -13,8 +13,6 @@ import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.Dictionary
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
open class ManagedHttpClient {
@@ -60,7 +58,7 @@ open class ManagedHttpClient {
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.url(url);
if(user_agent != null && !user_agent.isEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
requestBuilder.addHeader("User-Agent", user_agent)
for (pair in headers.entries)
@@ -137,7 +135,7 @@ open class ManagedHttpClient {
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(request.method, requestBody)
.url(request.url);
if(user_agent != null && !user_agent.isEmpty() && !request.headers.any { it.key.lowercase() == "user-agent" })
if(user_agent.isNotEmpty() && !request.headers.any { it.key.lowercase() == "user-agent" })
requestBuilder.addHeader("User-Agent", user_agent)
for (pair in request.headers.entries)
@@ -148,7 +146,7 @@ open class ManagedHttpClient {
val time = measureTimeMillis {
val call = client.newCall(requestBuilder.build());
request.onCallCreated?.emit(call);
request.onCallCreated.emit(call);
response = call.execute()
resp = Response(
response.code,
@@ -8,16 +8,20 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.StringWriter
import java.net.SocketTimeoutException
class HttpContext : AutoCloseable {
private val _stream: BufferedReader;
private val _inputStream: InputStream;
private var _responseStream: OutputStream? = null;
var id: String? = null;
var head: String = "";
var headers: HttpHeaders = HttpHeaders();
@@ -39,76 +43,130 @@ class HttpContext : AutoCloseable {
private val _responseHeaders: HttpHeaders = HttpHeaders();
constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
_stream = stream;
constructor(inputStream: InputStream, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
_inputStream = inputStream;
_responseStream = responseStream;
this.id = requestId;
try {
head = stream.readLine() ?: throw EmptyRequestException("No head found");
}
catch(ex: SocketTimeoutException) {
if((timeout ?: 0) > 0)
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
throw ex;
}
val methodEndIndex = head.indexOf(' ');
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
if (methodEndIndex == -1 || urlEndIndex == -1) {
Logger.w(TAG, "Skipped request, wrong format.");
throw IllegalStateException("Invalid request");
}
method = head.substring(0, methodEndIndex);
path = head.substring(methodEndIndex + 1, urlEndIndex);
if (path.contains("?")) {
val queryPartIndex = path.indexOf("?");
val queryParts = path.substring(queryPartIndex + 1).split("&");
path = path.substring(0, queryPartIndex);
for(queryPart in queryParts) {
val eqIndex = queryPart.indexOf("=");
if(eqIndex > 0)
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
else
query.put(queryPart, "");
val headerBytes = readHeaderBytes()
ByteArrayInputStream(headerBytes).use {
val reader = it.bufferedReader(Charsets.UTF_8)
try {
head = reader.readLine() ?: throw EmptyRequestException("No head found");
}
catch(ex: SocketTimeoutException) {
if((timeout ?: 0) > 0)
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
throw ex;
}
}
while (true) {
val line = stream.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val methodEndIndex = head.indexOf(' ');
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
if (methodEndIndex == -1 || urlEndIndex == -1) {
Logger.w(TAG, "Skipped request, wrong format.");
throw IllegalStateException("Invalid request");
}
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
method = head.substring(0, methodEndIndex);
path = head.substring(methodEndIndex + 1, urlEndIndex);
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
"keep-alive" -> {
val keepAliveParams = headerValue.split(",");
for(keepAliveParam in keepAliveParams) {
val eqIndex = keepAliveParam.indexOf("=");
if(eqIndex > 0){
when(keepAliveParam.substring(0, eqIndex)) {
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
if (path.contains("?")) {
val queryPartIndex = path.indexOf("?");
val queryParts = path.substring(queryPartIndex + 1).split("&");
path = path.substring(0, queryPartIndex);
for(queryPart in queryParts) {
val eqIndex = queryPart.indexOf("=");
if(eqIndex > 0)
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
else
query.put(queryPart, "");
}
}
while (true) {
val line = reader.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
"keep-alive" -> {
val keepAliveParams = headerValue.split(",");
for(keepAliveParam in keepAliveParams) {
val eqIndex = keepAliveParam.indexOf("=");
if(eqIndex > 0){
when(keepAliveParam.substring(0, eqIndex)) {
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
}
}
}
}
}
if(line.isNullOrEmpty())
break;
}
if(line.isNullOrEmpty())
break;
}
}
private fun readHeaderBytes(): ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = _inputStream.read()
if (b == -1) {
throw IOException("Unexpected end of stream while reading headers")
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
}
headerBytes.write(b)
}
return headerBytes.toByteArray()
}
fun readContentBytes(buffer: ByteArray, length: Int): Int {
val remainingBytes = (contentLength - _totalRead).coerceAtMost(length.toLong()).toInt()
val read = _inputStream.read(buffer, 0, remainingBytes);
if (read > 0) {
_totalRead += read
}
return read;
}
fun readContentString(): String {
val byteArrayOutputStream = ByteArrayOutputStream()
val buffer = ByteArray(4096)
var read: Int
while (true) {
read = readContentBytes(buffer, buffer.size)
if (read <= 0) break
byteArrayOutputStream.write(buffer, 0, read)
}
return byteArrayOutputStream.toString(Charsets.UTF_8.name())
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if (contentLength > 0)
_inputStream.skip(contentLength - _totalRead)
}
fun getHttpHeaderString(): String {
val writer = StringWriter();
writer.write(head + "\r\n");
@@ -139,8 +197,13 @@ class HttpContext : AutoCloseable {
}
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
val bytes = body?.toByteArray(Charsets.UTF_8);
if(body != null && headers.get("content-length").isNullOrEmpty())
headers.put("content-length", bytes!!.size.toString());
if(headers.get("content-length").isNullOrEmpty()) {
if (body != null) {
headers.put("content-length", bytes!!.size.toString());
} else {
headers.put("content-length", "0")
}
}
respond(status, headers) { responseStream ->
if(body != null) {
responseStream.write(bytes!!);
@@ -161,8 +224,7 @@ class HttpContext : AutoCloseable {
headersToRespond.put("keep-alive", "timeout=5, max=1000");
}
val responseHeader = HttpResponse(status, headers);
val responseHeader = HttpResponse(status, headersToRespond);
responseStream.write(responseHeader.getHttpHeaderBytes());
if(method != "HEAD") {
@@ -172,38 +234,9 @@ class HttpContext : AutoCloseable {
statusCode = status;
}
fun readContentBytes(buffer: CharArray, length: Int) : Int {
val reading = Math.min(length, (contentLength - _totalRead).toInt());
val read = _stream.read(buffer, 0, reading);
_totalRead += read;
//TODO: Fix this properly
if(contentLength - _totalRead < 400 && read < length) {
_totalRead = contentLength;
}
return read;
}
fun readContentString() : String{
val writer = StringWriter();
var read = 0;
val buffer = CharArray(4096);
do {
read = readContentBytes(buffer, buffer.size);
writer.write(buffer, 0, read);
} while(read > 0);
return writer.toString();
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if(contentLength > 0)
_stream.skip(contentLength - _totalRead);
}
override fun close() {
if(!keepAlive) {
_stream?.close();
_inputStream.close();
_responseStream?.close();
}
}
@@ -1,12 +1,12 @@
package com.futo.platformplayer.api.http.server
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
import java.io.BufferedReader
import java.io.InputStreamReader
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
import com.futo.platformplayer.logging.Logger
import java.io.BufferedInputStream
import java.io.OutputStream
import java.lang.reflect.Field
import java.lang.reflect.Method
@@ -14,7 +14,7 @@ import java.net.InetAddress
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
import java.util.*
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.stream.IntStream.range
@@ -29,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
var port = 0
private set;
private val _handlers = mutableListOf<HttpHandler>();
private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
private val _headHandlers = hashMapOf<String, HttpHandler>()
private var _workerPool: ExecutorService? = null;
@Synchronized
@@ -76,12 +77,12 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
private fun handleClientRequest(socket: Socket) {
_workerPool?.submit {
val requestReader = BufferedReader(InputStreamReader(socket.getInputStream()))
val requestStream = BufferedInputStream(socket.getInputStream());
val responseStream = socket.getOutputStream();
val requestId = UUID.randomUUID().toString().substring(0, 5);
try {
keepAliveLoop(requestReader, responseStream, requestId) { req ->
keepAliveLoop(requestStream, responseStream, requestId) { req ->
req.use { httpContext ->
if(!httpContext.path.startsWith("/plugin/"))
Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}")
@@ -107,7 +108,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
Logger.e(TAG, "Failed to handle client request.", e);
}
finally {
requestReader.close();
requestStream.close();
responseStream.close();
}
};
@@ -115,36 +116,82 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
fun getHandler(method: String, path: String) : HttpHandler? {
synchronized(_handlers) {
//TODO: Support regex paths?
if(method == "HEAD")
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") }
return _handlers.firstOrNull { it.method == method && it.path == path };
if (method == "HEAD") {
return _headHandlers[path]
}
val handlerMap = _handlers[method] ?: return null
return handlerMap[path]
}
}
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
synchronized(_handlers) {
_handlers.add(handler);
handler.allowHEAD = withHEAD;
var handlerMap: HashMap<String, HttpHandler>? = _handlers[handler.method];
if (handlerMap == null) {
handlerMap = hashMapOf()
_handlers[handler.method] = handlerMap
}
handlerMap[handler.path] = handler;
if (handler.allowHEAD || handler.method == "HEAD") {
_headHandlers[handler.path] = handler
}
}
return handler;
}
fun addHandlerWithAllowAllOptions(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
val allowedMethods = arrayListOf(handler.method, "OPTIONS")
if (withHEAD) {
allowedMethods.add("HEAD")
}
val tag = handler.tag
if (tag != null) {
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods).withTag(tag))
} else {
addHandler(HttpOptionsAllowHandler(handler.path, allowedMethods))
}
return addHandler(handler, withHEAD)
}
fun removeHandler(method: String, path: String) {
synchronized(_handlers) {
val handler = getHandler(method, path);
if(handler != null)
_handlers.remove(handler);
val handlerMap = _handlers[method] ?: return
val handler = handlerMap.remove(path) ?: return
if (method == "HEAD" || handler.allowHEAD) {
_headHandlers.remove(path)
}
}
}
fun removeAllHandlers(tag: String? = null) {
synchronized(_handlers) {
if(tag == null)
_handlers.clear();
else
_handlers.removeIf { it.tag == tag };
else {
for (pair in _handlers) {
val toRemove = ArrayList<String>()
for (innerPair in pair.value) {
if (innerPair.value.tag == tag) {
toRemove.add(innerPair.key)
if (pair.key == "HEAD" || innerPair.value.allowHEAD) {
_headHandlers.remove(innerPair.key)
}
}
}
for (x in toRemove)
pair.value.remove(x)
}
}
}
}
fun addBridgeHandlers(obj: Any, tag: String? = null) {
val tagToUse = tag ?: obj.javaClass.name;
//val tagToUse = tag ?: obj.javaClass.name;
val getMethods = obj::class.java.declaredMethods
.filter { it.getAnnotation(HttpGET::class.java) != null }
.map { Pair<Method, HttpGET>(it, it.getAnnotation(HttpGET::class.java)!!) }
@@ -164,13 +211,13 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
addHandler(HttpFuntionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
if(!getMethod.second.contentType.isEmpty())
this.withContentType(getMethod.second.contentType);
}.withContentType(getMethod.second.contentType ?: "");
}.withContentType(getMethod.second.contentType);
for(postMethod in postMethods)
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
addHandler(HttpFuntionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
if(!postMethod.second.contentType.isEmpty())
this.withContentType(postMethod.second.contentType);
}.withContentType(postMethod.second.contentType ?: "");
}.withContentType(postMethod.second.contentType);
for(getField in getFields) {
getField.first.isAccessible = true;
@@ -184,13 +231,13 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
}
else
it.respondCode(204);
}).withContentType(getField.second.contentType ?: "");
}).withContentType(getField.second.contentType);
}
}
private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
val stopCount = _stopCount;
var keepAlive = false;
var keepAlive: Boolean;
var requestsMax = 0;
var requestsTotal = 0;
do {
@@ -240,11 +287,13 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
for (intf in NetworkInterface.getNetworkInterfaces()) {
for (addr in intf.inetAddresses) {
if (!addr.isLoopbackAddress) {
val ipString: String = addr.hostAddress;
val isIPv4 = ipString.indexOf(':') < 0;
if (!isIPv4)
continue;
addresses.add(addr);
val ipString: String = addr.hostAddress ?: continue
val isIPv4 = ipString.indexOf(':') < 0
if (!isIPv4) {
continue
}
addresses.add(addr)
}
}
}
@@ -1,6 +1,3 @@
package com.futo.platformplayer.api.http.server.exceptions
import java.net.SocketTimeoutException
import java.util.concurrent.TimeoutException
class EmptyRequestException(msg: String) : Exception(msg) {}
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
val headers = this.headers.clone();
if(contentType != null)
headers["Content-Type"] = contentType;
headers["Content-Length"] = content.length.toString();
httpContext.respondCode(200, headers, content);
}
@@ -1,14 +1,16 @@
package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
import java.text.SimpleDateFormat
import java.util.*
import java.util.zip.GZIPOutputStream
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) {
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) {
val requestHeaders = httpContext.headers;
val responseHeaders = this.headers.clone();
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
val acceptEncoding = requestHeaders["Accept-Encoding"]
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
if (shouldGzip) {
responseHeaders["Content-Encoding"] = "gzip"
}
val range = requestHeaders["Range"]
var start: Long
val start: Long
val end: Long
if (range != null && range.startsWith("bytes=")) {
val parts = range.substring(6).split("-")
start = parts[0].toLong()
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1)
end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
} else {
start = 0
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
var totalBytesSent = 0
val contentLength = end - start + 1
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
responseHeaders["Content-Length"] = contentLength.toString()
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
file.inputStream().use { inputStream ->
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream ->
httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
try {
val buffer = ByteArray(8192)
inputStream.skip(start)
var current = start
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream
val outputStream = responseStream
while (true) {
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong());
val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
val bytesRead = inputStream.read(buffer);
if (bytesRead < 0) {
Logger.i(TAG, "End of file reached")
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
outputStream.write(buffer, 0, bytesToSend)
totalBytesSent += bytesToSend
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent")
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
start += bytesToSend.toLong()
if (start >= end) {
current += bytesToSend.toLong()
if (current >= end) {
Logger.i(TAG, "Expected amount of bytes sent")
break
}
}
Logger.i(TAG, "Finished sending file (segment)")
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
outputStream.flush()
} catch (e: Exception) {
httpContext.respondCode(500, headers)
}
}
if (closeAfterRequest) {
httpContext.keepAlive = false;
}
}
}
@@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
headers.put(key, value);
return this;
}
fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
fun withTag(tag: String) : HttpHandler {
@@ -2,19 +2,18 @@ package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
class HttpOptionsAllowHandler(path: String, val allowedMethods: List<String> = listOf()) : HttpHandler("OPTIONS", path) {
override fun handle(httpContext: HttpContext) {
//Just allow whatever is requested
val newHeaders = headers.clone()
newHeaders.put("Access-Control-Allow-Origin", "*")
val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", "");
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", "");
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
val newHeaders = headers.clone();
newHeaders.put("Allow", requestedMethods);
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
newHeaders.put("Access-Control-Allow-Headers", "*");
if (allowedMethods.isNotEmpty()) {
newHeaders.put("Access-Control-Allow-Methods", allowedMethods.map { it.uppercase() }.joinToString(", "))
} else {
newHeaders.put("Access-Control-Allow-Methods", "*")
}
newHeaders.put("Access-Control-Allow-Headers", "*")
httpContext.respondCode(200, newHeaders);
}
}
@@ -1,11 +1,20 @@
package com.futo.platformplayer.api.http.server.handlers
import android.net.Uri
import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.parsers.HttpResponseParser
import com.futo.platformplayer.readLine
import java.io.InputStream
import java.io.OutputStream
import java.lang.Exception
import java.net.Socket
import javax.net.ssl.SSLSocketFactory
class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) {
class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) {
var content: String? = null;
var contentType: String? = null;
@@ -17,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
private var _injectHost = false;
private var _injectReferer = false;
private val _client = ManagedHttpClient();
override fun handle(context: HttpContext) {
if (useTcp) {
handleWithTcp(context)
} else {
handleWithOkHttp(context)
}
}
private fun handleWithOkHttp(context: HttpContext) {
val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value;
@@ -34,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
//Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}");
//Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders);
@@ -44,8 +60,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
};
//Logger.i(TAG, "Proxied Response [${resp.code}]");
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) });
Logger.i(TAG, "Proxied Response [${resp.code}]");
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for(newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value);
@@ -65,6 +81,140 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
}
}
private fun handleWithTcp(context: HttpContext) {
if (content != null)
throw NotImplementedError("Content body is not supported")
val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value;
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
makeTcpRequest(proxyHeaders, useMethod, parsed, context)
}
private fun makeTcpRequest(proxyHeaders: HashMap<String, String>, useMethod: String, parsed: Uri, context: HttpContext) {
val requestBuilder = StringBuilder()
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
requestBuilder.append("\r\n")
val port = if (parsed.port == -1) {
when (parsed.scheme) {
"https" -> 443
"http" -> 80
else -> throw Exception("Unhandled scheme")
}
} else {
parsed.port
}
val socket = if (parsed.scheme == "https") {
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
sslSocketFactory.createSocket(parsed.host, port)
} else {
Socket(parsed.host, port)
}
socket.use { s ->
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
val inputStream = s.getInputStream()
val resp = HttpResponseParser(inputStream)
if (resp.statusCode == 302) {
val location = resp.location!!
Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location");
makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context)
} else {
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
val contentLength = resp.contentLength.toInt()
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for (newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value);
context.respond(resp.statusCode, headersFiltered) { responseStream ->
if (isChunked) {
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
handleChunkedTransfer(inputStream, responseStream)
} else if (contentLength > 0) {
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
transferFixedLengthContent(inputStream, responseStream, contentLength)
} else if (contentLength == -1) {
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
transferUntilEndOfStream(inputStream, responseStream)
} else {
Logger.i(TAG, "handleWithTcp no content");
}
}
}
}
}
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
var line: String?
val buffer = ByteArray(8192)
while (inputStream.readLine().also { line = it } != null) {
val size = line!!.trim().toInt(16)
responseStream.write(line!!.encodeToByteArray())
responseStream.write("\r\n".encodeToByteArray())
if (size == 0) {
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
break
}
var totalRead = 0
while (totalRead < size) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
responseStream.flush()
}
}
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
val buffer = ByteArray(8192)
var totalRead = 0
while (totalRead < contentLength) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
responseStream.flush()
}
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } >= 0) {
responseStream.write(buffer, 0, read)
}
responseStream.flush()
}
fun withContent(body: String) : HttpProxyHandler {
this.content = body;
return this;
@@ -92,4 +242,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
_ignoreRequestHeaders.add("referer");
return this;
}
companion object {
private const val TAG = "HttpProxyHandler"
}
}
@@ -3,18 +3,16 @@ package com.futo.platformplayer.api.media
import androidx.collection.LruCache
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.models.Playlist
/**
* A temporary class that caches video results
@@ -43,12 +41,12 @@ class CachedPlatformClient : IPlatformClient {
var result = _cache.get(url);
if(result == null) {
result = _client.getContentDetails(url);
if (result != null)
_cache.put(url, result);
_cache.put(url, result);
}
return result;
}
override fun getContentChapters(url: String): List<IChapter> = _client.getContentChapters(url);
override fun getPlaybackTracker(url: String): IPlaybackTracker? = _client.getPlaybackTracker(url);
override fun isChannelUrl(url: String): Boolean = _client.isChannelUrl(url);
@@ -3,17 +3,16 @@ package com.futo.platformplayer.api.media
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.models.Playlist
/**
* A client for a specific platform
@@ -100,6 +99,8 @@ interface IPlatformClient {
*/
fun getContentDetails(url: String): IPlatformContentDetails;
fun getContentChapters(url: String): List<IChapter>;
/**
* Gets the playback tracker for a piece of content
*/
@@ -9,7 +9,6 @@ import com.caverock.androidsvg.SVG
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.live.LiveEventComment
import com.futo.platformplayer.api.media.models.live.LiveEventDonation
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.structures.IPager
@@ -195,7 +194,7 @@ class LiveChatManager {
fun getEmojiDrawable(emoji: String, cb: (drawable: Drawable?)->Unit) {
var drawable: Drawable? = null;
var url: String? = null;
var url: String?;
synchronized(_cache_lock) {
url = _cache_urls[emoji];
if(url != null)
@@ -15,7 +15,8 @@ data class PlatformClientCapabilities(
val hasGetSearchCapabilities: Boolean = false,
val hasGetChannelCapabilities: Boolean = false,
val hasGetLiveEvents: Boolean = false,
val hasGetLiveChatWindow: Boolean = false
val hasGetLiveChatWindow: Boolean = false,
val hasGetContentChapters: Boolean = false
) {
}
@@ -20,7 +20,7 @@ class PlatformMultiClientPool {
val pool = synchronized(_clientPools) {
if(!_clientPools.containsKey(parentClient))
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
this.onDead.subscribe { client, pool ->
this.onDead.subscribe { _, pool ->
synchronized(_clientPools) {
if(_clientPools[parentClient] == pool)
_clientPools.remove(parentClient);
@@ -10,7 +10,7 @@ import com.futo.platformplayer.getOrThrow
* A link to a channel, often with its own name and thumbnail
*/
@kotlinx.serialization.Serializable
class PlatformAuthorLink {
open class PlatformAuthorLink {
val id: PlatformID;
val name: String;
val url: String;
@@ -28,6 +28,9 @@ class PlatformAuthorLink {
companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
if(value.has("membershipUrl"))
return PlatformAuthorMembershipLink.fromV8(config, value);
val context = "AuthorLink"
return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context),
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
/**
* A link to a channel, often with its own name and thumbnail
*/
@kotlinx.serialization.Serializable
class PlatformAuthorMembershipLink: PlatformAuthorLink {
val membershipUrl: String?;
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null, membershipUrl: String? = null): super(id, name, url, thumbnail, subscribers)
{
this.membershipUrl = membershipUrl;
}
companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
val context = "AuthorMembershipLink"
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context),
value.getOrThrow(config, "url", context),
value.getOrDefault<String>(config, "thumbnail", context, null),
if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null,
if(value.has("membershipUrl")) value.getOrThrow(config, "membershipUrl", context) else null
);
}
}
}
@@ -29,6 +29,7 @@ class ResultCapabilities(
const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -63,7 +64,6 @@ class FilterGroup(
val isMultiSelect: Boolean,
val id: String? = null
) {
@kotlinx.serialization.Transient
val idOrName: String get() = id ?: name;
companion object {
@@ -20,6 +20,10 @@ class Thumbnails {
fun getLQThumbnail() : String? {
return sources.firstOrNull()?.url;
}
fun getMinimumThumbnail(quality: Int): String? {
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
}
fun hasMultiple() = sources.size > 1;
@@ -0,0 +1,32 @@
package com.futo.platformplayer.api.media.models.chapters
import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
import com.futo.platformplayer.api.media.models.contents.ContentType
interface IChapter {
val name: String;
val type: ChapterType;
val timeStart: Double;
val timeEnd: Double;
}
enum class ChapterType(val value: Int) {
NORMAL(0),
SKIPPABLE(5),
SKIP(6),
SKIPONCE(7);
companion object {
fun fromInt(value: Int): ChapterType
{
val result = ChapterType.values().firstOrNull { it.value == value };
if(result == null)
throw UnknownPlatformException(value.toString());
return result;
}
}
}
@@ -4,10 +4,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.Pointer
import com.futo.polycentric.core.SignedEvent
import userpackage.Protocol.Reference
import java.time.OffsetDateTime
@@ -20,16 +17,18 @@ class PolycentricPlatformComment : IPlatformComment {
override val replyCount: Int?;
val eventPointer: Pointer;
val reference: Reference;
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, reference: Reference, replyCount: Int? = null) {
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, eventPointer: Pointer, replyCount: Int? = null) {
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
this.reference = reference;
this.eventPointer = eventPointer;
this.reference = eventPointer.toReference();
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
@@ -37,7 +36,7 @@ class PolycentricPlatformComment : IPlatformComment {
}
fun cloneWithUpdatedReplyCount(replyCount: Int?): PolycentricPlatformComment {
return PolycentricPlatformComment(contextUrl, author, message, rating, date, reference, replyCount);
return PolycentricPlatformComment(contextUrl, author, message, rating, date, eventPointer, replyCount);
}
companion object {
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
NESTED_VIDEO(11),
LOCKED(70),
PLACEHOLDER(90),
DEFERRED(91);
@@ -1,31 +1,23 @@
package com.futo.platformplayer.api.media.models.live
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.ratings.IRating
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.ratings.RatingScaler
import com.futo.platformplayer.api.media.models.ratings.RatingType
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.orDefault
interface IPlatformLiveEvent {
val type : LiveEventType;
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Unknown") : IPlatformLiveEvent {
val contextName = "LiveEvent";
val type = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
return when(type) {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
return when(t) {
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
LiveEventType.EMOJIS -> LiveEventEmojis.fromV8(config, obj);
LiveEventType.DONATION -> LiveEventDonation.fromV8(config, obj);
LiveEventType.VIEWCOUNT -> LiveEventViewCount.fromV8(config, obj);
LiveEventType.RAID -> LiveEventRaid.fromV8(config, obj);
else -> throw NotImplementedError("Unknown type ${type}");
else -> throw NotImplementedError("Unknown type $t");
}
}
}
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.models.locked
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformLockedContent: IPlatformContent {
val lockContentType: ContentType;
val lockDescription: String?;
val unlockUrl: String?;
val contentName: String?;
val contentThumbnails: Thumbnails;
}
@@ -0,0 +1,14 @@
package com.futo.platformplayer.api.media.models.modifier
class AdhocRequestModifier: IRequestModifier {
val _handler: (String, Map<String,String>)->IRequest;
override var allowByteSkip: Boolean = false;
constructor(modifyReq: (String, Map<String,String>)->IRequest) {
_handler = modifyReq;
}
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
return _handler(url, headers);
}
}
@@ -0,0 +1,6 @@
package com.futo.platformplayer.api.media.models.modifier
interface IModifierOptions {
val applyAuthClient: String?;
val applyCookieClient: String?;
}
@@ -0,0 +1,6 @@
package com.futo.platformplayer.api.media.models.modifier
interface IRequest {
val url: String?;
val headers: Map<String, String>;
}
@@ -0,0 +1,7 @@
package com.futo.platformplayer.api.media.models.modifier
interface IRequestModifier {
var allowByteSkip: Boolean;
fun modifyRequest(url: String, headers: Map<String, String>): IRequest
}
@@ -1,9 +1,6 @@
package com.futo.platformplayer.api.media.models.playlists
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.IPager
interface IPlatformPlaylist : IPlatformContent {
val thumbnail: String?;
@@ -2,10 +2,6 @@ package com.futo.platformplayer.api.media.models.post
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
/**
* A detailed video model with data including video/audio sources
@@ -14,14 +14,13 @@ interface IRating {
companion object {
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) };
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Unknown") : IRating {
val contextName = "Rating";
val type = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
return when(type) {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
return when(t) {
RatingType.LIKES -> RatingLikes.fromV8(config, obj);
RatingType.LIKEDISLIKES -> RatingLikeDislikes.fromV8(config, obj);
RatingType.SCALE -> RatingScaler.fromV8(config, obj);
else -> throw NotImplementedError("Unknown type ${type}");
else -> throw NotImplementedError("Unknown type $t");
}
}
}
@@ -0,0 +1,51 @@
package com.futo.platformplayer.api.media.models.streams.sources
import android.net.Uri
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
class HLSVariantVideoUrlSource(
override val name: String,
override val width: Int,
override val height: Int,
override val container: String,
override val codec: String,
override val bitrate: Int?,
override val duration: Long,
override val priority: Boolean,
val url: String
) : IVideoUrlSource {
override fun getVideoUrl(): String {
return url
}
}
class HLSVariantAudioUrlSource(
override val name: String,
override val bitrate: Int,
override val container: String,
override val codec: String,
override val language: String,
override val duration: Long?,
override val priority: Boolean,
val url: String
) : IAudioUrlSource {
override fun getAudioUrl(): String {
return url
}
}
class HLSVariantSubtitleUrlSource(
override val name: String,
override val url: String,
override val format: String,
) : ISubtitleSource {
override val hasFetch: Boolean = false
override fun getSubtitles(): String? {
return null
}
override suspend fun getSubtitlesURI(): Uri? {
return Uri.parse(url)
}
}
@@ -1,8 +1,6 @@
package com.futo.platformplayer.api.media.models.subtitles
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
interface ISubtitleSource {
val name: String;
@@ -1,13 +1,12 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.structures.IPager
/**
* A detailed video model with data including video/audio sources
@@ -2,12 +2,17 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.PlatformContentSerializer
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
@kotlinx.serialization.Serializable(with = PlatformContentSerializer::class)
interface SerializedPlatformContent: IPlatformContent {
override val contentType: ContentType;
fun toJson() : String;
fun fromJson(str : String) : SerializedPlatformContent;
fun fromJsonArray(str : String) : Array<SerializedPlatformContent>;
@@ -18,6 +23,7 @@ interface SerializedPlatformContent: IPlatformContent {
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
};
}
@@ -0,0 +1,62 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformLockedContent(
override val id: PlatformID,
override val name: String,
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?,
override val url: String,
override val shareUrl: String,
override val lockContentType: ContentType,
override val contentName: String?,
override val lockDescription: String? = null,
override val unlockUrl: String? = null,
override val contentThumbnails: Thumbnails
) : IPlatformLockedContent, SerializedPlatformContent {
override val contentType: ContentType = ContentType.LOCKED;
override fun toJson() : String {
return Json.encodeToString(this);
}
override fun fromJson(str : String) : SerializedPlatformLockedContent {
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
}
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
}
companion object {
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
return SerializedPlatformLockedContent(
content.id,
content.name,
content.author,
content.datetime,
content.url,
content.shareUrl,
content.lockContentType,
content.contentName,
content.lockDescription,
content.unlockUrl,
content.contentThumbnails
);
}
}
}
@@ -30,7 +30,7 @@ open class SerializedPlatformNestedContent(
override val contentProvider: String?,
override val contentThumbnails: Thumbnails
) : IPlatformNestedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.NESTED_VIDEO;
final override val contentType: ContentType = ContentType.NESTED_VIDEO;
override val contentPlugin: String? = StatePlatform.instance.getContentClientOrNull(contentUrl)?.id;
override val contentSupported: Boolean get() = contentPlugin != null;
@@ -8,6 +8,7 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -26,7 +27,7 @@ open class SerializedPlatformPost(
override val thumbnails: List<Thumbnails?>,
override val images: List<String>
) : IPlatformPost, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.POST;
override val contentType: ContentType = ContentType.POST;
override fun toJson() : String {
return Json.encodeToString(this);
@@ -26,7 +26,7 @@ open class SerializedPlatformVideo(
override val duration: Long,
override val viewCount: Long,
) : IPlatformVideo, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.MEDIA;
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false;
@@ -8,6 +8,5 @@ import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
class SerializedVideoMuxedSourceDescriptor(
val _videoSources: Array<VideoUrlSource>
): VideoMuxedSourceDescriptor(), ISerializedVideoSourceDescriptor {
@kotlinx.serialization.Transient
override val videoSources: Array<IVideoSource> get() = _videoSources.map { it }.toTypedArray();
};
@@ -1,15 +1,16 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
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.models.streams.sources.VideoUrlSource
@kotlinx.serialization.Serializable
class SerializedVideoNonMuxedSourceDescriptor(
val _videoSources: Array<VideoUrlSource>,
val _audioSources: Array<AudioUrlSource>
): VideoUnMuxedSourceDescriptor(), ISerializedVideoSourceDescriptor {
@kotlinx.serialization.Transient
override val videoSources: Array<IVideoSource> get() = _videoSources.map { it }.toTypedArray();
@kotlinx.serialization.Transient
override val audioSources: Array<IAudioSource> get() = _audioSources.map { it }.toTypedArray();
};
@@ -1,14 +1,14 @@
package com.futo.platformplayer.api.media.platforms.js
import android.content.Context
import com.futo.platformplayer.states.StateDeveloper
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.states.StateApp
import java.util.*
import com.futo.platformplayer.states.StateDeveloper
import java.util.UUID
class DevJSClient : JSClient {
override val id: String
@@ -20,14 +20,14 @@ class DevJSClient : JSClient {
val devID: String;
constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV")), null, script) {
constructor(context: Context, config: SourcePluginConfig, script: String, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, devID: String? = null, settings: HashMap<String, String?>? = null): super(context, SourcePluginDescriptor(config, auth?.toEncrypted(), captcha?.toEncrypted(), listOf("DEV"), settings), null, script) {
_devScript = script;
_auth = auth;
_captcha = captcha;
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
onCaptchaException.subscribe { client, captcha ->
StateApp.instance.handleCaptchaException(client, captcha);
onCaptchaException.subscribe { client, c ->
StateApp.instance.handleCaptchaException(client, c);
}
}
//TODO: Misisng auth/captcha pass on purpose?
@@ -37,8 +37,8 @@ class DevJSClient : JSClient {
_captcha = captcha;
this.devID = devID ?: UUID.randomUUID().toString().substring(0, 5);
onCaptchaException.subscribe { client, captcha ->
StateApp.instance.handleCaptchaException(client, captcha);
onCaptchaException.subscribe { client, c ->
StateApp.instance.handleCaptchaException(client, c);
}
}
@@ -49,7 +49,7 @@ class DevJSClient : JSClient {
_auth = auth;
}
fun recreate(context: Context): DevJSClient {
return DevJSClient(context, config, _devScript, _auth, _captcha, devID);
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
}
override fun getCopy(): JSClient {
@@ -4,17 +4,16 @@ import android.content.Context
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueBoolean
import com.caoccao.javet.values.primitive.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueNull
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformClientCapabilities
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
@@ -22,15 +21,29 @@ import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
import com.futo.platformplayer.api.media.platforms.js.internal.*
import com.futo.platformplayer.api.media.platforms.js.models.*
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSOptional
import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
import com.futo.platformplayer.api.media.platforms.js.models.JSCommentPager
import com.futo.platformplayer.api.media.platforms.js.models.JSContentPager
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveChatWindowDescriptor
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.exceptions.PluginEngineException
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
@@ -38,6 +51,8 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.ImageVariable
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@@ -48,13 +63,13 @@ open class JSClient : IPlatformClient {
val config: SourcePluginConfig;
protected val _context: Context;
private val _plugin: V8Plugin;
private val plugin: V8Plugin get() = _plugin ?: throw IllegalStateException("Client not enabled");
private val plugin: V8Plugin get() = _plugin
var descriptor: SourcePluginDescriptor
private set;
private val _client: JSHttpClient;
private val _clientAuth: JSHttpClient?;
private val _httpClient: JSHttpClient;
private val _httpClientAuth: JSHttpClient?;
private var _searchCapabilities: ResultCapabilities? = null;
private var _searchChannelContentsCapabilities: ResultCapabilities? = null;
private var _channelCapabilities: ResultCapabilities? = null;
@@ -91,6 +106,19 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
fun getSubscriptionRateLimit(): Int? {
val pluginRateLimit = config.subscriptionRateLimit;
val settingsRateLimit = descriptor.appSettings.rateLimit.getSubRateLimit();
if(settingsRateLimit > 0) {
if(pluginRateLimit != null)
return settingsRateLimit.coerceAtMost(pluginRateLimit);
else
return settingsRateLimit;
}
else
return pluginRateLimit;
}
val onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
@@ -104,9 +132,9 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, null, _client, _clientAuth);
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
@@ -133,9 +161,9 @@ open class JSClient : IPlatformClient {
_captcha = descriptor.getCaptchaData();
flags = descriptor.flags.toTypedArray();
_client = JSHttpClient(this, null, _captcha);
_clientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, script, _client, _clientAuth);
_httpClient = JSHttpClient(this, null, _captcha);
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
_plugin.withDependency(context, "scripts/polyfil.js");
_plugin.withDependency(context, "scripts/source.js");
_plugin.withScript(script);
@@ -154,6 +182,13 @@ open class JSClient : IPlatformClient {
fun getUnderlyingPlugin(): V8Plugin {
return _plugin;
}
fun getHttpClientById(id: String): JSHttpClient? {
if(_httpClient.clientId == id)
return _httpClient;
if(_httpClientAuth?.clientId == id)
return _httpClientAuth;
return plugin.httpClientOthers[id];
}
override fun initialize() {
Logger.i(TAG, "Plugin [${config.name}] initializing");
@@ -181,6 +216,7 @@ open class JSClient : IPlatformClient {
hasGetChannelCapabilities = plugin.executeBoolean("!!source.getChannelCapabilities") ?: false,
hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false,
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
);
try {
@@ -226,7 +262,7 @@ open class JSClient : IPlatformClient {
@JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform")
override fun getHome(): IPager<IPlatformContent> = isBusyWith {
ensureEnabled();
return@isBusyWith JSContentPager(config, plugin,
return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.getHome()"));
}
@@ -264,7 +300,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("channelId", "(optional) Channel id to search in")
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
ensureEnabled();
return@isBusyWith JSContentPager(config, plugin,
return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
}
@@ -288,7 +324,7 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasSearchChannelContents)
throw IllegalStateException("This plugin does not support channel search");
return@isBusyWith JSContentPager(config, plugin,
return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.searchChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
}
@@ -297,7 +333,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("query", "Query that channels should match")
override fun searchChannels(query: String): IPager<PlatformAuthorLink> = isBusyWith {
ensureEnabled();
return@isBusyWith JSChannelPager(config, plugin,
return@isBusyWith JSChannelPager(config, this,
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
}
@@ -344,7 +380,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("filters", "(optional) Filters to apply on contents")
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> = isBusyWith {
ensureEnabled();
return@isBusyWith JSContentPager(config, plugin,
return@isBusyWith JSContentPager(config, this,
plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
}
@@ -410,10 +446,21 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("url", "A content url (this platform)")
override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith {
ensureEnabled();
return@isBusyWith IJSContentDetails.fromV8(config,
return@isBusyWith IJSContentDetails.fromV8(this,
plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})"));
}
@JSOptional //getContentChapters = function(url, initialData)
@JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details")
@JSDocsParameter("url", "A content url (this platform)")
override fun getContentChapters(url: String): List<IChapter> = isBusyWith {
if(!capabilities.hasGetContentChapters)
return@isBusyWith listOf();
ensureEnabled();
return@isBusyWith JSChapter.fromV8(config,
plugin.executeTyped("source.getContentChapters(${Json.encodeToString(url)})"));
}
@JSOptional
@JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url")
@JSDocsParameter("url", "A content url (this platform)")
@@ -437,13 +484,13 @@ open class JSClient : IPlatformClient {
if (pager !is V8ValueObject) { //TODO: Maybe solve this better
return@isBusyWith EmptyPager<IPlatformComment>();
}
return@isBusyWith JSCommentPager(config, plugin, pager);
return@isBusyWith JSCommentPager(config, this, pager);
}
@JSDocs(17, "source.getSubComments(comment)", "Gets replies for a given comment")
@JSDocsParameter("comment", "Comment object that was returned by getComments")
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
ensureEnabled();
return comment.getReplies(this) ?: JSCommentPager(config, plugin,
return comment.getReplies(this) ?: JSCommentPager(config, this,
plugin.executeTyped("source.getSubComments(${Json.encodeToString(comment as JSComment)})"));
}
@@ -462,7 +509,7 @@ open class JSClient : IPlatformClient {
if(!capabilities.hasGetLiveEvents)
return@isBusyWith null;
ensureEnabled();
return@isBusyWith JSLiveEventPager(config, plugin,
return@isBusyWith JSLiveEventPager(config, this,
plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})"));
}
@JSDocs(19, "source.searchPlaylists(query)", "Searches for playlists on the platform")
@@ -475,7 +522,7 @@ open class JSClient : IPlatformClient {
ensureEnabled();
if(!capabilities.hasSearchPlaylists)
throw IllegalStateException("This plugin does not support playlist search");
return@isBusyWith JSContentPager(config, plugin, plugin.executeTyped("source.searchPlaylists(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.searchPlaylists(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})"));
}
@JSOptional
@JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform")
@@ -491,7 +538,7 @@ open class JSClient : IPlatformClient {
@JSDocsParameter("url", "Url of playlist")
override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith {
ensureEnabled();
return@isBusyWith JSPlaylistDetails(plugin, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})"));
return@isBusyWith JSPlaylistDetails(this, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})"));
}
@JSOptional
@@ -558,7 +605,7 @@ open class JSClient : IPlatformClient {
if(it.containsKey(claimType)) {
val templates = it[claimType];
if(templates != null)
for(value in values.keys.sortedBy { it }) {
for(value in values.keys.sortedBy { if(it == config.primaryClaimFieldType) Int.MIN_VALUE else it }) {
if(templates.containsKey(value)) {
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
}
@@ -1,20 +1,17 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')";
}
fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize());
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
}
private fun serialize(): String {
@@ -25,20 +22,10 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
val TAG = "SourceAuth";
fun fromEncrypted(encrypted: String?): SourceAuth? {
if(encrypted == null)
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceAuth {
private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers);
}
@@ -1,7 +1,5 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -13,7 +11,7 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize());
return SourceEncrypted.fromDecrypted { serialize() }.toJson();
}
private fun serialize(): String {
@@ -21,20 +19,10 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
}
companion object {
val TAG = "SourceAuth";
val TAG = "SourceCaptchaData";
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
if(encrypted == null)
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
}
fun deserialize(str: String): SourceCaptchaData {
@@ -0,0 +1,59 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.GEncryptionProvider
import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.Exception
@Serializable
data class SourceEncrypted(
val encrypted: String,
val version: Int = GEncryptionProvider.version
) {
fun toJson(): String {
return Json.encodeToString(this);
}
companion object {
fun fromDecrypted(serializer: () -> String): SourceEncrypted {
return SourceEncrypted(GEncryptionProvider.instance.encrypt(serializer()));
}
fun <T> decryptEncrypted(encrypted: String?, deserializer: (decrypted: String) -> T): T? {
if(encrypted == null)
return null;
try {
val encryptedSourceAuth = Json.decodeFromString<SourceEncrypted>(encrypted)
if (encryptedSourceAuth.version != GEncryptionProvider.version) {
throw Exception("Invalid encryption version.");
}
val decrypted = GEncryptionProvider.instance.decrypt(encryptedSourceAuth.encrypted);
try {
return deserializer(decrypted);
} catch(ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} catch (e: Throwable) {
//Try to fall back to old mechanism, remove this eventually
if (!encrypted.contains("version")) {
val decrypted = GEncryptionProviderV0.instance.decrypt(encrypted);
try {
return deserializer(decrypted);
} catch (ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} else {
return null;
}
}
}
}
}
@@ -5,9 +5,8 @@ import com.futo.platformplayer.SignatureProvider
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.states.StatePlugins
import kotlinx.serialization.decodeFromString
import java.net.URL
import java.util.*
import java.util.UUID
@kotlinx.serialization.Serializable
class SourcePluginConfig(
@@ -41,10 +40,12 @@ class SourcePluginConfig(
val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed.
var platformUrl: String? = null,
var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true,
var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf()
var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null
) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -142,9 +143,11 @@ class SourcePluginConfig(
val description: String,
val type: String,
val default: String? = null,
val variable: String? = null
val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
) {
@kotlinx.serialization.Transient
val variableOrName: String get() = variable ?: name;
}
}
@@ -1,7 +1,8 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField
import kotlinx.serialization.Serializable
@@ -25,17 +26,19 @@ class SourcePluginDescriptor {
@kotlinx.serialization.Transient
val onCaptchaChanged = Event0();
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null) {
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, settings: HashMap<String, String?>? = null) {
this.config = config;
this.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = listOf();
this.settings = settings ?: hashMapOf();
}
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List<String>) {
constructor(config :SourcePluginConfig, authEncrypted: String? = null, captchaEncrypted: String? = null, flags: List<String>, settings: HashMap<String, String?>? = null) {
this.config = config;
this.authEncrypted = authEncrypted;
this.captchaEncrypted = captchaEncrypted;
this.flags = flags;
this.settings = settings ?: hashMapOf();
}
fun getSettingsWithDefaults(): HashMap<String, String?> {
@@ -66,25 +69,48 @@ class SourcePluginDescriptor {
@Serializable
class AppPluginSettings {
@FormField("Visibility", "group", "Enable where this plugin's content are visible.", 2)
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
var tabEnabled = TabEnabled();
@Serializable
class TabEnabled {
@FormField("Home", FieldForm.TOGGLE, "Show content in home tab", 1)
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
var enableHome: Boolean? = null;
@FormField("Search", FieldForm.TOGGLE, "Show content in search results", 2)
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
var enableSearch: Boolean? = null;
}
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
var rateLimit = RateLimit();
@Serializable
class RateLimit {
@FormField(R.string.subscriptions, FieldForm.DROPDOWN, R.string.ratelimit_sub_setting_description, 1)
@DropdownFieldOptions("Plugin defined", "25", "50", "75", "100", "125", "150", "200")
var rateLimitSubs: Int = 0;
fun getSubRateLimit(): Int {
return when(rateLimitSubs) {
0 -> -1
1 -> 25
2 -> 50
3 -> 75
4 -> 100
5 -> 125
6 -> 150
7 -> 200
else -> -1
}
}
}
fun loadDefaults(config: SourcePluginConfig) {
if(tabEnabled.enableHome == null)
tabEnabled.enableHome = config.enableInHome ?: true;
tabEnabled.enableHome = config.enableInHome
if(tabEnabled.enableSearch == null)
tabEnabled.enableSearch = config.enableInSearch ?: true;
tabEnabled.enableSearch = config.enableInSearch
}
}
@@ -1,31 +1,41 @@
package com.futo.platformplayer.api.media.platforms.js.internal
import android.net.Uri
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.JSRequest
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.matchesDomain
import java.util.UUID
class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?;
val clientId = UUID.randomUUID().toString();
var doUpdateCookies: Boolean = true;
var doApplyCookies: Boolean = true;
var doAllowNewCookies: Boolean = true;
val isLoggedIn: Boolean get() = _auth != null;
private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
private var _otherCookieMap: HashMap<String, HashMap<String, String>>;
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() {
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
_jsClient = jsClient;
_jsConfig = config;
_auth = auth;
_captcha = captcha;
_currentCookieMap = hashMapOf();
_otherCookieMap = hashMapOf();
if(!auth?.cookieMap.isNullOrEmpty()) {
for(domainCookies in auth!!.cookieMap!!)
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
@@ -43,13 +53,49 @@ class JSHttpClient : ManagedHttpClient {
override fun clone(): ManagedHttpClient {
val newClient = JSHttpClient(_jsClient, _auth);
newClient._currentCookieMap = if(_currentCookieMap != null)
HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
else
hashMapOf();
newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
return newClient;
}
//TODO: Use this in beforeRequest to remove dup code
fun applyHeaders(url: Uri, headers: MutableMap<String, String>, applyAuth: Boolean = false, applyOtherCookies: Boolean = false) {
val domain = url.host!!.lowercase();
val auth = _auth;
if (applyAuth && auth != null) {
//TODO: Possibly add doApplyHeaders
for (header in auth.headers.filter { domain.matchesDomain(it.key) }.flatMap { it.value.entries })
headers.put(header.key, header.value);
}
if(doApplyCookies && (applyAuth || applyOtherCookies)) {
val cookiesToApply = hashMapOf<String, String>();
if(applyOtherCookies)
synchronized(_otherCookieMap) {
for(cookie in _otherCookieMap
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
}
if(applyAuth)
synchronized(_currentCookieMap) {
for(cookie in _currentCookieMap
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
};
if(cookiesToApply.size > 0) {
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
val existingCookies = headers["Cookie"];
if(!existingCookies.isNullOrEmpty())
headers.put("Cookie", existingCookies.trim(';') + "; " + cookieString);
else
headers.put("Cookie", cookieString);
}
}
}
override fun beforeRequest(request: okhttp3.Request): okhttp3.Request {
val domain = request.url.host.lowercase();
val auth = _auth;
@@ -65,10 +111,10 @@ class JSHttpClient : ManagedHttpClient {
}
if(doApplyCookies) {
if (!_currentCookieMap.isNullOrEmpty()) {
if (_currentCookieMap.isNotEmpty()) {
val cookiesToApply = hashMapOf<String, String>();
synchronized(_currentCookieMap!!) {
for(cookie in _currentCookieMap!!
synchronized(_currentCookieMap) {
for(cookie in _currentCookieMap
.filter { domain.matchesDomain(it.key) }
.flatMap { it.value.toList() })
cookiesToApply[cookie.first] = cookie.second;
@@ -87,8 +133,12 @@ class JSHttpClient : ManagedHttpClient {
}
}
_jsClient?.validateUrlOrThrow(request.url.toString());
return newBuilder?.let { it.build() } ?: request;
if(_jsClient != null)
_jsClient.validateUrlOrThrow(request.url.toString());
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
return newBuilder?.build() ?: request;
}
override fun afterRequest(resp: okhttp3.Response): okhttp3.Response {
@@ -98,85 +148,65 @@ class JSHttpClient : ManagedHttpClient {
val defaultCookieDomain =
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
for (header in resp.headers) {
if ((_auth != null || _currentCookieMap.isNotEmpty()) && header.first.lowercase() == "set-cookie") {
//val newCookies = cookieStringToMap(header.second.split("; "));
if(header.first.lowercase() == "set-cookie") {
var domainToUse = domain;
val cookie = cookieStringToPair(header.second);
//for (cookie in newCookies) {
var cookieValue = cookie.second;
var domainToUse = domain;
var cookieValue = cookie.second;
if (!cookie.first.isNullOrEmpty() && !cookie.second.isNullOrEmpty()) {
val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0)
continue;
cookieValue = cookieParts[0].trim();
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
val cookieParts = cookie.second.split(";");
if (cookieParts.size == 0)
continue;
cookieValue = cookieParts[0].trim();
val cookieVariables = cookieParts.drop(1).map {
val splitIndex = it.indexOf("=");
if (splitIndex < 0)
return@map Pair(it.trim().lowercase(), "");
return@map Pair<String, String>(
it.substring(0, splitIndex).lowercase().trim(),
it.substring(splitIndex + 1).trim()
);
}.toMap();
domainToUse = if (cookieVariables.containsKey("domain"))
cookieVariables["domain"]!!.lowercase();
else defaultCookieDomain;
}
val cookieVariables = cookieParts.drop(1).map {
val splitIndex = it.indexOf("=");
if (splitIndex < 0)
return@map Pair(it.trim().lowercase(), "");
return@map Pair<String, String>(
it.substring(0, splitIndex).lowercase().trim(),
it.substring(splitIndex + 1).trim()
);
}.toMap();
domainToUse = if (cookieVariables.containsKey("domain"))
cookieVariables["domain"]!!.lowercase();
else defaultCookieDomain;
//TODO: Make sure this has no negative effect besides apply cookies to root domain
if(!domainToUse.startsWith("."))
domainToUse = ".${domainToUse}";
}
val cookieMap = if (_currentCookieMap!!.containsKey(domainToUse))
_currentCookieMap!![domainToUse]!!;
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
_currentCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_currentCookieMap!!.put(domainToUse, newMap)
_currentCookieMap[domainToUse] = newMap
newMap;
}
if(cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap.put(cookie.first, cookieValue);
//}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
else {
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
_otherCookieMap[domainToUse]!!;
else {
val newMap = hashMapOf<String, String>();
_otherCookieMap[domainToUse] = newMap
newMap;
}
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
cookieMap[cookie.first] = cookieValue;
}
}
}
}
return resp;
}
private fun cookieStringToMap(parts: List<String>): Map<String, String> {
val map = hashMapOf<String, String>();
for(cookie in parts) {
val pair = cookieStringToPair(cookie)
map.put(pair.first, pair.second);
}
return map;
}
private fun cookieStringToPair(cookie: String): Pair<String, String> {
val cookieKey = cookie.substring(0, cookie.indexOf("="));
val cookieVal = cookie.substring(cookie.indexOf("=") + 1);
return Pair(cookieKey.trim(), cookieVal.trim());
}
//Prints out code for test reproduction..
fun printTestCode(url: String, body: ByteArray?, headers: Map<String, String>, cookieString: String, allHeaders: Map<String, String>? = null) {
var code = "Code: \n";
code += "\nurl = \"${url}\";";
if(body != null)
code += "\nbody = \"${String(body).replace("\"", "\\\"")}\";";
if(headers != null)
for(header in headers) {
code += "\nclient.Headers.Add(\"${header.key}\", \"${header.value}\");";
}
if(cookieString != null)
code += "\nclient.Headers.Add(\"Cookie\", \"${cookieString}\");";
if(allHeaders != null) {
code += "\n//OTHER HEADERS:"
for (header in allHeaders) {
code += "\nclient.Headers.Add(\"${header.key}\", \"${header.value}\");";
}
}
Logger.i("Testing", code);
}
}
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
@@ -10,19 +11,21 @@ import com.futo.platformplayer.getOrThrow
interface IJSContent: IPlatformContent {
companion object {
fun fromV8(config: SourcePluginConfig, obj: V8ValueObject): IPlatformContent {
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent {
val config = plugin.config;
val type: Int = obj.getOrThrow(config, "contentType", "ContentItem");
val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null);
//TODO: Temporary workaround for intercepting details in lists
if(pluginType != null && pluginType.endsWith("Details"))
return IJSContentDetails.fromV8(config, obj);
return IJSContentDetails.fromV8(plugin, obj);
return when(ContentType.fromInt(type)) {
ContentType.MEDIA -> JSVideo(config, obj);
ContentType.POST -> JSPost(config, obj);
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -4,17 +4,18 @@ import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrThrow
interface IJSContentDetails: IPlatformContent {
companion object {
fun fromV8(config: SourcePluginConfig, obj: V8ValueObject): IPlatformContentDetails {
val type: Int = obj.getOrThrow(config, "contentType", "ContentDetails");
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails {
val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails");
return when(ContentType.fromInt(type)) {
ContentType.MEDIA -> JSVideoDetails(config, obj);
ContentType.POST -> JSPostDetails(config, obj);
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
ContentType.POST -> JSPostDetails(plugin.config, obj);
else -> throw NotImplementedError("Unknown content type ${type}");
}
}
@@ -2,13 +2,14 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {}
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
override fun convertResult(obj: V8ValueObject): PlatformAuthorLink {
return PlatformAuthorLink.fromV8(config, obj);
@@ -0,0 +1,45 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueArray
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.chapters.IChapter
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
class JSChapter : IChapter {
override val name: String;
override val type: ChapterType;
override val timeStart: Double;
override val timeEnd: Double;
constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
this.name = name;
this.timeStart = timeStart;
this.timeEnd = timeEnd;
this.type = type;
}
companion object {
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject): IChapter {
val context = "Chapter";
val name = obj.getOrThrow<String>(config,"name", context);
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
return JSChapter(name, timeStart, timeEnd, type);
}
fun fromV8(config: IV8PluginConfig, arr: V8ValueArray): List<IChapter> {
return arr.keys.mapNotNull {
val obj = arr.get<V8ValueObject>(it);
return@mapNotNull fromV8(config, obj);
};
}
}
}
@@ -5,6 +5,7 @@ import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
@@ -60,6 +61,7 @@ class JSComment : IPlatformComment {
return null;
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
return JSCommentPager(_config!!, _plugin!!, obj);
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
return JSCommentPager(_config!!, plugin, obj);
}
}
@@ -2,15 +2,16 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.engine.V8Plugin
class JSCommentPager : JSPager<IPlatformComment>, IPager<IPlatformComment> {
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) { }
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) { }
override fun convertResult(obj: V8ValueObject): IPlatformComment {
return JSComment(config, plugin, obj);
return JSComment(config, plugin.getUnderlyingPlugin(), obj);
}
}
@@ -3,15 +3,16 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.IPluginSourced
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.V8Plugin
class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
override val sourceConfig: SourcePluginConfig get() = config;
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {}
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
override fun convertResult(obj: V8ValueObject): IPlatformContent {
return IJSContent.fromV8(config, obj);
return IJSContent.fromV8(plugin, obj);
}
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
import com.futo.platformplayer.engine.V8Plugin
@@ -10,7 +11,7 @@ import com.futo.platformplayer.getOrThrow
class JSLiveEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
override var nextRequest: Int;
constructor(config: SourcePluginConfig, plugin: V8Plugin, pager: V8ValueObject) : super(config, plugin, pager) {
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
}
@@ -0,0 +1,36 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.states.StatePlatform
//TODO: Refactor into video-only
class JSLockedContent: IPlatformLockedContent, JSContent {
override val contentType: ContentType get() = ContentType.LOCKED;
override val lockContentType: ContentType get() = ContentType.MEDIA;
override val lockDescription: String?;
override val unlockUrl: String?;
override val contentName: String?;
override val contentThumbnails: Thumbnails;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformLockedContent";
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
return@let Thumbnails.fromV8(config, it);
} ?: Thumbnails();
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
}
}

Some files were not shown because too many files have changed in this diff Show More