Fix: check permission before media store query, Add: start of casting

This commit is contained in:
advplyr 2021-11-20 17:27:03 -06:00
parent f40e971b90
commit 6bb8dfeffa
10 changed files with 477 additions and 23 deletions

View file

@ -13,8 +13,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 42
versionName "0.9.23-beta"
versionCode 43
versionName "0.9.24-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -29,10 +29,15 @@
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<!-- Support for Cast -->
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name="com.audiobookshelf.app.MainActivity"
android:label="@string/title_activity_main"
android:exported="true"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask">

View file

@ -0,0 +1,28 @@
package com.audiobookshelf.app
import android.content.Context
import android.util.Log
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
Log.d("CastOptionsProvider", "getCastOptions")
return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions(
CastMediaOptions.Builder()
// We manage the media session and the notifications ourselves.
.setMediaSessionEnabled(false)
.setNotificationOptions(null)
.build()
)
.setStopReceiverApplicationWhenEndingSession(true).build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}

View file

@ -1,12 +1,19 @@
package com.audiobookshelf.app
import android.Manifest
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
class LocalMediaManager {
private var ctx: Context
@ -40,8 +47,23 @@ class LocalMediaManager {
fun loadLocalAudio() {
Log.d(tag, "Media store looking for local audio files")
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
Log.e(tag, "Permission not granted to read from external storage")
return
}
val collection =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Audio.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL
)
} else {
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE)
val audioCursor: Cursor? = ctx.contentResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, proj, null, null, null)
val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null)
audioCursor?.use { cursor ->
// Cache column indices.

View file

@ -1,10 +1,14 @@
package com.audiobookshelf.app
import android.Manifest
import android.app.DownloadManager
import android.app.SearchManager
import android.content.*
import android.content.pm.PackageManager
import android.os.*
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.SimpleStorageHelper
import com.getcapacitor.BridgeActivity
@ -41,13 +45,6 @@ class MainActivity : BridgeActivity() {
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// REMOVE FOR TESTING
Log.d(tag, "STARTING UP APP")
// var client: OkHttpClient = OkHttpClient()
// var abManager = AudiobookManager(this, client)
// abManager.init()
// abManager.fetchAudiobooks()
Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)

View file

@ -231,4 +231,27 @@ class MyNativeAudio : Plugin() {
playerNotificationService.cancelSleepTimer()
call.resolve()
}
@PluginMethod
fun requestSession(call:PluginCall) {
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
playerNotificationService.requestSession(mainActivity, object : PlayerNotificationService.RequestSessionCallback() {
override fun onError(errorCode: Int) {
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
}
override fun onCancel() {
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
}
override fun onJoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
}
})
}
}

View file

