MediaParsingService.kt 18.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
/**
 * **************************************************************************
 * MediaParsingService.kt
 * ****************************************************************************
 * Copyright © 2018 VLC authors and VideoLAN
 * Author: Geoffrey Métais
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
 * ***************************************************************************
 */
23 24
package org.videolan.vlc

25
import android.annotation.SuppressLint
26 27 28 29 30 31 32 33 34 35 36
import android.app.Service
import android.arch.lifecycle.MutableLiveData
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.support.v4.app.NotificationManagerCompat
37
import android.support.v4.content.ContextCompat
38 39 40 41
import android.support.v4.content.LocalBroadcastManager
import android.text.TextUtils
import android.util.Log
import kotlinx.coroutines.experimental.*
42
import kotlinx.coroutines.experimental.android.Main
43 44
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.channels.actor
45 46 47 48
import org.videolan.libvlc.util.AndroidUtil
import org.videolan.medialibrary.Medialibrary
import org.videolan.medialibrary.interfaces.DevicesDiscoveryCb
import org.videolan.vlc.gui.helpers.NotificationHelper
49
import org.videolan.vlc.gui.wizard.startMLWizard
50
import org.videolan.vlc.repository.DirectoryRepository
51 52 53 54
import org.videolan.vlc.util.*
import java.io.File
import java.util.*

55 56 57
private const val TAG = "VLC/MediaParsingService"
private const val NOTIFICATION_DELAY = 1000L

58 59
class MediaParsingService : Service(), DevicesDiscoveryCb, CoroutineScope {
    override val coroutineContext = Dispatchers.Main.immediate
60 61 62 63 64 65 66 67
    private lateinit var wakeLock: PowerManager.WakeLock
    private lateinit var localBroadcastManager: LocalBroadcastManager

    private val binder = LocalBinder()
    private lateinit var medialibrary: Medialibrary
    private var parsing = 0
    private var reload = 0
    private var currentDiscovery: String? = null
68
    @Volatile private var lastNotificationTime = 0L
69
    private var notificationJob: Job? = null
70
    @Volatile private var scanActivated = false
71

72
    private val settings by lazy { Settings.getInstance(this) }
73

74
    private var scanPaused = false
75 76 77 78 79 80

    @Volatile
    private var serviceLock = false
    private var wasWorking: Boolean = false
    internal val sb = StringBuilder()

81
    private val notificationActor by lazy {
82
        actor<Notification>(capacity = Channel.UNLIMITED) {
83
            for (update in channel) when (update) {
84 85
                Show -> showNotification()
                Hide -> hideNotification()
86 87 88 89
            }
        }
    }

90
    @SuppressLint("WakelockTimeout")
91 92
    override fun onCreate() {
        super.onCreate()
93 94 95
        localBroadcastManager = LocalBroadcastManager.getInstance(this)
        medialibrary = Medialibrary.getInstance()
        medialibrary.addDeviceDiscoveryCb(this@MediaParsingService)
96
        val filter = IntentFilter()
Habib Kazemi's avatar
Habib Kazemi committed
97 98
        filter.addAction(ACTION_PAUSE_SCAN)
        filter.addAction(ACTION_RESUME_SCAN)
99 100
        registerReceiver(receiver, filter)
        localBroadcastManager.registerReceiver(receiver, IntentFilter(Medialibrary.ACTION_IDLE))
101
        val pm = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
102 103
        wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG)
        wakeLock.acquire()
104 105

