/* * Copyright (C) 2020 Team Gateship-One * (Hendrik Borghorst & Frederik Luetkes) * * The AUTHORS.md file contains a detailed contributors list: * <https://github.com/gateship-one/odyssey/blob/master/AUTHORS.md> * * 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 3 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, see <http://www.gnu.org/licenses/>. * */ package org.gateshipone.odyssey.mediascanner; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.os.PowerManager; import android.util.Log; import org.gateshipone.odyssey.BuildConfig; import org.gateshipone.odyssey.R; import org.gateshipone.odyssey.models.FileModel; import org.gateshipone.odyssey.utils.FileExplorerHelper; import java.util.ArrayList; import java.util.List; import java.util.ListIterator; import java.util.Timer; import java.util.TimerTask; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; public class MediaScannerService extends Service { private static final String TAG = MediaScannerService.class.getSimpleName(); public static final String BUNDLE_KEY_DIRECTORY = "org.gateshipone.odyssey.mediascanner.directory"; public static final String ACTION_START_MEDIASCANNING = "org.gateshipone.odyssey.mediascanner.start"; public static final String ACTION_CANCEL_MEDIASCANNING = "org.gateshipone.odyssey.mediascanner.cancel"; private static final int NOTIFICATION_ID = 126; private static final String NOTIFICATION_CHANNEL_ID = "MediaScanner"; /** * Defines how many tracks are sent at once to the MediaScanner. Should not be to big to avoid creating * to large objects for Binder IPC. */ private static final int MEDIASCANNER_BUNCH_SIZE = 100; private NotificationManager mNotificationManager; private NotificationCompat.Builder mBuilder; private List<FileModel> mRemainingFiles; private int mFilesToScan; private int mScannedFiles; private MediaScannerService.ActionReceiver mBroadcastReceiver; private PowerManager.WakeLock mWakelock; private boolean mAbort; @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { super.onCreate(); mNotificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); } @Override public void onDestroy() { unregisterReceiver(mBroadcastReceiver); super.onDestroy(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent != null && intent.getAction().equals(ACTION_START_MEDIASCANNING)) { mRemainingFiles = new ArrayList<>(); mAbort = false; FileModel directory = null; // read path to directory from extras Bundle extras = intent.getExtras(); if (extras != null) { String startDirectory = extras.getString(BUNDLE_KEY_DIRECTORY); directory = new FileModel(startDirectory); } if (BuildConfig.DEBUG) { Log.v(TAG, "start mediascanning"); } PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); mWakelock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "odyssey:wakelock:mediascanner"); mWakelock.acquire(); if (mBroadcastReceiver == null) { mBroadcastReceiver = new ActionReceiver(); // Create a filter to only handle certain actions IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(ACTION_CANCEL_MEDIASCANNING); registerReceiver(mBroadcastReceiver, intentFilter); } // create notification mBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle(getString(R.string.mediascanner_notification_title)) .setProgress(0, 0, true) .setSmallIcon(R.drawable.odyssey_notification); openChannel(); mBuilder.setOngoing(true); // Cancel action Intent nextIntent = new Intent(MediaScannerService.ACTION_CANCEL_MEDIASCANNING); PendingIntent nextPendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 1, nextIntent, PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.Action cancelAction = new NotificationCompat.Action.Builder(R.drawable.ic_close_24dp, getString(R.string.dialog_action_cancel), nextPendingIntent).build(); mBuilder.addAction(cancelAction); Notification notification = mBuilder.build(); startForeground(NOTIFICATION_ID, notification); mNotificationManager.notify(NOTIFICATION_ID, notification); // start scanning if (null != directory) { scanDirectory(this, directory); } } return START_NOT_STICKY; } private void updateNotification() { // Updates the notification but only every 10 elements to reduce load on the notification view if (mScannedFiles % 10 == 0 && !mAbort) { mBuilder.setProgress(mFilesToScan, mScannedFiles, false); mBuilder.setStyle(new NotificationCompat.BigTextStyle() .bigText(getString(R.string.mediascanner_notification_text, mScannedFiles, mFilesToScan))); mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build()); } } private void scanDirectory(final Context context, FileModel basePath) { new ListCreationTask(context).execute(basePath); } private void scanFileList(final Context context, List<FileModel> files) { mRemainingFiles = files; scanNextBunch(context); } /** * Proceeds to the next bunch of files to scan if any available. * * @param context Context used for scanning. */ private void scanNextBunch(final Context context) { if (mRemainingFiles.isEmpty() || mAbort) { // No files left to scan, stop service (delayed to allow the ServiceConnection to the MediaScanner to close itself) Timer delayedStopTimer = new Timer(); delayedStopTimer.schedule(new DelayedStopTask(), 100); return; } String[] bunch = new String[Math.min(MEDIASCANNER_BUNCH_SIZE, mRemainingFiles.size())]; int i = 0; ListIterator<FileModel> listIterator = mRemainingFiles.listIterator(); while (listIterator.hasNext() && i < MEDIASCANNER_BUNCH_SIZE) { bunch[i] = listIterator.next().getPath(); listIterator.remove(); i++; } MediaScannerConnection.scanFile(context, bunch, null, new MediaScanCompletedCallback(bunch.length, context)); } private void finishService() { if (BuildConfig.DEBUG) { Log.v(TAG, "finish mediascanning"); } mNotificationManager.cancel(NOTIFICATION_ID); stopForeground(true); if (mWakelock.isHeld()) { mWakelock.release(); } // Stop service. stopSelf(); } /** * Opens a notification channel and disables the LED and vibration */ private void openChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, this.getResources().getString(R.string.notification_channel_library_scanner), android.app.NotificationManager.IMPORTANCE_LOW); // Disable lights & vibration channel.enableVibration(false); channel.enableLights(false); channel.setVibrationPattern(null); // Register the channel mNotificationManager.createNotificationChannel(channel); } } private class MediaScanCompletedCallback implements MediaScannerConnection.OnScanCompletedListener { private final Context mContext; private final int mNumberOfFiles; private int mBunchScannedFiles; public MediaScanCompletedCallback(final int numberOfFiles, final Context context) { mContext = context; mNumberOfFiles = numberOfFiles; mBunchScannedFiles = 0; } @Override public void onScanCompleted(String path, Uri uri) { if (BuildConfig.DEBUG) { Log.v(TAG, "scan completed: " + uri); } mScannedFiles++; mBunchScannedFiles++; updateNotification(); if (mBunchScannedFiles == mNumberOfFiles) { if (BuildConfig.DEBUG) { Log.v(TAG, "Bunch complete, proceed to next one"); } scanNextBunch(mContext); } } } private class ActionReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (BuildConfig.DEBUG) { Log.e(TAG, "Broadcast requested"); } if (intent.getAction().equals(ACTION_CANCEL_MEDIASCANNING)) { if (BuildConfig.DEBUG) { Log.e(TAG, "Cancel requested"); } // abort scan after finish scanning current folder mAbort = true; // cancel notification mNotificationManager.cancel(NOTIFICATION_ID); stopForeground(true); } } } private class ListCreationTask extends AsyncTask<FileModel, Integer, List<FileModel>> { Context mContext; public ListCreationTask(Context context) { mContext = context; } @Override protected List<FileModel> doInBackground(FileModel... params) { List<FileModel> files = FileExplorerHelper.getInstance().getMissingDBFiles(mContext, params[0]); if (BuildConfig.DEBUG) { Log.v(TAG, "Got missing tracks: " + files.size()); } mFilesToScan = files.size(); scanFileList(mContext, files); return files; } } private class DelayedStopTask extends TimerTask { @Override public void run() { finishService(); } } }