@ -20,12 +20,18 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import androidx.mediarouter.app.MediaRouteChooserDialog
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.getcapacitor.JSObject
import com.getcapacitor.PluginCall
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.MediaSource
@ -33,8 +39,13 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import com.google.android.gms.cast.Cast.MessageReceivedCallback
import com.google.android.gms.cast.CastDevice
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.*
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.util.*
import kotlin.concurrent.schedule
@ -90,6 +101,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var sleepChapterTime:Long = 0L
private lateinit var audiobookManager:AudiobookManager
private var newConnectionListener:SessionListener? = null
fun setCustomObjectListener(mylistener: MyCustomObjectListener) {
listener = mylistener
@ -490,9 +502,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
terminateStream()
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "PLAY PAUSE TEST")
// transportControls.playFromSearch("Brave New World", Bundle())
if (mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) pause()
handleMediaButtonClickCount()
@ -997,5 +1006,361 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
sleepTimerTask = null
sleepChapterTime = 0L
}
/**
* If Cast is available, create a CastPlayer to handle communication with a Cast session.
*/
private val castPlayer: CastPlayer? by lazy {
try {
val castContext = CastContext.getSharedInstance(this)
CastPlayer(castContext).apply {
setSessionAvailabilityListener(CastSessionAvailabilityListener())
// addListener(playerListener)
}
} catch (e: Exception) {
// We wouldn't normally catch the generic `Exception` however
// calling `CastContext.getSharedInstance` can throw various exceptions, all of which
// indicate that Cast is unavailable.
// Related internal bug b/68009560.
Log.i(tag, "Cast is not available on this device. " +
"Exception thrown when attempting to obtain CastContext. " + e.message)
null
}
}
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
/**
* Called when a Cast session has started and the user wishes to control playback on a
* remote Cast receiver rather than play audio locally.
*/
override fun onCastSessionAvailable() {
// switchToPlayer(currentPlayer, castPlayer!!)
Log.d(tag, "CAST SeSSION AVAILABLE " + castPlayer?.deviceInfo)
mediaSessionConnector.setPlayer(castPlayer)
}
/**
* Called when a Cast session has ended and the user wishes to control playback locally.
*/
override fun onCastSessionUnavailable() {
// switchToPlayer(currentPlayer, exoPlayer)
Log.d(tag, "CAST SESSION UNAVAILABLE")
mediaSessionConnector.setPlayer(mPlayer)
}
}
fun requestSession(mainActivity:Activity, callback: RequestSessionCallback) {
mainActivity.runOnUiThread(object: Runnable {
override fun run() {
Log.d(tag, "CAST RUNNING ON MAIN THREAD")
val session: CastSession? = getSession()
if (session == null) {
// show the "choose a connection" dialog
// Add the connection listener callback
listenForConnection(callback)
// Create the dialog
// TODO accept theme as a config.xml option
val builder = MediaRouteChooserDialog(mainActivity, androidx.appcompat.R.style.Theme_AppCompat_NoActionBar)
builder.routeSelector = MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build()
builder.setCanceledOnTouchOutside(true)
builder.setOnCancelListener {
getSessionManager()!!.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
callback.onCancel()
}
builder.show()
} else {
// We are are already connected, so show the "connection options" Dialog
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
if (session.castDevice != null) {
builder.setTitle(session.castDevice.friendlyName)
}
builder.setOnDismissListener { callback.onCancel() }
builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) }
builder.show()
}
}
})
}
abstract class RequestSessionCallback : ConnectionCallback {
abstract fun onError(errorCode: Int)
abstract fun onCancel()
override fun onSessionEndedBeforeStart(errorCode: Int): Boolean {
onSessionStartFailed(errorCode)
return true
}
override fun onSessionStartFailed(errorCode: Int): Boolean {
onError(errorCode)
return true
}
}
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
override fun onSessionEnded(castSession: CastSession?, error: Int) {
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
Log.d(tag, "CAST END SESSION")
// media.setSession(null)
pluginCall?.resolve()
// listener.onSessionEnd(ChromecastUtilities.createSessionObject(castSession, if (stopCasting) "stopped" else "disconnected"))
}
}, CastSession::class.java)
getSessionManager()!!.endCurrentSession(stopCasting)
}
open class SessionListener : SessionManagerListener<CastSession> {
override fun onSessionStarting(castSession: CastSession?) {}
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {}
override fun onSessionStartFailed(castSession: CastSession?, error: Int) {}
override fun onSessionEnding(castSession: CastSession?) {}
override fun onSessionEnded(castSession: CastSession?, error: Int) {}
override fun onSessionResuming(castSession: CastSession?, sessionId: String) {}
override fun onSessionResumed(castSession: CastSession?, wasSuspended: Boolean) {}
override fun onSessionResumeFailed(castSession: CastSession?, error: Int) {}
override fun onSessionSuspended(castSession: CastSession?, reason: Int) {}
}
private fun startRouteScan() {
var connListener = object: ChromecastListener() {
override fun onReceiverAvailableUpdate(available: Boolean) {
Log.d(tag, "CAST RECEIVER UPDATE AVAILABLE $available")
}
override fun onSessionRejoin(jsonSession: JSONObject?) {
Log.d(tag, "CAST onSessionRejoin")
}
override fun onMediaLoaded(jsonMedia: JSONObject?) {
Log.d(tag, "CAST onMediaLoaded")
}
override fun onMediaUpdate(jsonMedia: JSONObject?) {
Log.d(tag, "CAST onMediaUpdate")
}
override fun onSessionUpdate(jsonSession: JSONObject?) {
Log.d(tag, "CAST onSessionUpdate")
}
override fun onSessionEnd(jsonSession: JSONObject?) {
Log.d(tag, "CAST onSessionEnd")
}
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
Log.d(tag, "CAST onMessageReceived")
}
}
var callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
// if the routes have changed, we may have an available device
// If there is at least one device available
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
// Stop the scan
stopRouteScan(this, null);
// Let the client know a receiver is available
connListener.onReceiverAvailableUpdate(true);
// Since we have a receiver we may also have an active session
var session = getSessionManager()?.currentCastSession;
// If we do have a session
if (session != null) {
// Let the client know
Log.d(tag, "LET SESSION KNOW ABOUT")
// media.setSession(session);
// connListener.onSessionRejoin(ChromecastUtilities.createSessionObject(session));
}
}
}
}
callback.setMediaRouter(getMediaRouter())
callback.onFilteredRouteUpdate();
getMediaRouter()!!.addCallback(MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build(),
callback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
}
internal interface CastListener : MessageReceivedCallback {
fun onMediaLoaded(jsonMedia: JSONObject?)
fun onMediaUpdate(jsonMedia: JSONObject?)
fun onSessionUpdate(jsonSession: JSONObject?)
fun onSessionEnd(jsonSession: JSONObject?)
}
internal abstract class ChromecastListener : CastStateListener, CastListener {
abstract fun onReceiverAvailableUpdate(available: Boolean)
abstract fun onSessionRejoin(jsonSession: JSONObject?)
/** CastStateListener functions. */
override fun onCastStateChanged(state: Int) {
onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE)
}
}
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
if (callback == null) {
completionCallback!!.run()
return
}
// ctx.runOnUiThread(Runnable {
callback.stop()
getMediaRouter()!!.removeCallback(callback)
completionCallback?.run()
// })
}
abstract class ScanCallback : MediaRouter.Callback() {
/**
* Called whenever a route is updated.
* @param routes the currently available routes
*/
abstract fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?)
/** records whether we have been stopped or not. */
private var stopped = false
/** Global mediaRouter object. */
private var mediaRouter: MediaRouter? = null
/**
* Sets the mediaRouter object.
* @param router mediaRouter object
*/
fun setMediaRouter(router: MediaRouter?) {
mediaRouter = router
}
/**
* Call this method when you wish to stop scanning.
* It is important that it is called, otherwise battery
* life will drain more quickly.
*/
fun stop() {
stopped = true
}
fun onFilteredRouteUpdate() {
if (stopped || mediaRouter == null) {
return
}
val outRoutes: MutableList<MediaRouter.RouteInfo> = ArrayList()
// Filter the routes
for (route in mediaRouter!!.routes) {
// We don't want default routes, or duplicate active routes
// or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
val extras: Bundle? = route.extras
if (extras != null) {
CastDevice.getFromBundle(extras)
if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
continue
}
}
if (!route.isDefault
&& !route.description.equals("Google Cast Multizone Member")
&& route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
outRoutes.add(route)
}
}
onRouteUpdate(outRoutes)
}
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
onFilteredRouteUpdate()
}
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
onFilteredRouteUpdate()
}
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
onFilteredRouteUpdate()
}
}
private fun listenForConnection(callback: ConnectionCallback) {
// We should only ever have one of these listeners active at a time, so remove previous
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
newConnectionListener = object : SessionListener() {
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {
Log.d(tag, "CAST SESSION STARTED ${castSession?.castDevice?.friendlyName}")
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
// media.setSession(castSession)
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
}
override fun onSessionStartFailed(castSession: CastSession?, errCode: Int) {
if (callback.onSessionStartFailed(errCode)) {
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
}
}
override fun onSessionEnded(castSession: CastSession?, errCode: Int) {
if (callback.onSessionEndedBeforeStart(errCode)) {
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
}
}
}
getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java)
}
private fun getContext(): CastContext {
return CastContext.getSharedInstance(ctx)
}
private fun getSessionManager(): SessionManager? {
return getContext().sessionManager
}
private fun getMediaRouter(): MediaRouter? {
return MediaRouter.getInstance(ctx)
}
private fun getSession(): CastSession? {
return getSessionManager()?.currentCastSession
}
internal interface ConnectionCallback {
/**
* Successfully joined a session on a route.
* @param jsonSession the session we joined
*/
fun onJoin(jsonSession: JSONObject?)
/**
* Called if we received an error.
* @param errorCode You can find the error meaning here:
* https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
* @return true if we are done listening for join, false, if we to keep listening
*/
fun onSessionStartFailed(errorCode: Int): Boolean
/**
* Called when we detect a session ended event before session started.
* See issues:
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
* @param errorCode error to output
* @return true if we are done listening for join, false, if we to keep listening
*/
fun onSessionEndedBeforeStart(errorCode: Int): Boolean
}
}