        if (lastNotificationTime == 5L) stopSelf()
106 107 108
    }

    override fun onBind(intent: Intent): IBinder? {
109
        return binder
110 111 112 113 114 115 116
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        if (intent == null) {
            exitCommand()
            return Service.START_NOT_STICKY
        }
117 118
        // Set 1s delay before displaying scan icon
        // Except for Android 8+ which expects startForeground immediately
119

120
        if (AndroidUtil.isOOrLater && lastNotificationTime == 0L) forceForeground()
121
        else if (lastNotificationTime <= 0L) lastNotificationTime = System.currentTimeMillis()
122
        when (intent.action) {
Habib Kazemi's avatar
Habib Kazemi committed
123 124 125
            ACTION_INIT -> {
                val upgrade = intent.getBooleanExtra(EXTRA_UPGRADE, false)
                val parse = intent.getBooleanExtra(EXTRA_PARSE, true)
126 127
                setupMedialibrary(upgrade, parse)
            }
Habib Kazemi's avatar
Habib Kazemi committed
128 129 130 131
            ACTION_RELOAD -> reload(intent.getStringExtra(EXTRA_PATH))
            ACTION_DISCOVER -> discover(intent.getStringExtra(EXTRA_PATH))
            ACTION_DISCOVER_DEVICE -> discoverStorage(intent.getStringExtra(EXTRA_PATH))
            ACTION_CHECK_STORAGES -> if (scanActivated) actions.offer(UpdateStorages) else exitCommand()
132 133 134 135 136 137 138 139 140
            else -> {
                exitCommand()
                return Service.START_NOT_STICKY
            }
        }
        started.value = true
        return Service.START_NOT_STICKY
    }

141 142
    private fun forceForeground() {
        val ctx = this@MediaParsingService
143
        val notification = NotificationHelper.createScanNotification(ctx, getString(R.string.loading_medialibrary), false, scanPaused)
144 145 146
        startForeground(43, notification)
    }

147 148 149 150 151 152
    private fun discoverStorage(path: String) {
        if (BuildConfig.DEBUG) Log.d(TAG, "discoverStorage: $path")
        if (TextUtils.isEmpty(path)) {
            exitCommand()
            return
        }
153
        actions.offer(DiscoverStorage(path))
154 155 156 157 158 159 160
    }

    private fun discover(path: String) {
        if (TextUtils.isEmpty(path)) {
            exitCommand()
            return
        }
161
        actions.offer(DiscoverFolder(path))
162 163 164
    }

    private fun addDeviceIfNeeded(path: String) {
165
        for (devicePath in medialibrary.devices) {
166 167 168 169 170 171 172 173 174 175 176 177
            if (path.startsWith(Strings.removeFileProtocole(devicePath))) {
                exitCommand()
                return
            }
        }
        for (storagePath in AndroidDevices.getExternalStorageDirectories()) {
            if (path.startsWith(storagePath)) {
                val uuid = FileUtils.getFileNameFromPath(path)
                if (TextUtils.isEmpty(uuid)) {
                    exitCommand()
                    return
                }
178
                medialibrary.addDevice(uuid, path, true)
179
                for (folder in Medialibrary.getBlackList())
180
                    medialibrary.banFolder(path + folder)
181 182 183 184 185
            }
        }
    }

    private fun reload(path: String?) {
186 187 188
        if (reload > 0) return
        if (TextUtils.isEmpty(path)) medialibrary.reload()
        else medialibrary.reload(path)
189 190
    }

191 192 193
    private fun setupMedialibrary(upgrade: Boolean, parse: Boolean) {
        if (medialibrary.isInitiated) {
            medialibrary.resumeBackgroundOperations()
194 195
            if (parse && !scanActivated) actions.offer(StartScan(upgrade))
        } else actions.offer(Init(upgrade, parse))
196 197
    }

198
    private suspend fun initMedialib(parse: Boolean, context: Context, shouldInit: Boolean, upgrade: Boolean) {
199
        addDevices(context, parse)
200
        if (upgrade) medialibrary.forceParserRetry()
201 202 203 204 205 206
        medialibrary.start()
        localBroadcastManager.sendBroadcast(Intent(VLCApplication.ACTION_MEDIALIBRARY_READY))
        if (parse) startScan(shouldInit, upgrade)
        else exitCommand()
    }

