Repo created
15
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
1
android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
100
android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import groovy.json.JsonSlurper
|
||||
|
||||
|
||||
class VersionInfo {
|
||||
val appId: String
|
||||
val version: String
|
||||
val versionCode: Int
|
||||
constructor(givenId: String, givenVersion: String, givenCode: Int) {
|
||||
appId = givenId
|
||||
version = givenVersion
|
||||
versionCode = givenCode
|
||||
}
|
||||
}
|
||||
|
||||
fun getVersionInfo(project: Project): VersionInfo {
|
||||
val json = JsonSlurper()
|
||||
val packageJsonPath = project.file("../../package.json")
|
||||
|
||||
val packageJson = json.parse(packageJsonPath) as Map<String, Any>
|
||||
val versionName = packageJson["version"] as String
|
||||
val appName = "io.freetubeapp." + packageJson["name"]
|
||||
val parts = versionName.split("-")
|
||||
val numbers = parts[0].split(".")
|
||||
val major = numbers[0].toInt()
|
||||
val minor = numbers[1].toInt()
|
||||
val patch = numbers[2].toInt()
|
||||
var build = 0
|
||||
if (parts.size > 2) {
|
||||
println(parts)
|
||||
build = parts[2].toInt()
|
||||
} else if (numbers.size > 3) {
|
||||
build = numbers[3].toInt()
|
||||
}
|
||||
|
||||
val versionCode = major * 10000000 + minor * 10000000 + patch * 1000 + build
|
||||
|
||||
return VersionInfo(appName, versionName, versionCode)
|
||||
}
|
||||
|
||||
val versionInfo = getVersionInfo(project)
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
signingConfigs {
|
||||
getByName("debug") {
|
||||
// inject signing config
|
||||
};
|
||||
}
|
||||
namespace = "io.freetubeapp.freetube"
|
||||
compileSdk = 34
|
||||
dataBinding {
|
||||
enable = true
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId = versionInfo.appId
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = versionInfo.versionCode
|
||||
versionName = versionInfo.version
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// in this case debug is just a name of a signing config
|
||||
// the release workflow injects the release keystore info into the "debug" signing config
|
||||
// i tried to add a signing config called "release", but i got build errors :(
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
|
||||
}
|
||||
21
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
106
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
|
||||
<supports-screens
|
||||
android:anyDensity="true"
|
||||
android:smallScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:largeScreens="true"
|
||||
android:resizeable="true"
|
||||
android:xlargeScreens="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FreeTubeAndroid"
|
||||
android:hardwareAccelerated="true"
|
||||
android:grantUriPermissions="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<service
|
||||
android:name=".KeepAliveService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="false">
|
||||
</service>
|
||||
|
||||
<receiver android:name=".MediaControlsReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="play" />
|
||||
<action android:name="pause" />
|
||||
<action android:name="previous" />
|
||||
<action android:name="next" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.FreeTubeAndroid"
|
||||
android:configChanges="screenSize|smallestScreenSize|orientation|screenLayout|uiMode"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
</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="https" />
|
||||
<data android:host="m.youtube.com" />
|
||||
<data android:host="www.youtube.com" />
|
||||
<data android:host="youtube.com" />
|
||||
<data android:host="youtu.be" />
|
||||
<!-- supported links -->
|
||||
<data android:host="invidious.fdn.fr" />
|
||||
<data android:host="vid.puffyan.us" />
|
||||
<data android:host="invidious.flokinet.to" />
|
||||
<data android:host="inv.bp.projectsegfau.lt" />
|
||||
<data android:host="invidious.lunar.icu" />
|
||||
<data android:host="invidious.io.lol" />
|
||||
<data android:host="inv.tux.pizza" />
|
||||
<data android:host="invidious.privacydev.net" />
|
||||
<data android:host="yt.artemislena.eu" />
|
||||
<data android:host="vid.priv.au" />
|
||||
<data android:host="onion.tube" />
|
||||
<data android:host="yt.oelrichsgarcia.de" />
|
||||
<data android:host="invidious.protokolla.fi" />
|
||||
<data android:host="invidious.asir.dev" />
|
||||
<data android:host="iv.nboeck.de" />
|
||||
<data android:host="invidious.private.coffee" />
|
||||
<data android:host="iv.datura.network" />
|
||||
<data android:host="anontube.lvkaszus.pl" />
|
||||
<data android:host="inv.us.projectsegfau.lt" />
|
||||
<data android:host="invidious.perennialte.ch" />
|
||||
<data android:host="invidious.drgns.space" />
|
||||
<data android:host="invidious.einfachzocken.eu" />
|
||||
<data android:host="invidious.slipfox.xyz" />
|
||||
<data android:host="invidious.no-logs.com" />
|
||||
<data android:host="yt.drgnz.club" />
|
||||
<data android:host="yt.cdaut.de" />
|
||||
<data android:host="iv.melmac.space" />
|
||||
<data android:host="inv.citw.lgbt" />
|
||||
<!-- /supported links -->
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package io.freetubeapp.freetube
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
|
||||
class KeepAliveService : Service() {
|
||||
companion object {
|
||||
private val CHANNEL_ID = "keep_alive"
|
||||
}
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Keep Alive", NotificationManager.IMPORTANCE_MIN)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
} else {
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManager.IMPORTANCE_MIN).build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForeground(1,
|
||||
Notification.Builder(this.applicationContext, CHANNEL_ID)
|
||||
.setContentTitle("FreeTube is running in the background.")
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setSmallIcon(R.drawable.ic_media_notification_icon)
|
||||
.build())
|
||||
} else {
|
||||
startForeground(1,
|
||||
Notification.Builder(this.applicationContext)
|
||||
.setContentTitle("FreeTube is running in the background.")
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.build())
|
||||
}
|
||||
}
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
return START_STICKY
|
||||
}
|
||||
override fun startForegroundService(service: Intent?): ComponentName? {
|
||||
return super.startForegroundService(service)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
package io.freetubeapp.freetube
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.addCallback
|
||||
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.OnRequestPermissionsResultCallback
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.freetubeapp.freetube.databinding.ActivityMainBinding
|
||||
import io.freetubeapp.freetube.helpers.Promise
|
||||
import io.freetubeapp.freetube.javascript.BotGuardJavascriptInterface
|
||||
import io.freetubeapp.freetube.javascript.FreeTubeJavaScriptInterface
|
||||
import io.freetubeapp.freetube.javascript.SigWebViewJavascriptInterface
|
||||
import io.freetubeapp.freetube.javascript.dispatchEvent
|
||||
import io.freetubeapp.freetube.webviews.BackgroundPlayWebView
|
||||
import io.freetubeapp.freetube.webviews.BotGuardWebView
|
||||
import io.freetubeapp.freetube.webviews.SigWebView
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.BlockingQueue
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
// region Keep Alive service
|
||||
private lateinit var keepAliveService: KeepAliveService
|
||||
private lateinit var keepAliveIntent: Intent
|
||||
// endregion
|
||||
|
||||
// region JS interfaces
|
||||
private lateinit var jsInterface: FreeTubeJavaScriptInterface
|
||||
lateinit var sigJsInterface: SigWebViewJavascriptInterface
|
||||
// endregion
|
||||
|
||||
// region Bindings
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
lateinit var webView: BackgroundPlayWebView
|
||||
lateinit var sigWebView: SigWebView
|
||||
lateinit var content: View
|
||||
private var fullscreenView: View? = null
|
||||
// endregion
|
||||
|
||||
// region Callbacks
|
||||
private lateinit var activityResultListeners: MutableList<(ActivityResult?) -> Unit>
|
||||
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
|
||||
// endregion
|
||||
|
||||
// region State Information
|
||||
var consoleMessages: MutableList<JSONObject> = mutableListOf()
|
||||
var showSplashScreen: Boolean = true
|
||||
var darkMode: Boolean = false
|
||||
var paused: Boolean = false
|
||||
var isInAPrompt: Boolean = false
|
||||
// endregion
|
||||
|
||||
// region Thread Pool Executor
|
||||
/*
|
||||
* Gets the number of available cores
|
||||
* (not always the same as the maximum number of cores)
|
||||
*/
|
||||
private val numberOfCores = Runtime.getRuntime().availableProcessors()
|
||||
// Instantiates the queue of Runnables as a LinkedBlockingQueue
|
||||
private val workQueue: BlockingQueue<Runnable> = LinkedBlockingQueue()
|
||||
// Sets the amount of time an idle thread waits before terminating
|
||||
private val keepAliveTime = 1
|
||||
// Sets the Time Unit to seconds
|
||||
private val keepAliveTimeUnit: TimeUnit = TimeUnit.SECONDS
|
||||
// Creates a thread pool manager
|
||||
var threadPoolExecutor = ThreadPoolExecutor(
|
||||
numberOfCores, // Initial pool size
|
||||
numberOfCores, // Max pool size
|
||||
keepAliveTime.toLong(),
|
||||
keepAliveTimeUnit,
|
||||
workQueue
|
||||
)
|
||||
// endregion
|
||||
|
||||
// region Overridden methods
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
|
||||
Configuration.UI_MODE_NIGHT_NO -> {
|
||||
darkMode = false
|
||||
}
|
||||
Configuration.UI_MODE_NIGHT_YES -> {
|
||||
darkMode = true
|
||||
}
|
||||
}
|
||||
|
||||
content = findViewById(android.R.id.content)
|
||||
content.viewTreeObserver.addOnPreDrawListener(
|
||||
object : ViewTreeObserver.OnPreDrawListener {
|
||||
override fun onPreDraw(): Boolean {
|
||||
// Check whether the initial data is ready.
|
||||
return if (!showSplashScreen) {
|
||||
// The content is ready. Start drawing.
|
||||
content.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
true
|
||||
} else {
|
||||
// The content isn't ready. Suspend.
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
activityResultListeners = mutableListOf()
|
||||
|
||||
activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
for (listener in activityResultListeners) {
|
||||
listener(it)
|
||||
}
|
||||
// clear the listeners
|
||||
activityResultListeners = mutableListOf()
|
||||
}
|
||||
|
||||
MediaControlsReceiver.notifyMediaSessionListeners = {
|
||||
action ->
|
||||
webView.dispatchEvent("media-$action")
|
||||
}
|
||||
|
||||
// this keeps android from shutting off the app to conserve battery
|
||||
keepAliveService = KeepAliveService()
|
||||
keepAliveIntent = Intent(this, keepAliveService.javaClass)
|
||||
startService(keepAliveIntent)
|
||||
|
||||
// this gets the controller for hiding and showing the system bars
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val windowInsetsController =
|
||||
WindowCompat.getInsetsController(window, window.decorView)
|
||||
windowInsetsController.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
webView = binding.webView
|
||||
webView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
// bind the back button to the web-view history
|
||||
onBackPressedDispatcher.addCallback {
|
||||
if (isInAPrompt) {
|
||||
webView.dispatchEvent("exit-prompt")
|
||||
jsInterface.exitPromptMode()
|
||||
} else {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
} else {
|
||||
this@MainActivity.moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webView.settings.javaScriptEnabled = true
|
||||
|
||||
// this is the 🥃 special sauce that makes local api streaming a possibility
|
||||
webView.settings.allowUniversalAccessFromFileURLs = true
|
||||
webView.settings.allowFileAccessFromFileURLs = true
|
||||
// allow playlist ▶auto-play in background
|
||||
webView.settings.mediaPlaybackRequiresUserGesture = false
|
||||
|
||||
jsInterface = FreeTubeJavaScriptInterface(this)
|
||||
webView.addJavascriptInterface(jsInterface, "Android")
|
||||
webView.webChromeClient = object: WebChromeClient() {
|
||||
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
||||
val messageData = JSONObject()
|
||||
messageData.put("content", consoleMessage.message())
|
||||
messageData.put("level", consoleMessage.messageLevel())
|
||||
messageData.put("timestamp", System.currentTimeMillis())
|
||||
messageData.put("id", UUID.randomUUID())
|
||||
messageData.put("key", "${messageData["id"]}-${messageData["timestamp"]}")
|
||||
messageData.put("sourceId", consoleMessage.sourceId())
|
||||
messageData.put("lineNumber", consoleMessage.lineNumber())
|
||||
consoleMessages.add(messageData)
|
||||
webView.dispatchEvent("console-message", "data", messageData)
|
||||
return super.onConsoleMessage(consoleMessage);
|
||||
}
|
||||
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
fullscreenView = view!!
|
||||
view.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
this@MainActivity.binding.root.addView(view)
|
||||
webView.visibility = View.GONE
|
||||
this@MainActivity.binding.root.fitsSystemWindows = false
|
||||
webView.dispatchEvent("start-fullscreen")
|
||||
}
|
||||
|
||||
override fun onHideCustomView() {
|
||||
webView.visibility = View.VISIBLE
|
||||
this@MainActivity.binding.root.removeView(fullscreenView)
|
||||
fullscreenView = null
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
this@MainActivity.binding.root.fitsSystemWindows = true
|
||||
webView.dispatchEvent("end-fullscreen")
|
||||
}
|
||||
}
|
||||
webView.webViewClient = object: WebViewClient() {
|
||||
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): WebResourceResponse? {
|
||||
// TODO refactor this to work for video streaming
|
||||
/*
|
||||
// LEFTOVER iOS WORKAROUND CODE
|
||||
if (request!!.requestHeaders.containsKey("x-user-agent")) {
|
||||
with (URL(request!!.url.toString()).openConnection() as HttpURLConnection) {
|
||||
requestMethod = request.method
|
||||
val isClient5 = request.requestHeaders.containsKey("x-youtube-client-name") && request.requestHeaders["x-youtube-client-name"] == "5"
|
||||
// map headers
|
||||
for (header in request!!.requestHeaders) {
|
||||
fun getReal(key: String, value: String): Array<String>? {
|
||||
if (key == "x-user-agent") {
|
||||
return arrayOf("User-Agent", value)
|
||||
}
|
||||
if (key == "User-Agent") {
|
||||
return null
|
||||
}
|
||||
if (key == "x-fta-request-id") {
|
||||
return null
|
||||
}
|
||||
if (isClient5) {
|
||||
if (key == "referrer") {
|
||||
return null
|
||||
}
|
||||
if (key == "origin") {
|
||||
return null
|
||||
}
|
||||
if (key == "Sec-Fetch-Site") {
|
||||
return null
|
||||
}
|
||||
if (key == "Sec-Fetch-Mode") {
|
||||
return null
|
||||
}
|
||||
if (key == "Sec-Fetch-Dest") {
|
||||
return null
|
||||
}
|
||||
if (key == "sec-ch-ua") {
|
||||
return null
|
||||
}
|
||||
if (key == "sec-ch-ua-mobile") {
|
||||
return null
|
||||
}
|
||||
if (key == "sec-ch-ua-platform") {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return arrayOf(key, value)
|
||||
}
|
||||
val real = getReal(header.key, header.value)
|
||||
if (real !== null) {
|
||||
setRequestProperty(real[0], real[1])
|
||||
}
|
||||
}
|
||||
if (request.requestHeaders.containsKey("x-fta-request-id")) {
|
||||
if (pendingRequestBodies.containsKey(request.requestHeaders["x-fta-request-id"])) {
|
||||
val body = pendingRequestBodies[request.requestHeaders["x-fta-request-id"]]
|
||||
pendingRequestBodies.remove(request.requestHeaders["x-fta-request-id"])
|
||||
outputStream.write(body!!.toByteArray())
|
||||
}
|
||||
}
|
||||
// 🧝♀️ magic
|
||||
return WebResourceResponse(this.contentType, this.contentEncoding, inputStream!!)
|
||||
}
|
||||
}
|
||||
*/
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
if (request!!.url!!.scheme == "file") {
|
||||
// don't send file url requests to a web browser (it will crash the app)
|
||||
return true
|
||||
}
|
||||
val regex = """^https?:\/\/((www\.)?youtube\.com(\/embed)?|youtu\.be)\/.*$"""
|
||||
|
||||
if (Regex(regex).containsMatchIn(request!!.url!!.toString())) {
|
||||
webView.dispatchEvent("youtube-link", "link", request!!.url!!.toString())
|
||||
return true
|
||||
}
|
||||
// send all requests to a real web browser
|
||||
val intent = Intent(Intent.ACTION_VIEW, request!!.url)
|
||||
this@MainActivity.startActivity(intent)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (intent!!.data !== null) {
|
||||
val url = intent!!.data.toString()
|
||||
val host = intent!!.data!!.host.toString()
|
||||
val intentPath = if (host != "youtube.com" && host != "youtu.be" && host != "m.youtube.com" && host != "www.youtube.com") {
|
||||
url.replace("${intent!!.data!!.host}", "youtube.com")
|
||||
} else {
|
||||
url
|
||||
}
|
||||
val intentEncoded = URLEncoder.encode(intentPath)
|
||||
webView.loadUrl("file:///android_asset/index.html?intent=${intentEncoded}")
|
||||
} else {
|
||||
webView.loadUrl("file:///android_asset/index.html")
|
||||
}
|
||||
|
||||
|
||||
sigWebView = binding.sigWebView
|
||||
sigJsInterface = SigWebViewJavascriptInterface(sigWebView, jsInterface.jsCommunicator)
|
||||
sigWebView.addJavascriptInterface(sigJsInterface, "Android")
|
||||
sigWebView.settings.javaScriptEnabled = true
|
||||
sigWebView.webChromeClient = object: WebChromeClient() {
|
||||
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
||||
val messageData = JSONObject()
|
||||
messageData.put("content", consoleMessage.message())
|
||||
messageData.put("level", consoleMessage.messageLevel())
|
||||
messageData.put("timestamp", System.currentTimeMillis())
|
||||
messageData.put("id", UUID.randomUUID())
|
||||
messageData.put("key", "${messageData["id"]}-${messageData["timestamp"]}")
|
||||
messageData.put("sourceId", consoleMessage.sourceId())
|
||||
messageData.put("lineNumber", consoleMessage.lineNumber())
|
||||
consoleMessages.add(messageData)
|
||||
webView.dispatchEvent("console-message", "data", messageData)
|
||||
return super.onConsoleMessage(consoleMessage)
|
||||
}
|
||||
}
|
||||
sigWebView.loadUrl("file:///android_asset/decipher.html")
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
when (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
|
||||
Configuration.UI_MODE_NIGHT_NO -> {
|
||||
darkMode = false
|
||||
webView.dispatchEvent("enabled-light-mode")
|
||||
}
|
||||
Configuration.UI_MODE_NIGHT_YES -> {
|
||||
darkMode = true
|
||||
webView.dispatchEvent("enabled-dark-mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* handles new intents which involve deep links (aka supported links)
|
||||
*/
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
if (intent!!.data !== null) {
|
||||
val uri = intent!!.data
|
||||
val isYT =
|
||||
uri!!.host!! == "www.youtube.com" || uri!!.host!! == "youtube.com" || uri!!.host!! == "m.youtube.com" || uri!!.host!! == "youtu.be"
|
||||
val url = if (!isYT) {
|
||||
uri.toString().replace(uri.host.toString(), "www.youtube.com")
|
||||
} else {
|
||||
uri
|
||||
}
|
||||
webView.dispatchEvent("youtube-link", "link", url.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
paused = true
|
||||
webView.dispatchEvent("app-pause")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
paused = false
|
||||
webView.dispatchEvent("app-resume")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// stop the keep alive service
|
||||
stopService(keepAliveIntent)
|
||||
// cancel media notification (if there is one)
|
||||
jsInterface.cancelMediaNotification()
|
||||
// clean up the web view
|
||||
webView.destroy()
|
||||
// call `super`
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun listenForActivityResults(listener: (ActivityResult?) -> Unit) {
|
||||
activityResultListeners.add(listener)
|
||||
}
|
||||
|
||||
fun launchIntent(intent: Intent): Promise<ActivityResult?, Exception> {
|
||||
return Promise(threadPoolExecutor, {
|
||||
resolve,
|
||||
reject ->
|
||||
try {
|
||||
listenForActivityResults {
|
||||
resolve(it)
|
||||
}
|
||||
activityResultLauncher.launch(intent)
|
||||
} catch (exception: Exception) {
|
||||
reject(exception)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun generateBgWebview(): BotGuardWebView {
|
||||
val wv = BotGuardWebView(this)
|
||||
wv.settings.javaScriptEnabled = true
|
||||
wv.settings.allowUniversalAccessFromFileURLs = true
|
||||
wv.webChromeClient = object: WebChromeClient() {
|
||||
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
||||
val messageData = JSONObject()
|
||||
messageData.put("content", consoleMessage.message())
|
||||
messageData.put("level", consoleMessage.messageLevel())
|
||||
messageData.put("timestamp", System.currentTimeMillis())
|
||||
messageData.put("id", UUID.randomUUID())
|
||||
messageData.put("key", "${messageData["id"]}-${messageData["timestamp"]}")
|
||||
messageData.put("sourceId", consoleMessage.sourceId())
|
||||
messageData.put("lineNumber", consoleMessage.lineNumber())
|
||||
consoleMessages.add(messageData)
|
||||
webView.dispatchEvent("console-message", "data", messageData)
|
||||
return super.onConsoleMessage(consoleMessage)
|
||||
}
|
||||
}
|
||||
return wv
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package io.freetubeapp.freetube
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
open class MediaControlsReceiver : BroadcastReceiver {
|
||||
|
||||
constructor() {
|
||||
}
|
||||
companion object Static {
|
||||
lateinit var notifyMediaSessionListeners: (String) -> Unit
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val action = intent!!.action
|
||||
notifyMediaSessionListeners(action!!)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package io.freetubeapp.freetube.helpers
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
fun AssetManager.readText(assetName: String) : String {
|
||||
val lines = mutableListOf<String>()
|
||||
val reader = BufferedReader(InputStreamReader(open(assetName)))
|
||||
try {
|
||||
var line = reader.readLine()
|
||||
while(line != null) {
|
||||
lines.add(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
// pass
|
||||
} finally {
|
||||
try {
|
||||
reader.close()
|
||||
} catch (ex: Exception) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package io.freetubeapp.freetube.helpers
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
|
||||
fun ContentResolver.readBytes(uri: Uri): ByteArray {
|
||||
val stream = openInputStream(uri)
|
||||
val content = stream!!.readBytes()
|
||||
stream.close()
|
||||
return content
|
||||
}
|
||||
|
||||
fun ContentResolver.writeBytes(uri: Uri, bytes: ByteArray, writeMode: WriteMode = WriteMode.Truncate) {
|
||||
val mode = if (writeMode == WriteMode.Truncate) {
|
||||
"wt"
|
||||
} else if (writeMode == WriteMode.Append) {
|
||||
"wa"
|
||||
} else {
|
||||
"w"
|
||||
}
|
||||
val stream = openOutputStream(uri, mode)
|
||||
stream!!.write(bytes)
|
||||
stream.flush()
|
||||
stream.close()
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package io.freetubeapp.freetube.helpers
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.nio.charset.Charset
|
||||
|
||||
enum class WriteMode {
|
||||
Truncate,
|
||||
Append
|
||||
}
|
||||
|
||||
fun File.readText(): String {
|
||||
return FileInputStream(this).bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
fun File.writeText(content: String, writeMode: WriteMode = WriteMode.Truncate, createIfDoesntExist: Boolean = true) {
|
||||
if (!this.exists() && createIfDoesntExist) {
|
||||
createNewFile()
|
||||
}
|
||||
if (writeMode == WriteMode.Truncate) {
|
||||
writeText(content, Charset.forName("utf-8"))
|
||||
} else {
|
||||
appendText(content, Charset.forName("utf-8"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package io.freetubeapp.freetube.helpers
|
||||
|
||||
import io.freetubeapp.freetube.javascript.AsyncJSCommunicator
|
||||
import java.util.UUID.randomUUID
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
|
||||
class Promise<T, G> {
|
||||
private val successListeners: MutableList<(T) -> Unit> = mutableListOf()
|
||||
private var successResult: T? = null
|
||||
private val errorListeners: MutableList<(G) -> Unit> = mutableListOf()
|
||||
private var errorResult: G? = null
|
||||
private val id = "${randomUUID()}"
|
||||
|
||||
constructor(executor: ThreadPoolExecutor, runnable: ((T) -> Unit, (G) -> Unit) -> Unit) {
|
||||
executor.run {
|
||||
runnable.invoke({
|
||||
result ->
|
||||
notifySuccess(result)
|
||||
}, {
|
||||
result ->
|
||||
notifyError(result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun addJsCommunicator(communicator: AsyncJSCommunicator) : String {
|
||||
then {
|
||||
communicator.resolve(id, "$it")
|
||||
}
|
||||
catch {
|
||||
communicator.reject(id, "$it")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
fun notifySuccess(result: T) {
|
||||
successResult = result
|
||||
successListeners.forEach {
|
||||
listener ->
|
||||
listener.invoke(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyError(result: G) {
|
||||
errorResult = result
|
||||
errorListeners.forEach {
|
||||
listener ->
|
||||
listener.invoke(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun then(listener: (T) -> Unit): Promise<T, G> {
|
||||
if (successResult != null) {
|
||||
// assume success result won't be unset
|
||||
listener(successResult!!)
|
||||
} else {
|
||||
successListeners.add(listener)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun catch(listener: (G) -> Unit): Promise<T, G> {
|
||||
if (errorResult != null) {
|
||||
// assume success result won't be unset
|
||||
listener(errorResult!!)
|
||||
} else {
|
||||
errorListeners.add(listener)
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package io.freetubeapp.freetube.helpers
|
||||
|
||||
import android.graphics.Color
|
||||
|
||||
fun String.hexToColour() : Int {
|
||||
return if (length == 7) {
|
||||
Color.rgb(
|
||||
Integer.valueOf(substring(1, 3), 16),
|
||||
Integer.valueOf(substring(3, 5), 16),
|
||||
Integer.valueOf(substring(5, 7), 16)
|
||||
)
|
||||
} else if (length == 4) {
|
||||
val r = substring(1, 2)
|
||||
val g = substring(2, 3)
|
||||
val b = substring(3, 4)
|
||||
Color.rgb(
|
||||
Integer.valueOf("$r$r", 16),
|
||||
Integer.valueOf("$g$g", 16),
|
||||
Integer.valueOf("$b$b", 16)
|
||||
)
|
||||
} else {
|
||||
Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package io.freetubeapp.freetube.javascript
|
||||
|
||||
import android.webkit.WebView
|
||||
import java.util.UUID.randomUUID
|
||||
|
||||
class AsyncJSCommunicator(givenWebView: WebView) {
|
||||
private val webView = givenWebView
|
||||
private var syncMessages: MutableMap<String, String> = HashMap()
|
||||
|
||||
/**
|
||||
* @return the id of a promise on the window
|
||||
*/
|
||||
fun jsPromise(): String {
|
||||
return "${randomUUID()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* resolves a js promise given the id
|
||||
*/
|
||||
fun resolve(id: String, message: String) {
|
||||
syncMessages[id] = message
|
||||
webView.dispatchEvent("$id-resolve")
|
||||
}
|
||||
|
||||
/**
|
||||
* rejects a js promise given the id
|
||||
*/
|
||||
fun reject(id: String, message: String) {
|
||||
syncMessages[id] = message
|
||||
webView.dispatchEvent("$id-reject")
|
||||
}
|
||||
|
||||
fun getSyncMessage(promise: String): String {
|
||||
val value = syncMessages[promise]
|
||||
syncMessages.remove(promise)
|
||||
return value!!
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package io.freetubeapp.freetube.javascript
|
||||
|
||||
import android.webkit.JavascriptInterface
|
||||
import io.freetubeapp.freetube.MainActivity
|
||||
|
||||
class BotGuardJavascriptInterface(main: MainActivity) {
|
||||
private var poToken: String? = null
|
||||
private var tokenListeners: MutableList<(String) -> Unit> = mutableListOf()
|
||||
val pendingRequestBodies: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
@JavascriptInterface
|
||||
fun queueBody(id: String, body: String) {
|
||||
pendingRequestBodies[id] = body
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun returnToken(token: String) {
|
||||
notify(token)
|
||||
poToken = token
|
||||
}
|
||||
|
||||
fun notify(token: String) {
|
||||
tokenListeners.forEach {
|
||||
it(token)
|
||||
}
|
||||
tokenListeners = mutableListOf()
|
||||
}
|
||||
|
||||
fun onReturnToken(callback: (String) -> Unit) {
|
||||
if (poToken != null) {
|
||||
callback(poToken!!)
|
||||
} else {
|
||||
tokenListeners.add(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,770 @@
|
|||
package io.freetubeapp.freetube.javascript
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.media.session.PlaybackState.STATE_PAUSED
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.WindowManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.activity.result.ActivityResult
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import io.freetubeapp.freetube.MainActivity
|
||||
import io.freetubeapp.freetube.MediaControlsReceiver
|
||||
import io.freetubeapp.freetube.R
|
||||
import io.freetubeapp.freetube.helpers.Promise
|
||||
import io.freetubeapp.freetube.helpers.hexToColour
|
||||
import io.freetubeapp.freetube.helpers.readBytes
|
||||
import io.freetubeapp.freetube.helpers.readText
|
||||
import io.freetubeapp.freetube.helpers.writeBytes
|
||||
import io.freetubeapp.freetube.helpers.writeText
|
||||
import io.freetubeapp.freetube.webviews.BotGuardWebView
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.Charset
|
||||
import java.util.UUID.*
|
||||
|
||||
|
||||
class FreeTubeJavaScriptInterface {
|
||||
private var context: MainActivity
|
||||
private var mediaSession: MediaSession?
|
||||
private var lastPosition: Long
|
||||
private var lastState: Int
|
||||
private var lastNotification: Notification? = null
|
||||
private var keepScreenOn: Boolean = false
|
||||
val jsCommunicator: AsyncJSCommunicator
|
||||
|
||||
companion object {
|
||||
private const val DATA_DIRECTORY = "data://"
|
||||
private const val CHANNEL_ID = "media_controls"
|
||||
private val NOTIFICATION_ID = (2..1000).random()
|
||||
private val NOTIFICATION_TAG = String.format("%s", randomUUID())
|
||||
}
|
||||
|
||||
constructor(main: MainActivity) {
|
||||
context = main
|
||||
mediaSession = null
|
||||
lastPosition = 0
|
||||
lastState = PlaybackState.STATE_PLAYING
|
||||
jsCommunicator = AsyncJSCommunicator(main.webView)
|
||||
}
|
||||
|
||||
// region Media Notifications
|
||||
|
||||
/**
|
||||
* retrieves actions for the media controls
|
||||
* @param state the current state of the media controls (ex PlaybackState.STATE_PLAYING or PlaybackState.STATE_PAUSED
|
||||
*/
|
||||
private fun getActions(state: Int = lastState): Array<Notification.Action> {
|
||||
var neutralAction = arrayOf("Pause", "pause")
|
||||
var neutralIcon = androidx.media3.ui.R.drawable.exo_icon_pause
|
||||
if (state == PlaybackState.STATE_PAUSED) {
|
||||
neutralAction = arrayOf("Play", "play")
|
||||
neutralIcon = androidx.media3.ui.R.drawable.exo_icon_play
|
||||
}
|
||||
return arrayOf(
|
||||
Notification.Action.Builder(
|
||||
androidx.media3.ui.R.drawable.exo_ic_skip_previous,
|
||||
"Back",
|
||||
PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction("previous"), PendingIntent.FLAG_IMMUTABLE)
|
||||
).build(),
|
||||
Notification.Action.Builder(
|
||||
neutralIcon,
|
||||
neutralAction[0],
|
||||
PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction(neutralAction[1]), PendingIntent.FLAG_IMMUTABLE)
|
||||
).build(),
|
||||
Notification.Action.Builder(
|
||||
androidx.media3.ui.R.drawable.exo_ic_skip_next,
|
||||
"Next",
|
||||
PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction("next"), PendingIntent.FLAG_IMMUTABLE)
|
||||
).build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieves the media style for the media controls notification
|
||||
*/
|
||||
private fun getMediaStyle(): Notification.MediaStyle? {
|
||||
if (mediaSession != null) {
|
||||
return Notification.MediaStyle()
|
||||
.setMediaSession(mediaSession!!.sessionToken).setShowActionsInCompactView(0, 1, 2)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a fresh media controls notification given the current `mediaSession`
|
||||
* @param actions a list of actions for the media controls (defaults to `getActions()`)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getMediaControlsNotification(actions: Array<Notification.Action> = getActions()): Notification? {
|
||||
val mediaStyle = getMediaStyle()
|
||||
if (mediaStyle != null) {
|
||||
// when clicking the notification, launch the app as if the user tapped on it in their launcher (open an existing instance if able)
|
||||
val notificationIntent = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setClass(context, MainActivity::class.java)
|
||||
|
||||
// always reuse notification
|
||||
if (lastNotification != null) {
|
||||
lastNotification!!.actions = actions
|
||||
return lastNotification
|
||||
}
|
||||
lastNotification = Notification.Builder(context, CHANNEL_ID)
|
||||
.setStyle(getMediaStyle())
|
||||
.setSmallIcon(R.drawable.ic_media_notification_icon)
|
||||
.addAction(
|
||||
actions[0]
|
||||
)
|
||||
.addAction(
|
||||
actions[1]
|
||||
)
|
||||
.addAction(
|
||||
actions[2]
|
||||
)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context, 1, notificationIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.setDeleteIntent(
|
||||
PendingIntent.getBroadcast(context, 1, Intent(context, MediaControlsReceiver::class.java).setAction("pause"), PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
return lastNotification
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pushes a notification
|
||||
* @param notification the notification the be pushed (usually a media controls notification)
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun pushNotification(notification: Notification) {
|
||||
if (lastNotification !== null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// always set notifications to pause before sending another on android 13+
|
||||
setState(mediaSession!!, STATE_PAUSED)
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* sets the state of the media session
|
||||
* @param session the current media session
|
||||
* @param state the state of playback
|
||||
* @param position the position in milliseconds of playback
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun setState(session: MediaSession, state: Int, position: Long? = null) {
|
||||
|
||||
if (state != lastState) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// need to reissue a notification if we want to update the actions
|
||||
var actions = getActions(state)
|
||||
val notification = getMediaControlsNotification(actions)
|
||||
pushNotification(notification!!)
|
||||
}
|
||||
}
|
||||
lastState = state
|
||||
var statePosition: Long
|
||||
if (position == null) {
|
||||
statePosition = lastPosition
|
||||
} else {
|
||||
statePosition = position
|
||||
}
|
||||
session.setPlaybackState(
|
||||
PlaybackState.Builder()
|
||||
.setState(state, statePosition, 0.0f)
|
||||
.setActions(PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackState.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackState.ACTION_PLAY_FROM_SEARCH or PlaybackState.ACTION_SEEK_TO)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* sets the metadata of the media session
|
||||
* @param session the current media session
|
||||
* @param trackName the video name
|
||||
* @param artist the channel name
|
||||
* @param duration duration in milliseconds
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun setMetadata(session: MediaSession, trackName: String, artist: String, duration: Long, art: String?, pushNotification: Boolean = true) {
|
||||
var notification: Notification? = null
|
||||
if (pushNotification) {
|
||||
notification = getMediaControlsNotification()
|
||||
}
|
||||
|
||||
if (art != null) {
|
||||
// todo move this to a function and add try catch
|
||||
val connection = URL(art).openConnection()
|
||||
connection.connect()
|
||||
val input = connection.getInputStream()
|
||||
val bitmapArt = BitmapFactory.decodeStream(input)
|
||||
// todo
|
||||
session.setMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, trackName)
|
||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
|
||||
.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmapArt)
|
||||
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmapArt)
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, duration)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
session.setMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, trackName)
|
||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, artist)
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, duration)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
if (pushNotification && notification != null) {
|
||||
pushNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* creates (or updates) a media session
|
||||
* @param title the track name / video title
|
||||
* @param artist the author / channel name
|
||||
* @param duration the duration in milliseconds of the video
|
||||
* @param thumbnail a URL to the thumbnail for the video
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@JavascriptInterface
|
||||
fun createMediaSession(title: String, artist: String, duration: Long = 0, thumbnail: String? = null) {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
val channel = notificationManager.getNotificationChannel(CHANNEL_ID, "Media Controls")
|
||||
?: NotificationChannel(CHANNEL_ID, "Media Controls", NotificationManager.IMPORTANCE_MIN)
|
||||
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
var session: MediaSession
|
||||
|
||||
// don't create multiple sessions or multiple channels
|
||||
if (mediaSession == null) {
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
// add the callbacks && listeners
|
||||
|
||||
session = MediaSession(context, CHANNEL_ID)
|
||||
session.isActive = true
|
||||
mediaSession = session
|
||||
session.setCallback(object : MediaSession.Callback() {
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
context.webView.dispatchEvent("media-next")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious()
|
||||
context.webView.dispatchEvent("media-previous")
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
super.onSeekTo(pos)
|
||||
context.webView.dispatchEvent("media-seek", "position", pos)
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
super.onPlay()
|
||||
context.webView.dispatchEvent("media-play")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
context.webView.dispatchEvent("media-pause")
|
||||
}
|
||||
|
||||
})
|
||||
} else {
|
||||
session = mediaSession!!
|
||||
}
|
||||
|
||||
val notification = getMediaControlsNotification()
|
||||
// use the set metadata function without pushing a notification
|
||||
setMetadata(session, title, artist, duration, thumbnail, false)
|
||||
setState(session, PlaybackState.STATE_PLAYING)
|
||||
|
||||
pushNotification(notification!!)
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the state of the active media session
|
||||
* @param state the state; should be an Int (as a string because the java bridge)
|
||||
* @param position the position; should be a Long (as a string because the java bridge)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun updateMediaSessionState(state: String?, position: String? = null) {
|
||||
var givenState = state?.toInt()
|
||||
if (state == null) {
|
||||
givenState = lastState
|
||||
} else {
|
||||
}
|
||||
if (position != null) {
|
||||
lastPosition = position.toLong()!!
|
||||
}
|
||||
setState(mediaSession!!, givenState!!, position?.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the metadata of the active media session
|
||||
* @param trackName the video title
|
||||
* @param artist the channel name
|
||||
* @param duration the length of the video in milliseconds
|
||||
* @param art the URL to the video thumbnail
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
@JavascriptInterface
|
||||
fun updateMediaSessionData(trackName: String, artist: String, duration: Long, art: String? = null) {
|
||||
setMetadata(mediaSession!!, trackName, artist, duration, art)
|
||||
}
|
||||
|
||||
/**
|
||||
* cancels the active media notification
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun cancelMediaNotification() {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.cancelAll()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region File Helpers
|
||||
|
||||
/**
|
||||
* @param directory a shortened directory uri
|
||||
* @return a full directory uri
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getDirectory(directory: String): String {
|
||||
val path = if (directory == DATA_DIRECTORY) {
|
||||
// this is the directory cordova gave us access to before
|
||||
context.getExternalFilesDir(null)!!.parent
|
||||
} else {
|
||||
directory
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getFileNameFromUri(uri: String): String {
|
||||
var result: String? = null
|
||||
val cursor = context.contentResolver.query(Uri.parse(uri), null, null, null, null)
|
||||
try {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index != -1) {
|
||||
result = cursor.getString(index)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cursor!!.close()
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
result = uri.split(Regex("(/)|(%2F)")).last()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun revokePermissionForTree(treeUri: String) {
|
||||
context.revokeUriPermission(Uri.parse(treeUri), Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun listFilesInTree(tree: String): String {
|
||||
val directory = DocumentFile.fromTreeUri(context, Uri.parse(tree))
|
||||
val files = directory!!.listFiles().joinToString(",") { file ->
|
||||
"{ \"uri\": \"${file.uri}\", \"fileName\": \"${file.name}\", \"isFile\": ${file.isFile}, \"isDirectory\": ${file.isDirectory} }"
|
||||
}
|
||||
return "[$files]"
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun createFileInTree(tree: String, fileName: String): String {
|
||||
val directory = DocumentFile.fromTreeUri(context, Uri.parse(tree))
|
||||
return directory!!.createFile("*/*", fileName)!!.uri.toString()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun createDirectoryInTree(tree: String, fileName: String): String {
|
||||
val directory = DocumentFile.fromTreeUri(context, Uri.parse(tree))
|
||||
return directory!!.createDirectory(fileName)!!.uri.toString()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun deleteFileInTree(fileUri: String): Boolean {
|
||||
val file = DocumentFile.fromTreeUri(context, Uri.parse(fileUri))
|
||||
return file!!.delete()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region IO
|
||||
/**
|
||||
* reads a file from storage
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun readFile(basedir: String, filename: String): String {
|
||||
return Promise(context.threadPoolExecutor, {
|
||||
resolve,
|
||||
reject ->
|
||||
try {
|
||||
if (basedir.startsWith("content://")) {
|
||||
resolve(
|
||||
context.contentResolver
|
||||
.readBytes(Uri.parse(basedir))
|
||||
.toString(Charset.forName("utf-8"))
|
||||
)
|
||||
} else {
|
||||
val path = getDirectory(basedir)
|
||||
resolve(File(path, filename).readText())
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
reject(ex.stackTraceToString())
|
||||
}
|
||||
}).addJsCommunicator(jsCommunicator)
|
||||
}
|
||||
|
||||
/**
|
||||
* writes a file to storage
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun writeFile(basedir: String, filename: String, content: String): String {
|
||||
return Promise(context.threadPoolExecutor, {
|
||||
resolve,
|
||||
reject ->
|
||||
try {
|
||||
if (basedir.startsWith("content://")) {
|
||||
// urls created by save dialog
|
||||
context.contentResolver.writeBytes(
|
||||
Uri.parse(basedir),
|
||||
content.toByteArray()
|
||||
)
|
||||
resolve("true")
|
||||
} else {
|
||||
val path = getDirectory(basedir)
|
||||
File(path, filename).writeText(content)
|
||||
resolve("true")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
reject(ex.stackTraceToString())
|
||||
}
|
||||
}).addJsCommunicator(jsCommunicator)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Dialogs
|
||||
/**
|
||||
* requests a save dialog, resolves a js promise when done, resolves with `USER_CANCELED` if the user cancels
|
||||
* @return a js promise id
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun requestSaveDialog(fileName: String, fileType: String): String {
|
||||
return Promise(context.threadPoolExecutor, {
|
||||
resolve,
|
||||
reject
|
||||
->
|
||||
context.launchIntent(
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType(fileType)
|
||||
.putExtra(Intent.EXTRA_TITLE, fileName)
|
||||
).then {
|
||||
if (it!!.resultCode == Activity.RESULT_CANCELED) {
|
||||
resolve("USER_CANCELED")
|
||||
}
|
||||
try {
|
||||
val payload = JSONObject()
|
||||
payload.put("uri", it.data!!.data)
|
||||
resolve(payload)
|
||||
} catch (ex: Exception) {
|
||||
reject(ex.toString())
|
||||
}
|
||||
}
|
||||
}).addJsCommunicator(jsCommunicator)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun requestOpenDialog(fileTypes: String): String {
|
||||
return Promise(context.threadPoolExecutor, {
|
||||
resolve,
|
||||
reject ->
|
||||
context.launchIntent(
|
||||
Intent(Intent.ACTION_GET_CONTENT)
|
||||
.setType("*/*")
|
||||
.putExtra(Intent.EXTRA_MIME_TYPES, fileTypes.split(",").toTypedArray())
|
||||
).then {
|
||||
if (it!!.resultCode == Activity.RESULT_CANCELED) {
|
||||
resolve("USER_CANCELED")
|
||||
}
|
||||
try {
|
||||
val uri = it.data!!.data
|
||||
val mimeType = context.contentResolver.getType(uri!!)
|
||||
val fileName = getFileNameFromUri(uri.toString())
|
||||
val payload = JSONObject()
|
||||
payload.put("uri", uri)
|
||||
payload.put("type", mimeType)
|
||||
payload.put("fileName", fileName)
|
||||
resolve(payload)
|
||||
} catch (ex: Exception) {
|
||||
reject(ex.toString())
|
||||
}
|
||||
}
|
||||
}).addJsCommunicator(jsCommunicator)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun requestDirectoryAccessDialog(): String {
|
||||
return Promise(context.threadPoolExecutor, {
|
||||
resolve,
|
||||
reject ->
|
||||
context.launchIntent(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
).then {
|
||||
if (it!!.resultCode == Activity.RESULT_CANCELED) {
|
||||
resolve("USER_CANCELED")
|
||||
}
|
||||
try {
|
||||
val uri = it.data!!.data!!
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
resolve(uri)
|
||||
} catch (ex: Exception) {
|
||||
reject(ex.toString())
|
||||
}
|
||||
}
|
||||
}).addJsCommunicator(jsCommunicator)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region System
|
||||
|
||||
@JavascriptInterface
|
||||
fun getLogs(): String {
|
||||
var logs = "["
|
||||
for (message in context.consoleMessages) {
|
||||
logs += "${message},"
|
||||
}
|
||||
// get rid of trailing comma
|
||||
logs = logs.substring(0, logs.length - 1)
|
||||
logs += "]"
|
||||
return logs
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun restart() {
|
||||
context.finish()
|
||||
context.startActivity(Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setClass(context, MainActivity::class.java))
|
||||
}
|
||||
|
||||
/**
|
||||
* hides the splashscreen
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun hideSplashScreen() {
|
||||
context.showSplashScreen = false
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun enableKeepScreenOn() {
|
||||
if (!keepScreenOn) {
|
||||
keepScreenOn = true
|
||||
context.runOnUiThread {
|
||||
context.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun disableKeepScreenOn() {
|
||||
if (keepScreenOn) {
|
||||
keepScreenOn = false
|
||||
context.runOnUiThread {
|
||||
context.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* used on the JS side for async js communication
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getSyncMessage(promise: String): String {
|
||||
return jsCommunicator.getSyncMessage(promise)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun themeSystemUi(navigationHex: String, statusHex: String, navigationDarkMode: Boolean = true, statusDarkMode: Boolean = true) {
|
||||
context.runOnUiThread {
|
||||
val windowInsetsController =
|
||||
WindowCompat.getInsetsController(context.window, context.window.decorView)
|
||||
windowInsetsController.isAppearanceLightNavigationBars = !navigationDarkMode
|
||||
windowInsetsController.isAppearanceLightStatusBars = !statusDarkMode
|
||||
context.window.navigationBarColor = navigationHex.hexToColour()
|
||||
context.window.statusBarColor = statusHex.hexToColour()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun getSystemTheme(): String {
|
||||
if (context.darkMode) {
|
||||
return "dark"
|
||||
} else {
|
||||
return "light"
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun isAppPaused(): Boolean {
|
||||
return context.paused
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun enterPromptMode() {
|
||||
context.webView.isVerticalScrollBarEnabled = false
|
||||
context.isInAPrompt = true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun exitPromptMode() {
|
||||
context.webView.isVerticalScrollBarEnabled = true
|
||||
context.isInAPrompt = false
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setScale(scale: Int) {
|
||||
context.webView.setScale(scale / 100.0)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Data Extraction
|
||||
|
||||
private fun getBotGuardScript(videoId: String, sessionContext: String, includeDebugMessage: Boolean = true): String {
|
||||
val script = context.assets.readText("botGuardScript.js")
|
||||
val functionName = script.split("export{")[1].split(" as default};")[0]
|
||||
val exportSection = "export{${functionName} as default};"
|
||||
val then = if (includeDebugMessage) {
|
||||
"(TOKEN_RESULT) => { console.log(`Your potoken is \${TOKEN_RESULT}`); Android.returnToken(TOKEN_RESULT) }"
|
||||
} else {
|
||||
"(TOKEN_RESULT) => { Android.returnToken(TOKEN_RESULT) }"
|
||||
}
|
||||
val bakedScript =
|
||||
script.replace(exportSection, "; ${functionName}(\"$videoId\", $sessionContext).then($then)")
|
||||
return bakedScript
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun generatePOToken(videoId: String, sessionContext: String): String {
|
||||
return Promise(context.threadPoolExecutor, {
|
||||
resolve,
|
||||
reject
|
||||
->
|
||||
context.runOnUiThread {
|
||||
try {
|
||||
val bgScript = getBotGuardScript(videoId, sessionContext)
|
||||
val bgWv = context.generateBgWebview()
|
||||
bgWv.jsInterface.onReturnToken {
|
||||
run {
|
||||
context.runOnUiThread {
|
||||
resolve(it)
|
||||
bgWv.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
context.runOnUiThread {
|
||||
bgWv.loadDataWithBaseURL(
|
||||
"https://www.youtube.com/",
|
||||
"<script>\n" +
|
||||
"window.ofetch = window.fetch\n" +
|
||||
"window.fetch = async (url, data) => {\n" +
|
||||
" if (url.startsWith('https://www.google.com/')) {\n" +
|
||||
" return new Promise((resolve, _) => {" +
|
||||
" const script = document.createElement('script')\n" +
|
||||
" script.src = url\n" +
|
||||
" script.async = true\n" +
|
||||
" document.body.appendChild(script)\n" +
|
||||
" script.addEventListener('load', () => {\n" +
|
||||
" resolve({ text: () => '() => {}' })\n" +
|
||||
" })\n" +
|
||||
" })\n" +
|
||||
" }\n" +
|
||||
" const id = crypto.randomUUID()\n" +
|
||||
" if (data && 'body' in data) {" +
|
||||
" Android.queueBody(id, data.body)\n" +
|
||||
" data.headers['x-fta-request-id'] = id\n" +
|
||||
" }" +
|
||||
" return await window.ofetch(url, data)\n" +
|
||||
"}</script><script>${bgScript}</script>",
|
||||
"text/html",
|
||||
"utf-8",
|
||||
null
|
||||
)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
reject(exception.message!!)
|
||||
}
|
||||
}
|
||||
}).addJsCommunicator(jsCommunicator)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun runDecipherScript(id: String, code: String): String {
|
||||
// pass data to other webview
|
||||
context.sigJsInterface.jsCommunicator.resolve(id, code)
|
||||
// dispatch event to read data
|
||||
context.sigWebView.dispatchEvent("message", "id", id)
|
||||
return id
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
/**
|
||||
@JavascriptInterface
|
||||
fun queueFetchBody(id: String, body: String) {
|
||||
if (body != "undefined") {
|
||||
context.pendingRequestBodies[id] = body
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package io.freetubeapp.freetube.javascript
|
||||
|
||||
import android.util.JsonReader
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import io.freetubeapp.freetube.MainActivity
|
||||
import io.freetubeapp.freetube.webviews.SigWebView
|
||||
import org.json.JSONObject
|
||||
|
||||
class SigWebViewJavascriptInterface(
|
||||
webView: SigWebView,
|
||||
private val remoteJSCommunicator: AsyncJSCommunicator
|
||||
) {
|
||||
val jsCommunicator = AsyncJSCommunicator(webView)
|
||||
|
||||
@JavascriptInterface
|
||||
fun readSync(id: String): String {
|
||||
return jsCommunicator.getSyncMessage(id)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun resolve(id: String, message: String) {
|
||||
remoteJSCommunicator.resolve(id, message)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun reject(id: String, message: String) {
|
||||
remoteJSCommunicator.reject(id, message)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package io.freetubeapp.freetube.javascript
|
||||
|
||||
import android.webkit.WebView
|
||||
import org.json.JSONObject
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* fires and forgets javascript
|
||||
* @param js javascript string to be evaluated
|
||||
*/
|
||||
fun WebView.fafJS(js: String) {
|
||||
post {
|
||||
loadUrl("javascript: $js")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* calls `window.dispatchEvent` with just the event name
|
||||
*/
|
||||
fun WebView.dispatchEvent(eventName: String) {
|
||||
fafJS("window.dispatchEvent(new Event(\"$eventName\"))")
|
||||
}
|
||||
|
||||
/**
|
||||
* calls `window.dispatchEvent` with the given json assigned to the event which is dispatched
|
||||
*/
|
||||
fun WebView.dispatchEvent(eventName: String, event: JSONObject) {
|
||||
var js = "var tempVar = new Event(\"$eventName\");"
|
||||
js += "Object.assign(tempVar, $event);"
|
||||
js += "window.dispatchEvent(tempVar);"
|
||||
fafJS(js)
|
||||
}
|
||||
|
||||
/**
|
||||
* calls `window.dispatchEvent` with an event with a single custom key with a string value
|
||||
*/
|
||||
fun WebView.dispatchEvent(eventName: String, keyName: String, data: String) {
|
||||
val wrapper = JSONObject()
|
||||
wrapper.put(keyName, data)
|
||||
dispatchEvent(eventName, wrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* calls `window.dispatchEvent` with an event with a single custom key with a long value
|
||||
*/
|
||||
fun WebView.dispatchEvent(eventName: String, keyName: String, data: Long) {
|
||||
val wrapper = JSONObject()
|
||||
wrapper.put(keyName, data)
|
||||
dispatchEvent(eventName, wrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* calls `window.dispatchEvent` with an event with a single custom key with a JSON value
|
||||
*/
|
||||
fun WebView.dispatchEvent(eventName: String, keyName: String, data: JSONObject) {
|
||||
val wrapper = JSONObject()
|
||||
wrapper.put(keyName, data)
|
||||
dispatchEvent(eventName, wrapper)
|
||||
}
|
||||
|
||||
/**
|
||||
* encodes a string message for transport across the java bridge
|
||||
* @param message the message to be encoded
|
||||
*/
|
||||
fun btoa(message: String): String {
|
||||
return "atob(\"${String(
|
||||
android.util.Base64.encode(message.toByteArray(), android.util.Base64.DEFAULT),
|
||||
StandardCharsets.UTF_8
|
||||
)}\")"
|
||||
}
|
||||
|
||||
/**
|
||||
* @param message the message to log
|
||||
* @param level used in js as "console.$level" (ex: log, warn, error)
|
||||
*/
|
||||
fun WebView.consoleLog(message: String, level: String = "log") {
|
||||
fafJS("console.$level(${btoa(message)})")
|
||||
}
|
||||
|
||||
fun WebView.consoleError(message: String) {
|
||||
consoleLog(message, "error")
|
||||
}
|
||||
|
||||
fun WebView.consoleWarn(message: String) {
|
||||
consoleLog(message, "warn")
|
||||
}
|
||||
|
||||
fun WebView.setScale(scale: Double) {
|
||||
post {
|
||||
setInitialScale((350 * scale).toInt())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package io.freetubeapp.freetube.webviews
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.webkit.WebView
|
||||
|
||||
open class BackgroundPlayWebView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : WebView(context, attrs) {
|
||||
private var once: Boolean = false
|
||||
override fun onWindowVisibilityChanged(visibility: Int) {
|
||||
if (once) return
|
||||
if (visibility != View.GONE) super.onWindowVisibilityChanged(View.VISIBLE)
|
||||
once = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package io.freetubeapp.freetube.webviews
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import io.freetubeapp.freetube.MainActivity
|
||||
import io.freetubeapp.freetube.javascript.BotGuardJavascriptInterface
|
||||
import io.freetubeapp.freetube.javascript.consoleLog
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class BotGuardWebView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) :
|
||||
// no need to communicate window visibility to botguard
|
||||
BackgroundPlayWebView(context, attrs) {
|
||||
val jsInterface = BotGuardJavascriptInterface(context as MainActivity)
|
||||
init {
|
||||
addJavascriptInterface(jsInterface, "Android")
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): WebResourceResponse? {
|
||||
if (request!!.url.toString().startsWith("data:text/html") || request!!.url.toString().startsWith("https://www.youtube.com/api/jnn/v1/GenerateIT")) {
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
with(URL(request.url.toString()).openConnection() as HttpURLConnection) {
|
||||
requestMethod = request.method
|
||||
// map headers
|
||||
for (header in request!!.requestHeaders) {
|
||||
setRequestProperty(header.key, header.value)
|
||||
}
|
||||
|
||||
if (url.toString().startsWith("https://www.youtube.com/youtubei/")) {
|
||||
setRequestProperty("Referer", "https://www.youtube.com/")
|
||||
setRequestProperty("Origin", "https://www.youtube.com")
|
||||
setRequestProperty("Sec-Fetch-Site", "same-origin")
|
||||
setRequestProperty("Sec-Fetch-Mode", "same-origin")
|
||||
setRequestProperty("X-Youtube-Bootstrap-Logged-In", "false")
|
||||
}
|
||||
if (url.toString().startsWith("https://www.google.com/js/")) {
|
||||
setRequestProperty("referer", "https://www.google.com/")
|
||||
setRequestProperty("origin", "https://www.google.com")
|
||||
setRequestProperty("Sec-Fetch-Dest", "script")
|
||||
setRequestProperty("Sec-Fetch-Site", "cross-site")
|
||||
setRequestProperty("Accept-Language", "*")
|
||||
}
|
||||
if (request.requestHeaders.containsKey("x-fta-request-id")) {
|
||||
if (jsInterface.pendingRequestBodies.containsKey(request.requestHeaders["x-fta-request-id"])) {
|
||||
val body = jsInterface.pendingRequestBodies[request.requestHeaders["x-fta-request-id"]]
|
||||
jsInterface.pendingRequestBodies.remove(request.requestHeaders["x-fta-request-id"])
|
||||
outputStream.write(body!!.toByteArray())
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 🧝♀️ magic
|
||||
return WebResourceResponse(this.contentType, this.contentEncoding, inputStream!!);
|
||||
} catch (ex: Exception) {
|
||||
consoleLog(ex.message!!, "error")
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package io.freetubeapp.freetube.webviews
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import io.freetubeapp.freetube.MainActivity
|
||||
import io.freetubeapp.freetube.javascript.consoleLog
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class SigWebView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) :
|
||||
// no need to communicate window visibility
|
||||
BackgroundPlayWebView(context, attrs) {
|
||||
init {
|
||||
val mainActivity = (context as MainActivity)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): WebResourceResponse? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64"
|
||||
android:width="64dp"
|
||||
android:height="64dp">
|
||||
<path
|
||||
android:pathData="M8.2 5C5.8732 5 4 6.8732001 4 9.2000001V21.8 44.2 56.8C4 59.1268 5.8732 61 8.2 61H20.8 33.960547 43.2C52.5072 61 60 53.5072 60 44.2V36.921093 21.8 9.2000001C60 6.8732001 58.1268 5 55.8 5H43.2 20.8Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.2" />
|
||||
<path
|
||||
android:pathData="M8.2 4C5.8732 4 4 5.8732001 4 8.2000001V20.8 43.2 55.8C4 58.1268 5.8732 60 8.2 60H20.8 33.960547 43.2C52.5072 60 60 52.5072 60 43.2V35.921093 20.8 8.2000001C60 5.8732001 58.1268 4 55.8 4H43.2 20.8Z"
|
||||
android:fillColor="#E4E4E4" />
|
||||
<path
|
||||
android:pathData="M8.1992188 4C5.8724189 4 4 5.8724189 4 8.1992188L4 9.1992188C4 6.8724189 5.8724189 5 8.1992188 5L55.800781 5C58.127581 5 60 6.8724189 60 9.1992188L60 8.1992188C60 5.8724189 58.127581 4 55.800781 4L8.1992188 4z"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.2" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64"
|
||||
android:width="64dp"
|
||||
android:height="64dp">
|
||||
<path android:fillColor="#000000" android:fillAlpha="0.1" android:strokeAlpha="0.1" android:strokeWidth="0.784519" android:pathData="M 18.326366,17.254906 c -1.195215,0 -2.157429,0.99992 -2.157429,2.241704 v 23.535578 c 0,3.362226 4.314857,5.60349 6.472286,5.60349 h 2.157426 V 46.393976 43.032188 19.49661 c 0,-1.241784 -0.962212,-2.241704 -2.157426,-2.241704 z m 11.055286,0 c -1.235245,0 -2.229445,0.962213 -2.229445,2.157429 v 4.314856 c 0,1.195215 0.9942,2.157427 2.229445,2.157427 h 13.378199 c 3.344531,0 5.574378,-4.314856 5.574378,-6.472283 v -2.157429 h -2.229445 -3.344933 z m -1.193635,10.98327 a 1.0130623,0.89550711 0 0 0 -1.03581,0.896375 v 5.773573 5.773572 a 1.0130623,0.89550711 0 0 0 1.518476,0.773792 l 5.657119,-2.886786 5.655587,-2.886786 a 1.0130623,0.89550711 0 0 0 0,-1.550651 L 34.327802,31.24601 28.670683,28.359226 a 1.0130623,0.89550711 0 0 0 -0.482666,-0.12105 z"/>
|
||||
<path android:fillColor="#f04242" android:strokeWidth="0.784519" android:pathData="M 18.326366,16.470386 c -1.195215,0 -2.157429,0.999702 -2.157429,2.241485 V 42.24745 c 0,3.362226 4.314857,5.603709 6.472286,5.603709 h 2.157426 V 45.609676 42.24745 18.711871 c 0,-1.241783 -0.962212,-2.241485 -2.157426,-2.241485 z"/>
|
||||
<path android:fillColor="#f04242" android:strokeWidth="0.784519" android:pathData="M 27.152207,22.942671 c 0,1.195214 0.994442,2.157428 2.229687,2.157428 h 13.378119 c 3.344529,0 5.574216,-4.314857 5.574216,-6.472284 V 16.470386 H 46.104542 42.760013 29.381894 c -1.235245,0 -2.229687,0.962214 -2.229687,2.157429 z"/>
|
||||
<path android:fillColor="#14a4df" android:strokeWidth="0.784519" android:pathData="M 28.188772,27.453898 a 1.0130623,0.89550711 0 0 0 -1.036564,0.896414 v 5.773134 5.773132 a 1.0130623,0.89550711 0 0 0 1.518946,0.774261 l 5.655999,-2.886564 5.656,-2.886568 a 1.0130623,0.89550711 0 0 0 0,-1.550402 l -5.656,-2.886567 -5.655999,-2.886566 a 1.0130623,0.89550711 0 0 0 -0.482382,-0.120274 z"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="64dp"
|
||||
android:height="64dp"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64">
|
||||
|
||||
<path
|
||||
android:fillColor="#f04242"
|
||||
android:strokeWidth="1.45267"
|
||||
android:pathData="M 6.7254451,2.7624871 c -2.1983806,0 -3.9681979,1.86353 -3.9681979,4.178321 V 50.813154 c 0,6.26748 7.9363928,10.445797 11.9045908,10.445797 H 18.63003 V 57.080634 50.813154 6.9408081 c 0,-2.314791 -1.769814,-4.178321 -3.968192,-4.178321 z" />
|
||||
<path
|
||||
android:fillColor="#f04242"
|
||||
android:strokeWidth="1.45267"
|
||||
android:pathData="M 22.958972,14.827384 c 0,2.227981 1.829094,4.02163 4.101102,4.02163 h 24.606614 c 6.151652,0 10.252754,-8.043264 10.252754,-12.0648939 V 2.7624871 H 57.81834 51.666688 27.060074 c -2.272008,0 -4.101102,1.793649 -4.101102,4.021633 z" />
|
||||
<path
|
||||
android:fillColor="#14a4df"
|
||||
android:strokeWidth="1.45267"
|
||||
android:pathData="M 24.865544,23.236699 a 1.8633436,1.6693024 0 0 0 -1.906571,1.670993 v 10.761619 10.761616 a 1.8633436,1.6693024 0 0 0 2.793825,1.443288 l 10.40318,-5.380803 10.403182,-5.380811 a 1.8633436,1.6693024 0 0 0 0,-2.890083 l -10.403182,-5.38081 -10.40318,-5.380807 a 1.8633436,1.6693024 0 0 0 -0.887254,-0.224202 z" />
|
||||
|
||||
</vector>
|
||||
19
android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".MainActivity">
|
||||
<io.freetubeapp.freetube.webviews.BackgroundPlayWebView
|
||||
android:id="@+id/web_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
<io.freetubeapp.freetube.webviews.SigWebView
|
||||
android:id="@+id/sig_web_view"
|
||||
android:layout_width="1920px"
|
||||
android:layout_height="1080px"
|
||||
android:visibility="invisible" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
3
android/app/src/main/res/values-land/dimens.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
11
android/app/src/main/res/values-night-v31/themes.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Base.Theme.FreeTubeAndroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
<item name="android:windowSplashScreenBackground">#212121</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#212121</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
</style>
|
||||
</resources>
|
||||
12
android/app/src/main/res/values-night-v33/themes.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Base.Theme.FreeTubeAndroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
<item name="android:windowSplashScreenBackground">#212121</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#212121</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values-night/themes.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.FreeTubeAndroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
9
android/app/src/main/res/values-v23/themes.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.FreeTubeAndroid" parent="Base.Theme.FreeTubeAndroid">
|
||||
<!-- Transparent system bars for edge-to-edge. -->
|
||||
<item name="android:navigationBarColor">@android:color/background_dark</item>
|
||||
<item name="android:statusBarColor">@android:color/background_dark</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
10
android/app/src/main/res/values-v31/themes.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Base.Theme.FreeTubeAndroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
<item name="android:windowSplashScreenBackground">#E4E4E4</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#E4E4E4</item>
|
||||
</style>
|
||||
</resources>
|
||||
11
android/app/src/main/res/values-v33/themes.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Base.Theme.FreeTubeAndroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
<item name="android:windowSplashScreenBackground">#E4E4E4</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#E4E4E4</item>
|
||||
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||
</style>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values-w1240dp/dimens.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">200dp</dimen>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values-w600dp/dimens.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">48dp</dimen>
|
||||
</resources>
|
||||
5
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/dimens.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<dimen name="fab_margin">16dp</dimen>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">FreeTube Android</string>
|
||||
</resources>
|
||||
9
android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.FreeTubeAndroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.FreeTubeAndroid" parent="Base.Theme.FreeTubeAndroid" />
|
||||
</resources>
|
||||
13
android/app/src/main/res/xml/backup_rules.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
5
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.10" apply false
|
||||
}
|
||||
23
android/gradle.properties
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
6
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#Sat Jan 27 08:18:27 EST 2024
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
185
android/gradlew
vendored
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
18
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "FreeTube Android"
|
||||
include(":app")
|
||||
|
||||