View file

@ -11,10 +11,7 @@ import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.*
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "StorageManager")
@ -160,7 +157,17 @@ class StorageManager : Plugin() {
var folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Searching folder $folderUrl")
var df: DocumentFile = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))!!
var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
if (df == null) {
Log.e(TAG, "Folder Doc File Invalid $folderUrl")
var jsobj = JSObject()
jsobj.put("folders", JSArray())
jsobj.put("files", JSArray())
call.resolve(jsobj)
return
}
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
var mediaFolders = mutableListOf<MediaFolder>()

View file

@ -23,6 +23,7 @@
<!-- <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span> -->
<!-- <widgets-connection-icon /> -->
<!-- <span class="material-icons" style="font-size: 1.75rem" @click="testCast">menu</span> -->
<nuxt-link class="h-7 mx-2" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span>
@ -36,6 +37,8 @@
</template>
<script>
// import MyNativeAudio from '@/plugins/my-native-audio'
export default {
data() {
return {
@ -83,6 +86,10 @@ export default {
}
},
methods: {
// testCast() {
// console.log('TEST CAST CLICK')
// MyNativeAudio.requestSession()
// },
clickShowSideDrawer() {
this.$store.commit('setShowSideDrawer', true)
},

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-app",
"version": "v0.9.23-beta",
"version": "v0.9.24-beta",
"author": "advplyr",
"scripts": {
"dev": "nuxt --hostname localhost --port 1337",