207
    private suspend fun addDevices(context: Context, addExternal: Boolean) {
208
        val devices = ArrayList<String>()
209
        Collections.addAll(devices, *DirectoryRepository.getInstance(context).getMediaDirectories())
210
        val sharedPreferences = Settings.getInstance(context)
211 212 213 214
        for (device in devices) {
            val isMainStorage = TextUtils.equals(device, AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY)
            val uuid = FileUtils.getFileNameFromPath(device)
            if (TextUtils.isEmpty(device) || TextUtils.isEmpty(uuid)) continue
215
            val isNew = (isMainStorage || (addExternal && withContext(Dispatchers.IO) { File(device).canRead() } ))
216
                    && medialibrary.addDevice(if (isMainStorage) "main-storage" else uuid, device, !isMainStorage)
217 218 219 220 221 222 223 224 225 226 227 228
            val isIgnored = sharedPreferences.getBoolean("ignore_$uuid", false)
            if (!isMainStorage && isNew && !isIgnored) showStorageNotification(device)
        }
    }

    private fun startScan(shouldInit: Boolean, upgrade: Boolean) {
        scanActivated = true
        when {
            shouldInit -> {
                for (folder in Medialibrary.getBlackList())
                    medialibrary.banFolder(AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY + folder)
                medialibrary.discover(AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY)
229
            }
230 231 232 233 234 235
            upgrade -> {
                medialibrary.unbanFolder("${AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY}/WhatsApp/")
                medialibrary.banFolder("${AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY}/WhatsApp/Media/WhatsApp Animated Gifs/")
            }
            settings.getBoolean("auto_rescan", true) -> reload(null)
            else -> exitCommand()
236 237 238 239 240 241 242 243 244
        }
    }

    private fun showStorageNotification(device: String) {
        newStorages.value.apply {
            newStorages.postValue(if (this === null) mutableListOf(device) else this.apply { add(device) })
        }
    }

245
    private suspend fun updateStorages() {
246
        serviceLock = true
247
        val ctx = applicationContext
248 249
        val (sharedPreferences, devices, knownDevices) = withContext(Dispatchers.IO) {
            val sharedPreferences = Settings.getInstance(ctx)
250 251 252
            val devices = AndroidDevices.getExternalStorageDirectories()
            Triple(sharedPreferences, devices, medialibrary.devices)
        }
253 254 255 256 257 258 259 260
        val missingDevices = Util.arrayToArrayList(knownDevices)
        missingDevices.remove("file://${AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY}")
        for (device in devices) {
            val uuid = FileUtils.getFileNameFromPath(device)
            if (TextUtils.isEmpty(device) || TextUtils.isEmpty(uuid)) continue
            if (ExternalMonitor.containsDevice(knownDevices, device)) {
                missingDevices.remove("file://$device")
                continue
261
            }
262
            val isNew = withContext(Dispatchers.IO) { medialibrary.addDevice(uuid, device, true) }
263 264
            val isIgnored = sharedPreferences.getBoolean("ignore_$uuid", false)
            if (!isIgnored && isNew) showStorageNotification(device)
265
        }
266
        withContext(Dispatchers.IO) { for (device in missingDevices) medialibrary.removeDevice(FileUtils.getFileNameFromPath(device)) }
267 268
        serviceLock = false
        exitCommand()
269 270
    }

271
    private suspend fun showNotification() {
272
        val currentTime = System.currentTimeMillis()
273 274
        if (lastNotificationTime == -1L || currentTime - lastNotificationTime < NOTIFICATION_DELAY) return
        lastNotificationTime = currentTime
275
        val discovery = withContext(Dispatchers.Default) {
276 277
            sb.setLength(0)
            when {
278 279
                parsing > 0 -> sb.append(getString(R.string.ml_parse_media)).append(' ').append(parsing).append("%")
                currentDiscovery != null -> sb.append(getString(R.string.ml_discovering)).append(' ').append(Uri.decode(Strings.removeFileProtocole(currentDiscovery)))
280 281 282
                else -> sb.append(getString(R.string.ml_parse_media))
            }
            val progressText = sb.toString()
283
            val updateAction = wasWorking != medialibrary.isWorking
284
            if (updateAction) wasWorking = !wasWorking
285
            if (!isActive) return@withContext ""
286 287 288 289 290
            val notification = NotificationHelper.createScanNotification(this@MediaParsingService, progressText, updateAction, scanPaused)
            if (lastNotificationTime != -1L) {
                try {
                    startForeground(43, notification)
                } catch (ignored: IllegalArgumentException) {}
291 292
                progressText
            } else ""
293
        }
294
        showProgress(parsing, discovery)
295 296
    }

