Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 08:57:03 +01:00
parent f3a6b3a320
commit f954c78789
614 changed files with 135712 additions and 2 deletions

15
android/.gitignore vendored Normal file
View 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
View file

@ -0,0 +1 @@
/build

View 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
View 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

View 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>

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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!!)
}
}

View file

@ -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")
}

View file

@ -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()
}

View file

@ -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"))
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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!!
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
*/
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}
}
}
}

View file

@ -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
}
}
}
}

View 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>

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">16dp</dimen>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">FreeTube Android</string>
</resources>

View 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>

View 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>

View 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
View 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
View 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

Binary file not shown.

View 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
View 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" "$@"

View 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")