Offline support, downloading, syncing progress

This commit is contained in:
Mark Cooper
2021-09-12 18:37:08 -05:00
parent e33a054288
commit 7fe37ff983
37 changed files with 2836 additions and 201 deletions
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 8
versionName "0.4.0-beta"
versionCode 9
versionName "0.8.0-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+4
View File
@@ -9,8 +9,12 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-dialog')
implementation project(':capacitor-network')
implementation project(':capacitor-storage')
implementation project(':robingenz-capacitor-app-update')
implementation project(':capacitor-data-storage-sqlite')
}
@@ -1,10 +1,26 @@
[
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{
"pkg": "@capacitor/dialog",
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
},
{
"pkg": "@capacitor/network",
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
},
{
"pkg": "@capacitor/storage",
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
},
{
"pkg": "@robingenz/capacitor-app-update",
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
},
{
"pkg": "capacitor-data-storage-sqlite",
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
}
]
@@ -0,0 +1,320 @@
package com.audiobookshelf.app
import android.app.DownloadManager
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "AudioDownloader")
class AudioDownloader : Plugin() {
private val tag = "AudioDownloader"
lateinit var mainActivity:MainActivity
lateinit var downloadManager:DownloadManager
data class AudiobookDownload(val url: String, val filename:String, val downloadId: Long)
var downloads:MutableList<AudiobookDownload> = mutableListOf()
data class CoverItem(val name:String, val coverUrl:String)
data class AudiobookItem(val id: Long, val uri: Uri, val name: String, val size: Int, val duration: Int, val coverUrl: String) {
fun toJSObject() : JSObject {
var obj = JSObject()
obj.put("id", this.id)
obj.put("uri", this.uri)
obj.put("name", this.name)
obj.put("size", this.size)
obj.put("duration", this.duration)
obj.put("coverUrl", this.coverUrl)
return obj
}
}
var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var recieverEvent : (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
Log.d(tag, "RECEIVE EVT $evt $id")
if (evt == "complete") {
var path = downloadManager.getUriForDownloadedFile(id)
var download = downloads.find { it.downloadId == id }
var filename = download?.filename
var jsobj = JSObject()
jsobj.put("downloadId", id)
jsobj.put("contentUrl", path)
jsobj.put("filename", filename)
notifyListeners("onDownloadComplete", jsobj)
downloads = downloads.filter { it.downloadId != id } as MutableList<AudiobookDownload>
}
if (evt == "clicked") {
Log.d(tag, "Clicked $id back in the audiodownloader")
}
}
mainActivity.registerBroadcastReceiver(recieverEvent)
}
fun loadAudiobooks() {
var covers = loadCovers()
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.IS_AUDIOBOOK
)
var _audiobookItems:MutableList<AudiobookItem> = mutableListOf()
val selection = "${MediaStore.Audio.Media.IS_AUDIOBOOK} == ?"
val selectionArgs = arrayOf("1")
val sortOrder = "${MediaStore.Audio.Media.DISPLAY_NAME} ASC"
activity.applicationContext.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val durationColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
val isAudiobookColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.IS_AUDIOBOOK)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val name = cursor.getString(nameColumn)
val duration = cursor.getInt(durationColumn)
val size = cursor.getInt(sizeColumn)
var isAudiobook = cursor.getInt(isAudiobookColumn)
if (isAudiobook == 1) {
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
Log.d(tag, "Got Content FRom MEdia STORE $id $contentUri, Name: $name, Dur: $duration, Size: $size")
var audiobookId = File(name).nameWithoutExtension
var coverItem:CoverItem? = covers.find{it.name == audiobookId}
var coverUrl = coverItem?.coverUrl ?: ""
_audiobookItems.add(AudiobookItem(id, contentUri, name, duration, size, coverUrl))
}
}
audiobookItems = _audiobookItems
var audiobookObjs:List<JSObject> = _audiobookItems.map{ it.toJSObject() }
var mediaItemNoticePayload = JSObject()
mediaItemNoticePayload.put("items", audiobookObjs)
notifyListeners("onMediaLoaded", mediaItemNoticePayload)
}
}
fun loadCovers() : MutableList<CoverItem> {
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME
)
val sortOrder = "${MediaStore.Images.Media.DISPLAY_NAME} ASC"
var coverItems:MutableList<CoverItem> = mutableListOf()
activity.applicationContext.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val filename = cursor.getString(nameColumn)
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
var name = File(filename).nameWithoutExtension
Log.d(tag, "Got IMAGE FRom Media STORE $id $contentUri, Name: $name")
var coverItem = CoverItem(name, contentUri.toString())
coverItems.add(coverItem)
}
}
return coverItems
}
@PluginMethod
fun load(call: PluginCall) {
loadAudiobooks()
call.resolve()
}
@PluginMethod
fun downloadCover(call: PluginCall) {
var url = call.data.getString("downloadUrl", "unknown").toString()
var title = call.data.getString("title", "Cover").toString()
var filename = call.data.getString("filename", "audiobook.jpg").toString()
Log.d(tag, "Called download cover: $url")
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle("Cover Art: $title")
dlRequest.setDescription("Cover art for audiobook")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_AUDIOBOOKS, filename)
var downloadId = downloadManager.enqueue(dlRequest)
var progressReceiver : (prog: Long) -> Unit = { prog: Long ->
//
}
var doneReceiver : (success: Boolean) -> Unit = { success: Boolean ->
var jsobj = JSObject()
if (success) {
var path = downloadManager.getUriForDownloadedFile(downloadId)
jsobj.put("url", path)
call.resolve(jsobj)
} else {
jsobj.put("failed", true)
call.resolve(jsobj)
}
}
var progressUpdater = DownloadProgressUpdater(downloadManager, downloadId, progressReceiver, doneReceiver)
progressUpdater.run()
}
@PluginMethod
fun download(call: PluginCall) {
var url = call.data.getString("downloadUrl", "unknown").toString()
var title = call.data.getString("title", "Audiobook").toString()
var filename = call.data.getString("filename", "audiobook.mp3").toString()
Log.d(tag, "Called download: $url")
var dlRequest = DownloadManager.Request(Uri.parse(url))
dlRequest.setTitle(title)
dlRequest.setDescription("Downloading to Audiobooks directory")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_AUDIOBOOKS, filename)
var downloadId = downloadManager.enqueue(dlRequest)
var download = AudiobookDownload(url, filename, downloadId)
downloads.add(download)
var progressReceiver : (prog: Long) -> Unit = { prog: Long ->
var jsobj = JSObject()
jsobj.put("filename", filename)
jsobj.put("downloadId", downloadId)
jsobj.put("progress", prog)
notifyListeners("onDownloadProgress", jsobj)
}
var doneReceiver : (success: Boolean) -> Unit = { success: Boolean ->
Log.d(tag, "RECIEVER DONE, SUCCES? $success")
}
var progressUpdater = DownloadProgressUpdater(downloadManager, downloadId, progressReceiver, doneReceiver)
progressUpdater.run()
val ret = JSObject()
ret.put("value", downloadId)
call.resolve(ret)
}
@PluginMethod
fun delete(call:PluginCall) {
var filename = call.data.getString("filename", "audiobook.mp3").toString()
var url = call.data.getString("url", "").toString()
var coverUrl = call.data.getString("coverUrl", "").toString()
Log.d(tag, "Called delete file $filename $url")
var contentResolver = activity.applicationContext.contentResolver
contentResolver.delete(Uri.parse(url), null, null)
if (coverUrl != "") {
contentResolver.delete(Uri.parse(coverUrl), null, null)
}
call.resolve()
}
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver:(Long) -> Unit, private var doneReceiver:(Boolean) -> Unit) : Thread() {
private val query: DownloadManager.Query = DownloadManager.Query()
private var totalBytes: Int = 0
private var TAG = "DownloadProgressUpdater"
init {
query.setFilterById(this.downloadId)
}
override fun run() {
Log.d(TAG, "RUN FOR ID $downloadId")
var keepRunning = true
while (keepRunning) {
Thread.sleep(500)
manager.query(query).use {
if (it.moveToFirst()) {
//get total bytes of the file
if (totalBytes <= 0) {
totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
}
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
doneReceiver(true)
} else {
doneReceiver(false)
}
keepRunning = false
this.interrupt()
} else {
//update progress
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
receiver(percentProgress)
}
} else {
Log.e(TAG, "NOT FOUND IN QUERY")
keepRunning = false
}
}
}
}
}
}
@@ -16,10 +16,14 @@ class Audiobook {
var playbackSpeed:Float = 1f
var duration:Long = 0
var isLocal:Boolean = false
var contentUrl:String = ""
var hasPlayerLoaded:Boolean = false
val playlistUri:Uri
val coverUri:Uri
var playlistUri:Uri = Uri.EMPTY
var coverUri:Uri = Uri.EMPTY
var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) {
id = jsondata.getString("id", "audiobook").toString()
@@ -34,7 +38,22 @@ class Audiobook {
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
duration = jsondata.getString("duration", "0")!!.toLong()
playlistUri = Uri.parse(playlistUrl)
coverUri = Uri.parse(cover)
// Local data
isLocal = jsondata.getBoolean("isLocal", false) == true
contentUrl = jsondata.getString("contentUrl", "").toString()
if (playlistUrl != "") {
playlistUri = Uri.parse(playlistUrl)
}
if (cover != "") {
coverUri = Uri.parse(cover)
} else {
coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
cover = coverUri.toString()
}
if (contentUrl != "") {
contentUri = Uri.parse(contentUrl)
}
}
}
@@ -1,15 +1,16 @@
package com.audiobookshelf.app
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.app.DownloadManager
import android.content.*
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import com.example.myapp.MyNativeAudio
import android.widget.Toast
import com.getcapacitor.BridgeActivity
class MainActivity : BridgeActivity() {
private val tag = "MainActivity"
@@ -18,11 +19,45 @@ class MainActivity : BridgeActivity() {
private lateinit var mConnection : ServiceConnection
lateinit var pluginCallback : () -> Unit
lateinit var downloaderCallback : (String, Long) -> Unit
val broadcastReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("complete", thisdlid)
Log.d(tag, "DOWNNLAOD COMPELTE $thisdlid")
Toast.makeText(this@MainActivity, "Download Completed $thisdlid", Toast.LENGTH_SHORT)
}
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
downloaderCallback("clicked", thisdlid)
Log.d(tag, "CLICKED NOTFIFICAIONT $thisdlid")
Toast.makeText(this@MainActivity, "Download CLICKED $thisdlid", Toast.LENGTH_SHORT)
}
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
registerPlugin(MyNativeAudio::class.java)
registerPlugin(AudioDownloader::class.java)
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
}
registerReceiver(broadcastReceiver, filter)
}
override fun onDestroy() {
super.onDestroy()
// unregisterReceiver(broadcastReceiver)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -62,4 +97,8 @@ class MainActivity : BridgeActivity() {
val stopIntent = Intent(this, PlayerNotificationService::class.java)
stopService(stopIntent)
}
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
downloaderCallback = cb
}
}
@@ -1,13 +1,10 @@
package com.example.myapp
package com.audiobookshelf.app
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
@@ -56,12 +53,20 @@ class MyNativeAudio : Plugin() {
} else {
Log.w(tag, "Service already started --")
}
var jsobj = JSObject()
var audiobook:Audiobook = Audiobook(call.data)
if (audiobook.playlistUrl == "" && audiobook.contentUrl == "") {
Log.e(tag, "Invalid URL for init audio player")
jsobj.put("success", false)
return call.resolve(jsobj)
}
Handler(Looper.getMainLooper()).post() {
playerNotificationService.initPlayer(audiobook)
call.resolve()
jsobj.put("success", true)
call.resolve(jsobj)
}
}
@@ -109,6 +114,7 @@ class MyNativeAudio : Plugin() {
call.resolve()
}
}
@PluginMethod
fun seekBackward(call: PluginCall) {
var amount:Long = call.getString("amount", "0")!!.toLong()
@@ -117,6 +123,7 @@ class MyNativeAudio : Plugin() {
call.resolve()
}
}
@PluginMethod
fun setPlaybackSpeed(call: PluginCall) {
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
@@ -126,6 +133,7 @@ class MyNativeAudio : Plugin() {
call.resolve()
}
}
@PluginMethod
fun terminateStream(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
@@ -24,10 +25,15 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaExtractor
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 kotlinx.coroutines.*
import java.io.File
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
@@ -217,13 +223,13 @@ class PlayerNotificationService : Service() {
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder()
var builder = MediaDescriptionCompat.Builder()
.setMediaId(currentAudiobook!!.id)
.setTitle(currentAudiobook!!.title)
.setSubtitle(currentAudiobook!!.author)
.setMediaUri(currentAudiobook!!.playlistUri)
.setIconUri(currentAudiobook!!.coverUri)
.build()
return builder.build()
}
}
mediaSessionConnector.setQueueNavigator(queueNavigator)
@@ -280,6 +286,9 @@ class PlayerNotificationService : Service() {
}
}
private fun setPlayerListeners() {
mPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
@@ -351,28 +360,42 @@ class PlayerNotificationService : Service() {
Log.d(tag, "Init Player audiobook already playing")
}
val metadata = MediaMetadataCompat.Builder()
var metadataBuilder = 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()
if (currentAudiobook!!.cover != "") {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
}
var metadata = metadataBuilder.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)
var mediaSource:MediaSource
if (currentAudiobook!!.isLocal) {
Log.d(tag, "Playing Local File")
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.contentUri).setMediaMetadata(mediaMetadata).build()
var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId)
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
} else {
Log.d(tag, "Playing HLS File")
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}"))
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
mPlayer.setMediaSource(mediaSource, true)
mPlayer.prepare()
+12
View File
@@ -2,8 +2,20 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-storage'
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
include ':robingenz-capacitor-app-update'
project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android')
include ':capacitor-data-storage-sqlite'
project(':capacitor-data-storage-sqlite').projectDir = new File('../node_modules/capacitor-data-storage-sqlite/android')