297 298 299 300
    private suspend fun hideNotification() {
        notificationJob?.cancelAndJoin()
        lastNotificationTime = -1L
        NotificationManagerCompat.from(this@MediaParsingService).cancel(43)
301
        showProgress(-1, "")
302 303 304 305 306 307 308 309
    }

    override fun onDiscoveryStarted(entryPoint: String) {
        if (BuildConfig.DEBUG) Log.v(TAG, "onDiscoveryStarted: $entryPoint")
    }

    override fun onDiscoveryProgress(entryPoint: String) {
        if (BuildConfig.DEBUG) Log.v(TAG, "onDiscoveryProgress: $entryPoint")
310
        currentDiscovery = entryPoint
311
        notificationActor.offer(Show)
312 313 314 315 316 317 318 319
    }

    override fun onDiscoveryCompleted(entryPoint: String) {
        if (BuildConfig.DEBUG) Log.v(TAG, "onDiscoveryCompleted: $entryPoint")
    }

    override fun onParsingStatsUpdated(percent: Int) {
        if (BuildConfig.DEBUG) Log.v(TAG, "onParsingStatsUpdated: $percent")
320
        parsing = percent
321
        if (parsing != 100) notificationActor.offer(Show)
322 323 324 325
    }

    override fun onReloadStarted(entryPoint: String) {
        if (BuildConfig.DEBUG) Log.v(TAG, "onReloadStarted: $entryPoint")
326
        if (TextUtils.isEmpty(entryPoint)) ++reload
327 328 329 330
    }

    override fun onReloadCompleted(entryPoint: String) {
        if (BuildConfig.DEBUG) Log.v(TAG, "onReloadCompleted $entryPoint")
331
        if (TextUtils.isEmpty(entryPoint)) --reload
332 333
    }

334
    private fun exitCommand() = launch {
335
        if (!medialibrary.isWorking && !serviceLock) stopSelf()
336 337 338 339
    }

    override fun onDestroy() {
        started.value = false
340
        notificationActor.offer(Hide)
341
        medialibrary.removeDeviceDiscoveryCb(this)
342 343
        unregisterReceiver(receiver)
        localBroadcastManager.unregisterReceiver(receiver)
344
        if (wakeLock.isHeld) wakeLock.release()
345 346 347 348 349 350
        super.onDestroy()
    }

    private inner class LocalBinder : Binder()

    private fun showProgress(parsing: Int, discovery: String) {
351 352 353 354
        if (parsing == -1) {
            progress.value = null
            return
        }
355
        val status = progress.value
356
        progress.value = if (status === null) ScanProgress(parsing, discovery) else status.copy(parsing = parsing, discovery = discovery)
357 358
    }

359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386
    private val actions = actor<MLAction>(capacity = Channel.UNLIMITED) {
        for (action in channel) when (action) {
            is DiscoverStorage -> {
                for (folder in Medialibrary.getBlackList()) medialibrary.banFolder(action.path + folder)
                medialibrary.discover(action.path)
            }
            is DiscoverFolder -> {
                addDeviceIfNeeded(action.path)
                medialibrary.discover(action.path)
            }
            is Init -> {
                val context = this@MediaParsingService
                var shouldInit = !dbExists(context)
                val initCode = medialibrary.init(context)
                shouldInit = shouldInit or (initCode == Medialibrary.ML_INIT_DB_RESET)
                if (initCode != Medialibrary.ML_INIT_FAILED) initMedialib(action.parse, context, shouldInit, action.upgrade)
                else exitCommand()
            }
            is StartScan -> {
                scanActivated = true
                addDevices(this@MediaParsingService, true)
                startScan(false, action.upgrade)
            }
            UpdateStorages -> updateStorages()
        }
    }

    private val receiver = object : BroadcastReceiver() {
387
        @SuppressLint("WakelockTimeout")
388 389
        override fun onReceive(context: Context, intent: Intent) {
            when (intent.action) {
Habib Kazemi's avatar
Habib Kazemi committed
390
                ACTION_PAUSE_SCAN -> {
391 392 393 394
                    if (wakeLock.isHeld) wakeLock.release()
                    scanPaused = true
                    medialibrary.pauseBackgroundOperations()
                }
Habib Kazemi's avatar
Habib Kazemi committed
395
                ACTION_RESUME_SCAN -> {
396 397 398 399 400 401 402 403 404 405 406 407 408 409
                    if (!wakeLock.isHeld) wakeLock.acquire()
                    medialibrary.resumeBackgroundOperations()
                    scanPaused = false
                }
                Medialibrary.ACTION_IDLE -> if (intent.getBooleanExtra(Medialibrary.STATE_IDLE, true)) {
                    if (!scanPaused) {
                        exitCommand()
                        return
                    }
                }
            }
        }
    }

