Skip to content

Commit

Permalink
Add ability to export subscriptions, playlists, history
Browse files Browse the repository at this point in the history
  • Loading branch information
MarmadileManteater committed Feb 5, 2024
1 parent ff278ca commit 929010f
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.freetubeapp.freetube

import android.annotation.SuppressLint
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
Expand All @@ -10,13 +11,16 @@ import android.graphics.BitmapFactory
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Build
import android.webkit.JavascriptInterface
import androidx.activity.result.ActivityResult
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationManagerCompat
import java.io.File
import java.io.FileInputStream
import java.net.URL
import java.net.URLEncoder
import java.util.UUID.*

class FreeTubeJavaScriptInterface {
Expand All @@ -41,7 +45,8 @@ class FreeTubeJavaScriptInterface {
}

/**
* Returns a directory given a directory (returns the full directory for shortened directories like `data://`)
* @param directory a shortened directory uri
* @return a full directory uri
*/
private fun getDirectory(directory: String): String {
val path = if (directory == DATA_DIRECTORY) {
Expand Down Expand Up @@ -326,12 +331,18 @@ class FreeTubeJavaScriptInterface {
setMetadata(mediaSession!!, trackName, artist, duration, art)
}

/**
* cancels the active media notification
*/
@JavascriptInterface
fun cancelMediaNotification() {
val manager = NotificationManagerCompat.from(context)
manager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID)
}

/**
* reads a file from storage
*/
@JavascriptInterface
fun readFile(basedir: String, filename: String): String {
try {
Expand All @@ -343,15 +354,85 @@ class FreeTubeJavaScriptInterface {
}
}

/**
* writes a file to storage
*/
@JavascriptInterface
fun writeFile(basedir: String, filename: String, content: String): Boolean {
try {
if (basedir.startsWith("content://")) {
// urls created by save dialog
val stream = context.contentResolver.openOutputStream(Uri.parse(basedir), "wt")
stream!!.write(content.toByteArray())
stream!!.flush()
stream!!.close()
return true
}
val path = getDirectory(basedir)
var file = File(path, filename)
if (!file.exists()) {
file.createNewFile()
}
file.writeText(content)
return true
} catch (ex: Exception) {
return false
}
}

/**
* 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 {
val promise = jsPromise()
val saveDialogIntent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType(fileType)
.putExtra(Intent.EXTRA_TITLE, fileName)
context.listenForActivityResults {
result: ActivityResult? ->
if (result!!.resultCode == Activity.RESULT_CANCELED) {
resolve(promise, "USER_CANCELED")
}
try {
val uri = result!!.data!!.data
// something about the java bridge url decodes all strings, so I am going to double encode this one
val uriBase = "content://com.android.providers.downloads.documents/document/"
var stringUri = uri.toString().replace(uriBase, "")

resolve(promise, "${uriBase}${URLEncoder.encode(stringUri, "utf-8")}")
} catch (ex: Exception) {
reject(promise, ex.toString())
}
}
context.activityResultLauncher.launch(saveDialogIntent)
return promise
}

/**
* @return the id of a promise on the window
*/
private fun jsPromise(): String {
val id = "${randomUUID()}"
context.runOnUiThread {
context.webView.loadUrl("javascript: window['${id}'] = {}; window['${id}'].promise = new Promise((resolve, reject) => { window['${id}'].resolve = resolve; window['${id}'].reject = reject })")
}
return id
}

/**
* resolves a js promise given the id
*/
private fun resolve(id: String, message: String) {
context.webView.loadUrl("javascript: window['${id}'].resolve(\"${message}\")")
}

/**
* rejects a js promise given the id
*/
private fun reject(id: String, message: String) {
context.webView.loadUrl("javascript: window['${id}'].reject(new Error(\"${message}\"))")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ 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
Expand All @@ -21,14 +24,27 @@ class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback {

private lateinit var binding: ActivityMainBinding
private lateinit var permissionsListeners: MutableList<(Int, Array<String?>, IntArray) -> Unit>
private lateinit var activityResultListeners: MutableList<(ActivityResult?) -> Unit>
private lateinit var keepAliveService: KeepAliveService
private lateinit var keepAliveIntent: Intent
private var fullscreenView: View? = null
lateinit var webView: BackgroundPlayWebView
lateinit var jsInterface: FreeTubeJavaScriptInterface
lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

activityResultListeners = mutableListOf()

activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
for (listener in activityResultListeners) {
listener(it)
}
// clear the listeners
activityResultListeners = mutableListOf()
}

MediaControlsReceiver.notifyMediaSessionListeners = {
action ->
webView.loadUrl(String.format("javascript: window.notifyMediaSessionListeners('%s')", action))
Expand Down Expand Up @@ -107,7 +123,22 @@ class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback {
"};" +
"window.clearAllMediaSessionEventListeners = function () {" +
" window.mediaSessionListeners = {}" +
"};"
"};" +
"window.awaitAsyncResult = function (id) {" +
" return new Promise((resolve, reject) => {" +
" const interval = setInterval(async () => {" +
" if (id in window) {" +
" clearInterval(interval);" +
" try {" +
" const result = await window[id].promise;" +
" resolve(result)" +
" } catch (ex) {" +
" reject(ex)" +
" }" +
" }" +
" }, 1)" +
" }) " +
"}"
)
super.onPageFinished(view, url)
}
Expand All @@ -120,6 +151,10 @@ class MainActivity : AppCompatActivity(), OnRequestPermissionsResultCallback {
fun listenForPermissionsCallbacks(listener: (Int, Array<String?>, IntArray) -> Unit) {
permissionsListeners.add(listener)
}
fun listenForActivityResults(listener: (ActivityResult?) -> Unit) {
activityResultListeners.add(listener)
}

override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String?>,
grantResults: IntArray
Expand Down
59 changes: 59 additions & 0 deletions src/renderer/helpers/android.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import android from 'android'
export const STATE_PLAYING = 3
export const STATE_PAUSED = 2

/**
* @typedef SaveDialogResponse
* @property {boolean} canceled
* @property {'SUCCESS'|'USER_CANCELED'} type
* @property {string?} uri
*/

/**
* creates a new media session / or updates the previous one
* @param {string} title
Expand All @@ -15,6 +22,58 @@ export function createMediaSession(title, artist, duration, cover = null) {
android.createMediaSession(title, artist, duration, cover)
}

/**
* Updates the current media session's state
* @param {number} state the playback state, either `STATE_PAUSED` or `STATE_PLAYING`
* @param {number?} position playback position in milliseconds
*/
export function updateMediaSessionState(state, position = null) {
android.updateMediaSessionState(state?.toString() || null, position)
}

/**
* Requests a save with a dialog
* @param {string} fileName name of requested file
* @param {string} fileType mime type
* @returns {Promise<SaveDialogResponse>} either a uri based on the user's input or a cancelled response
*/
export async function requestSaveDialog(fileName, fileType) {
// request a 💾save dialog
const promiseId = android.requestSaveDialog(fileName, fileType)
// await the promise returned from the ☕ bridge
const response = await window.awaitAsyncResult(promiseId)
// handle case if user cancels prompt
if (response === 'USER_CANCELED') {
return {
canceled: true,
type: response,
uri: null
}
} else {
return {
canceled: false,
type: 'SUCCESS',
uri: response
}
}
}

/**
* @param {string} arg1 base uri or path
* @param {string} arg2 path or content
* @param {string?} arg3 content or undefined
* @returns
*/
export function writeFile(arg1, arg2, arg3 = undefined) {
let baseUri, path, content
if (arg3 === undefined) {
baseUri = arg1
path = ''
content = arg2
} else {
baseUri = arg1
path = arg2
content = arg3
}
return android.writeFile(baseUri, path, content)
}
9 changes: 9 additions & 0 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IpcChannels } from '../../constants'
import FtToastEvents from '../components/ft-toast/ft-toast-events'
import i18n from '../i18n/index'
import router from '../router/index'
import { requestSaveDialog, writeFile } from './android'

// allowed characters in channel handle: A-Z, a-z, 0-9, -, _, .
// https://support.google.com/youtube/answer/11585688#change_handle
Expand Down Expand Up @@ -335,6 +336,8 @@ export async function showSaveDialog (options) {
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
return await ipcRenderer.invoke(IpcChannels.SHOW_SAVE_DIALOG, options)
} else if (process.env.IS_ANDROID) {
return await requestSaveDialog(options.defaultPath.split('/').at(-1), 'application/octet-stream')
} else {
// If the native filesystem api is available
if ('showSaveFilePicker' in window) {
Expand Down Expand Up @@ -366,6 +369,12 @@ export async function writeFileFromDialog (response, content) {
if (process.env.IS_ELECTRON) {
const { filePath } = response
return await fs.writeFile(filePath, content)
} else if (process.env.IS_ANDROID) {
/** @type {import('./android').SaveDialogResponse} */
const saveDialogResponse = response
if (!writeFile(saveDialogResponse.uri, content)) {
throw new Error(saveDialogResponse.uri)
}
} else {
if ('showOpenFilePicker' in window) {
const { handle } = response
Expand Down

0 comments on commit 929010f

Please sign in to comment.