init
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.audiobookshelf.app">
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name="com.audiobookshelf.app.MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:enabled="true"
|
||||
android:name=".PlayerNotificationService">
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/toast",
|
||||
"classpath": "com.capacitorjs.plugins.toast.ToastPlugin"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Audiobook {
|
||||
var id:String = "audiobook"
|
||||
var token:String = ""
|
||||
var playlistUrl:String = ""
|
||||
var title:String = "No Title"
|
||||
var author:String = "Unknown"
|
||||
var series:String = ""
|
||||
var cover:String = ""
|
||||
var playWhenReady:Boolean = false
|
||||
var startTime:Long = 0
|
||||
var duration:Long = 0
|
||||
|
||||
var hasPlayerLoaded:Boolean = false
|
||||
|
||||
val playlistUri:Uri
|
||||
val coverUri:Uri
|
||||
|
||||
constructor(jsondata:JSObject) {
|
||||
id = jsondata.getString("id", "audiobook").toString()
|
||||
title = jsondata.getString("title", "No Title").toString()
|
||||
token = jsondata.getString("token", "").toString()
|
||||
author = jsondata.getString("author", "Unknown").toString()
|
||||
series = jsondata.getString("series", "").toString()
|
||||
cover = jsondata.getString("cover", "").toString()
|
||||
playlistUrl = jsondata.getString("playlistUrl", "").toString()
|
||||
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
|
||||
startTime = jsondata.getString("startTime", "0")!!.toLong()
|
||||
duration = jsondata.getString("duration", "0")!!.toLong()
|
||||
|
||||
playlistUri = Uri.parse(playlistUrl)
|
||||
coverUri = Uri.parse(cover)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import com.example.myapp.MyNativeAudio
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
class MainActivity : BridgeActivity() {
|
||||
private val tag = "MainActivity"
|
||||
|
||||
private var mBounded = false
|
||||
lateinit var foregroundService : PlayerNotificationService
|
||||
private lateinit var mConnection : ServiceConnection
|
||||
|
||||
lateinit var pluginCallback : () -> Unit
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(tag, "onCreate")
|
||||
registerPlugin(MyNativeAudio::class.java)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
|
||||
mConnection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
Log.w(tag, "Service Disconnected")
|
||||
mBounded = false
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
Log.d(tag, "Service Connected")
|
||||
|
||||
|
||||
mBounded = true
|
||||
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
||||
foregroundService = mLocalBinder.getService()
|
||||
|
||||
// Let MyNativeAudio know foreground service is ready and setup event listener
|
||||
if (pluginCallback != null) {
|
||||
pluginCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
|
||||
}
|
||||
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
mConnection?.let { unbindService(it) };
|
||||
mBounded = false;
|
||||
}
|
||||
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
stopService(stopIntent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.example.myapp
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.Audiobook
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.PlayerNotificationService
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "MyNativeAudio")
|
||||
class MyNativeAudio : Plugin() {
|
||||
private val tag = "MyNativeAudio"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
lateinit var playerNotificationService: PlayerNotificationService
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
var foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
|
||||
playerNotificationService.setCustomObjectListener(object: PlayerNotificationService.MyCustomObjectListener {
|
||||
override fun onPlayingUpdate(isPlaying:Boolean) {
|
||||
emit("onPlayingUpdate", isPlaying)
|
||||
}
|
||||
override fun onMetadata(metadata:JSObject) {
|
||||
notifyListeners("onMetadata", metadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
mainActivity.pluginCallback = foregroundServiceReady
|
||||
|
||||
}
|
||||
|
||||
fun emit(evtName: String, value:Any) {
|
||||
var ret:JSObject = JSObject()
|
||||
ret.put("value", value)
|
||||
notifyListeners(evtName, ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun initPlayer(call: PluginCall) {
|
||||
if (!PlayerNotificationService.isStarted) {
|
||||
Log.w(tag, "Starting foreground service --")
|
||||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
||||
ContextCompat.startForegroundService(mainActivity, intent)
|
||||
}
|
||||
} else {
|
||||
Log.w(tag, "Service already started --")
|
||||
}
|
||||
|
||||
|
||||
var audiobook:Audiobook = Audiobook(call.data)
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.initPlayer(audiobook)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
Log.d(tag, "Get Current Time $currentTime")
|
||||
val ret = JSObject()
|
||||
ret.put("value", currentTime)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun pausePlayer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.pause()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun playPlayer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.play()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekPlayer(call: PluginCall) {
|
||||
var time:Long = call.getString("timeMs", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekPlayer(time)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekForward10(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekForward10()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@PluginMethod
|
||||
fun seekBackward10(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekBackward10()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@PluginMethod
|
||||
fun terminateStream(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.terminateStream()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.getcapacitor.JSObject
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import com.google.android.exoplayer2.upstream.*
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class PlayerNotificationService : Service() {
|
||||
|
||||
companion object {
|
||||
var isStarted = false
|
||||
}
|
||||
|
||||
interface MyCustomObjectListener {
|
||||
fun onPlayingUpdate(isPlaying: Boolean)
|
||||
fun onMetadata(metadata: JSObject)
|
||||
}
|
||||
|
||||
private val tag = "PlayerService"
|
||||
|
||||
private lateinit var listener:MyCustomObjectListener
|
||||
private lateinit var ctx:Context
|
||||
private lateinit var mPlayer: SimpleExoPlayer
|
||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
private val binder = LocalBinder()
|
||||
private val glideOptions = RequestOptions()
|
||||
.fallback(R.drawable.icon)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
|
||||
private var notificationId = 10;
|
||||
private var channelId = "audiobookshelf_channel"
|
||||
private var channelName = "Audiobookshelf Channel"
|
||||
|
||||
private var currentAudiobook:Audiobook? = null
|
||||
|
||||
fun setCustomObjectListener(mylistener: MyCustomObjectListener) {
|
||||
listener = mylistener
|
||||
}
|
||||
|
||||
/*
|
||||
Service related stuff
|
||||
*/
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
Log.d(tag, "onBind")
|
||||
return binder
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
// Return this instance of LocalService so clients can call public methods
|
||||
fun getService(): PlayerNotificationService = this@PlayerNotificationService
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val stopIntent = Intent(context, PlayerNotificationService::class.java)
|
||||
context.stopService(stopIntent)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(tag, "onStartCommand $startId")
|
||||
isStarted = true
|
||||
|
||||
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createNotificationChannel(channelId: String, channelName: String): String{
|
||||
val chan = NotificationChannel(channelId,
|
||||
channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||
chan.lightColor = Color.DKGRAY
|
||||
chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
service.createNotificationChannel(chan)
|
||||
return channelId
|
||||
}
|
||||
|
||||
// detach player
|
||||
override fun onDestroy() {
|
||||
playerNotificationManager.setPlayer(null)
|
||||
mPlayer.release()
|
||||
mediaSession.release()
|
||||
Log.d(tag, "onDestroy")
|
||||
isStarted = false
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
//removing service when user swipe out our app
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.d(tag, "onTaskRemoved")
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ctx = this
|
||||
|
||||
channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
createNotificationChannel(channelId, channelName)
|
||||
} else ""
|
||||
|
||||
mPlayer = SimpleExoPlayer.Builder(this).build()
|
||||
setPlayerListeners()
|
||||
|
||||
val sessionActivityPendingIntent =
|
||||
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
|
||||
PendingIntent.getActivity(this, 0, sessionIntent, 0)
|
||||
}
|
||||
mediaSession = MediaSessionCompat(this, tag)
|
||||
.apply {
|
||||
setSessionActivity(sessionActivityPendingIntent)
|
||||
isActive = true
|
||||
}
|
||||
|
||||
Log.d(tag, "Media Session Set")
|
||||
|
||||
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
||||
|
||||
val builder = PlayerNotificationManager.Builder(
|
||||
ctx,
|
||||
notificationId,
|
||||
channelId)
|
||||
|
||||
builder.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
|
||||
|
||||
builder.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
onGoing: Boolean) {
|
||||
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
startForeground(notificationId, notification)
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(
|
||||
notificationId: Int,
|
||||
dismissedByUser: Boolean
|
||||
) {
|
||||
if (dismissedByUser) {
|
||||
Log.d(tag, "onNotificationCancelled dismissed by user")
|
||||
stopSelf()
|
||||
} else {
|
||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
playerNotificationManager = builder.build()
|
||||
|
||||
|
||||
playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken)
|
||||
playerNotificationManager.setUsePlayPauseActions(true)
|
||||
playerNotificationManager.setUseNextAction(false)
|
||||
playerNotificationManager.setUsePreviousAction(false)
|
||||
playerNotificationManager.setUseChronometer(false)
|
||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
|
||||
// Unknown action
|
||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
|
||||
// Color is set based on the art - cannot override
|
||||
// playerNotificationManager.setColor(Color.RED)
|
||||
// playerNotificationManager.setColorized(true)
|
||||
|
||||
// Icon needs to be black and white
|
||||
// playerNotificationManager.setSmallIcon(R.drawable.icon_32)
|
||||
|
||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(currentAudiobook!!.id)
|
||||
.setTitle(currentAudiobook!!.title)
|
||||
.setSubtitle(currentAudiobook!!.author)
|
||||
.setMediaUri(currentAudiobook!!.playlistUri)
|
||||
.setIconUri(currentAudiobook!!.coverUri)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlayer(mPlayer)
|
||||
|
||||
//attach player to playerNotificationManager
|
||||
playerNotificationManager.setPlayer(mPlayer)
|
||||
}
|
||||
|
||||
private inner class DescriptionAdapter(private val controller: MediaControllerCompat) :
|
||||
PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
|
||||
override fun createCurrentContentIntent(player: Player): PendingIntent? =
|
||||
controller.sessionActivity
|
||||
|
||||
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
|
||||
|
||||
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
|
||||
|
||||
override fun getCurrentLargeIcon(
|
||||
player: Player,
|
||||
callback: PlayerNotificationManager.BitmapCallback
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
|
||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||
// Cache the bitmap for the current audiobook so that successive calls to
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Block on downloading artwork.
|
||||
Glide.with(ctx).applyDefaultRequestOptions(glideOptions)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlayerListeners() {
|
||||
mPlayer.addListener(object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
error.message?.let { Log.e(tag, it) }
|
||||
error.localizedMessage?.let { Log.e(tag, it) }
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||
Log.d(tag, "EVENT_TRACKS_CHANGED")
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_TIMELINE_CHANGED)) {
|
||||
Log.d(tag, "EVENT_TIMELINE_CHANGED")
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
|
||||
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + mPlayer.isLoading.toString())
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (mPlayer.playbackState == Player.STATE_READY) {
|
||||
Log.d(tag, "STATE_READY : " + mPlayer.duration.toString())
|
||||
|
||||
if (!currentAudiobook!!.hasPlayerLoaded && currentAudiobook!!.startTime > 0) {
|
||||
Log.d(tag, "Should seek to ${currentAudiobook!!.startTime}")
|
||||
mPlayer.seekTo(currentAudiobook!!.startTime)
|
||||
}
|
||||
|
||||
currentAudiobook!!.hasPlayerLoaded = true
|
||||
sendClientMetadata("ready")
|
||||
}
|
||||
if (mPlayer.playbackState == Player.STATE_BUFFERING) {
|
||||
Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString())
|
||||
sendClientMetadata("buffering")
|
||||
}
|
||||
if (mPlayer.playbackState == Player.STATE_ENDED) {
|
||||
Log.d(tag, "STATE_ENDED")
|
||||
sendClientMetadata("ended")
|
||||
}
|
||||
if (mPlayer.playbackState == Player.STATE_IDLE) {
|
||||
Log.d(tag, "STATE_IDLE")
|
||||
sendClientMetadata("idle")
|
||||
}
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
||||
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED")
|
||||
}
|
||||
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
||||
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED")
|
||||
}
|
||||
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
|
||||
Log.d(tag, "EVENT IS PLAYING CHANGED")
|
||||
if (listener != null) listener.onPlayingUpdate(player.isPlaying)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
User callable methods
|
||||
*/
|
||||
|
||||
// fun initPlayer(token: String, playlistUri: String, playWhenReady: Boolean, currentTime: Long, title: String, artist: String, albumArt: String) {
|
||||
fun initPlayer(audiobook: Audiobook) {
|
||||
currentAudiobook = audiobook
|
||||
|
||||
Log.d(tag, "Init Player Audiobook ${currentAudiobook!!.playlistUrl} | ${currentAudiobook!!.title} | ${currentAudiobook!!.author}")
|
||||
|
||||
if (mPlayer.isPlaying) {
|
||||
Log.d(tag, "Init Player audiobook already playing")
|
||||
}
|
||||
|
||||
val metadata = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id)
|
||||
.build()
|
||||
|
||||
mediaSession.setMetadata(metadata)
|
||||
|
||||
var mediaMetadata = MediaMetadata.Builder().build()
|
||||
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
|
||||
|
||||
var dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
dataSourceFactory.setUserAgent(channelId)
|
||||
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobook!!.token}"))
|
||||
|
||||
var mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||
|
||||
mPlayer.setMediaSource(mediaSource, true)
|
||||
mPlayer.prepare()
|
||||
mPlayer.playWhenReady = currentAudiobook!!.playWhenReady
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentTime() : Long {
|
||||
return mPlayer.currentPosition
|
||||
}
|
||||
|
||||
fun play() {
|
||||
if (mPlayer.isPlaying) {
|
||||
Log.d(tag, "Already playing")
|
||||
return
|
||||
}
|
||||
mPlayer.play()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mPlayer.pause()
|
||||
}
|
||||
|
||||
fun seekPlayer(time: Long) {
|
||||
mPlayer.seekTo(time)
|
||||
}
|
||||
|
||||
fun seekForward10() {
|
||||
mPlayer.seekTo(mPlayer.currentPosition + 10000)
|
||||
}
|
||||
|
||||
fun seekBackward10() {
|
||||
mPlayer.seekTo(mPlayer.currentPosition - 10000)
|
||||
}
|
||||
|
||||
fun terminateStream() {
|
||||
if (mPlayer.playbackState == Player.STATE_READY) {
|
||||
mPlayer.clearMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendClientMetadata(stateName: String) {
|
||||
var metadata = JSObject()
|
||||
var duration = mPlayer.duration
|
||||
if (duration < 0) duration = 0
|
||||
metadata.put("duration", duration)
|
||||
metadata.put("currentTime", mPlayer.currentPosition)
|
||||
metadata.put("stateName", stateName)
|
||||
if (listener != null) listener.onMetadata(metadata)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,12 @@
|
||||
<?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"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 536 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 923 B |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">AudioBookshelf</string>
|
||||
<string name="title_activity_main">AudioBookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
<feature name="File">
|
||||
<param name="android-package" value="org.apache.cordova.file.FileUtils"/>
|
||||
<param name="onload" value="true"/>
|
||||
</feature>
|
||||
|
||||
<feature name="Media">
|
||||
<param name="android-package" value="org.apache.cordova.media.AudioHandler"/>
|
||||
</feature>
|
||||
|
||||
|
||||
</widget>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||