410 411 412 413
    companion object {
        val progress = MutableLiveData<ScanProgress>()
        val started = MutableLiveData<Boolean>()
        val newStorages = MutableLiveData<MutableList<String>>()
414
        var wizardShowing = false
415 416 417 418
    }
}

data class ScanProgress(val parsing: Int, val discovery: String)
419 420

fun reload(ctx: Context) {
Habib Kazemi's avatar
Habib Kazemi committed
421
    ContextCompat.startForegroundService(ctx, Intent(ACTION_RELOAD, null, ctx, MediaParsingService::class.java))
422 423
}

424
fun Context.startMedialibrary(firstRun: Boolean = false, upgrade: Boolean = false, parse: Boolean = true) = GlobalScope.launch(Dispatchers.Main.immediate) {
Geoffrey Métais's avatar
Geoffrey Métais committed
425
    if (Medialibrary.getInstance().isStarted || !Permissions.canReadStorage(this@startMedialibrary)) return@launch
426
    val prefs = withContext(Dispatchers.IO) { Settings.getInstance(this@startMedialibrary) }
427
    val scanOpt = if (AndroidDevices.showTvUi(this@startMedialibrary)) ML_SCAN_ON else prefs.getInt(KEY_MEDIALIBRARY_SCAN, -1)
428
    if (parse && scanOpt == -1) {
Habib Kazemi's avatar
Habib Kazemi committed
429
        if (dbExists(this@startMedialibrary)) prefs.edit().putInt(KEY_MEDIALIBRARY_SCAN, ML_SCAN_ON).apply()
430
        else {
Geoffrey Métais's avatar
Geoffrey Métais committed
431
            if (MediaParsingService.wizardShowing) return@launch
432 433
            MediaParsingService.wizardShowing = true
            startMLWizard()
Geoffrey Métais's avatar
Geoffrey Métais committed
434
            return@launch
435 436
        }
    }
Habib Kazemi's avatar
Habib Kazemi committed
437
    val intent = Intent(ACTION_INIT, null, this@startMedialibrary, MediaParsingService::class.java)
438
    ContextCompat.startForegroundService(this@startMedialibrary, intent
Habib Kazemi's avatar
Habib Kazemi committed
439 440 441
            .putExtra(EXTRA_FIRST_RUN, firstRun)
            .putExtra(EXTRA_UPGRADE, upgrade)
            .putExtra(EXTRA_PARSE, parse && scanOpt == ML_SCAN_ON))
442 443
}

444
private suspend fun dbExists(context: Context) = withContext(Dispatchers.IO) {
445
    File(context.getDir("db", Context.MODE_PRIVATE).toString() + Medialibrary.VLC_MEDIA_DB_NAME).exists()
446 447 448 449 450 451 452 453 454 455 456 457
}

private sealed class MLAction
private class DiscoverStorage(val path: String) : MLAction()
private class DiscoverFolder(val path: String) : MLAction()
private class Init(val upgrade: Boolean, val parse: Boolean) : MLAction()
private class StartScan(val upgrade: Boolean) : MLAction()
private object UpdateStorages : MLAction()

private sealed class Notification
private object Show : Notification()
private object Hide : Notification()