com.kanedias.vanilla.audiotag.PluginService.java Source code

Java tutorial

Introduction

Here is the source code for com.kanedias.vanilla.audiotag.PluginService.java

Source

/*
 * Copyright (C) 2016 Oleg Chernovskiy <adonai@xaker.ru>
 *
 * 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 com.kanedias.vanilla.audiotag;

import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Binder;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.v4.content.FileProvider;
import android.support.v4.provider.DocumentFile;
import android.util.Log;
import android.widget.Toast;

import com.kanedias.vanilla.plugins.PluginConstants;
import com.kanedias.vanilla.plugins.PluginUtils;
import com.kanedias.vanilla.plugins.saf.SafRequestActivity;
import com.kanedias.vanilla.plugins.saf.SafUtils;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.audio.exceptions.CannotReadException;
import org.jaudiotagger.audio.exceptions.CannotWriteException;
import org.jaudiotagger.audio.exceptions.InvalidAudioFrameException;
import org.jaudiotagger.audio.exceptions.ReadOnlyFileException;
import org.jaudiotagger.audio.generic.Utils;
import org.jaudiotagger.tag.*;
import org.jaudiotagger.tag.id3.AbstractID3v2Tag;
import org.jaudiotagger.tag.id3.valuepair.ImageFormats;
import org.jaudiotagger.tag.images.AndroidArtwork;
import org.jaudiotagger.tag.images.Artwork;
import org.jaudiotagger.tag.reference.ID3V2Version;
import org.jaudiotagger.tag.reference.PictureTypes;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;

import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
import static com.kanedias.vanilla.plugins.PluginConstants.*;

/**
 * Main service of Plugin system.
 * This service must be able to handle ACTION_WAKE_PLUGIN, ACTION_REQUEST_PLUGIN_PARAMS and ACTION_LAUNCH_PLUGIN
 * intents coming from VanillaMusic.
 * <p/>
 * Casual conversation looks like this:
 * <pre>
 *     VanillaMusic                                 Plugin
 *          |                                         |
 *          |       ACTION_WAKE_PLUGIN broadcast      |
 *          |---------------------------------------->| (plugin init if just installed)
 *          |                                         |
 *          | ACTION_REQUEST_PLUGIN_PARAMS broadcast  |
 *          |---------------------------------------->| (this is handled by BroadcastReceiver first)
 *          |                                         |
 *          |      ACTION_HANDLE_PLUGIN_PARAMS        |
 *          |<----------------------------------------| (plugin answer with name and desc)
 *          |                                         |
 *          |           ACTION_LAUNCH_PLUGIN          |
 *          |---------------------------------------->| (plugin is allowed to show window)
 * </pre>
 *
 * @see PluginConstants
 * @see TagEditActivity
 *
 * @author Oleg Chernovskiy
 */
public class PluginService extends Service {

    private AtomicInteger mBindCounter = new AtomicInteger(0);

    private SharedPreferences mPrefs;

    private Intent mLaunchIntent;
    private AudioFile mAudioFile;
    private Tag mTag;

    public class PluginBinder extends Binder {
        public PluginService getService() {
            return PluginService.this;
        }
    }

    /**
     * If this is called, then tag edit activity requested bind procedure for this service
     * Usually service is already started and has file field initialized.
     *
     * @param intent intent passed to start this service
     * @return null if file load failed, plugin binder object otherwise
     */
    @Override
    public IBinder onBind(Intent intent) {
        mBindCounter.incrementAndGet();
        if (loadFile(false)) {
            return new PluginBinder();
        }
        return null;
    }

    /**
     * If this is called, then tag edit activity is finished with its user interaction and
     * service is safe to be stopped too.
     */
    @Override
    public boolean onUnbind(Intent intent) {
        // we need to stop this service or ServiceConnection will remain active and onBind won't be called again
        // activity will see old file loaded in such case!
        if (mBindCounter.decrementAndGet() == 0) {
            stopSelf();
        }
        return false;
    }

    @Override
    public void onCreate() {
        super.onCreate();

        TagOptionSingleton.getInstance().setAndroid(true);
        mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
    }

    /**
     * Main plugin service operation entry point. This is called each time plugins are quieried
     * and requested by main Vanilla Music app and also when plugins communicate with each other through P2P-intents.
     * @param intent intent provided by broadcast or request
     * @param flags - not used
     * @param startId - not used
     * @return always constant {@link #START_NOT_STICKY}
     */
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null) {
            final String action = intent.getAction();
            switch (action) {
            case ACTION_WAKE_PLUGIN:
                Log.i(LOG_TAG, "Plugin enabled!");
                break;
            case ACTION_REQUEST_PLUGIN_PARAMS:
                handleRequestPluginParams(intent);
                break;
            case ACTION_LAUNCH_PLUGIN:
                mLaunchIntent = intent;
                handleLaunchPlugin();
                break;
            default:
                Log.e(LOG_TAG, "Unknown intent action received!" + action);
            }
        }
        return START_NOT_STICKY;
    }

    /**
     * Sends plugin info back to Vanilla Music service.
     * @param intent intent from player
     */
    private void handleRequestPluginParams(Intent intent) {
        Intent answer = new Intent(ACTION_HANDLE_PLUGIN_PARAMS);
        answer.setPackage(intent.getPackage());
        answer.putExtra(EXTRA_PARAM_PLUGIN_NAME, getString(R.string.tag_editor));
        answer.putExtra(EXTRA_PARAM_PLUGIN_APP, getApplicationInfo());
        answer.putExtra(EXTRA_PARAM_PLUGIN_DESC, getString(R.string.plugin_desc));
        sendBroadcast(answer);
    }

    private void handleLaunchPlugin() {
        if (mLaunchIntent.hasExtra(EXTRA_PARAM_SAF_P2P)) {
            // it's SAF intent that is returned from SAF activity, should have URI inside
            persistThroughSaf(mLaunchIntent);
            return;
        }

        // if it's P2P intent, just try to read/write file as requested
        if (PluginUtils.havePermissions(this, WRITE_EXTERNAL_STORAGE) && mLaunchIntent.hasExtra(EXTRA_PARAM_P2P)) {
            if (loadFile(false)) {
                handleP2pIntent();
            }
            stopSelf();
            return;
        }

        // either we have no permissions to write to SD and activity is requested
        // or this is normal user-requested operation (non-P2P)
        // start activity!
        Intent dialogIntent = new Intent(this, TagEditActivity.class);
        dialogIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        dialogIntent.putExtras(mLaunchIntent);
        startActivity(dialogIntent);
    }

    public Tag getTag() {
        return mTag;
    }

    /**
     * Loads file as {@link AudioFile} and performs initial tag creation if it's absent.
     * If error happens while loading, shows popup indicating error details.
     * @return true if and only if file was successfully read and initialized in tag system, false otherwise
     */
    public boolean loadFile(boolean force) {
        if (!force && mTag != null) {
            return true; // don't reload same file
        }

        // we need only path passed to us
        Uri fileUri = mLaunchIntent.getParcelableExtra(EXTRA_PARAM_URI);
        if (fileUri == null) {
            return false;
        }

        File file = new File(fileUri.getPath());
        if (!file.exists()) {
            return false;
        }

        try {
            mAudioFile = AudioFileIO.read(file);
            mTag = mAudioFile.getTagOrCreateAndSetDefault();
        } catch (CannotReadException | IOException | TagException | ReadOnlyFileException
                | InvalidAudioFrameException e) {
            Log.e(LOG_TAG, String.format(getString(R.string.error_audio_file), file.getAbsolutePath()), e);
            Toast.makeText(this, String.format(getString(R.string.error_audio_file) + ", %s",
                    file.getAbsolutePath(), e.getLocalizedMessage()), Toast.LENGTH_SHORT).show();
            return false;
        }

        return true;
    }

    /**
     * upgrades ID3v2.x tag to ID3v2.4 for loaded file.
     * Call this method only if you know exactly that file contains ID3 tag.
     */
    public void upgradeID3v2() {
        mTag = mAudioFile.convertID3Tag((AbstractID3v2Tag) mTag, ID3V2Version.ID3_V24);
        mAudioFile.setTag(mTag);
        writeFile();
    }

    /**
     * Writes file to backing filesystem provider, this may be either SAF-managed sdcard or internal storage.
     */
    public void writeFile() {
        if (SafUtils.isSafNeeded(mAudioFile.getFile(), this)) {
            if (mPrefs.contains(PREF_SDCARD_URI)) {
                // we already got the permission!
                persistThroughSaf(null);
                return;
            }

            // request SAF permissions in SAF activity
            Intent safIntent = new Intent(this, SafRequestActivity.class);
            safIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            safIntent.putExtra(PluginConstants.EXTRA_PARAM_PLUGIN_APP, getApplicationInfo());
            safIntent.putExtras(mLaunchIntent);
            startActivity(safIntent);
            // it will pass us URI back after the work is done
        } else {
            persistThroughFile();
        }
    }

    /**
     * Writes changes in tags directly into file and closes activity.
     * Call this if you're absolutely sure everything is right with file and tag.
     */
    private void persistThroughFile() {
        try {
            AudioFileIO.write(mAudioFile);
            Toast.makeText(this, R.string.file_written_successfully, Toast.LENGTH_SHORT).show();

            // update media database
            File persisted = mAudioFile.getFile();
            MediaScannerConnection.scanFile(this, new String[] { persisted.getAbsolutePath() }, null, null);
        } catch (CannotWriteException e) {
            Log.e(LOG_TAG, String.format(getString(R.string.error_audio_file), mAudioFile.getFile().getPath()), e);
            Toast.makeText(this, String.format(getString(R.string.error_audio_file) + ", %s",
                    mAudioFile.getFile().getPath(), e.getLocalizedMessage()), Toast.LENGTH_LONG).show();
        }
    }

    /**
     * Write changes through SAF framework - the only way to do it in Android > 4.4 when working with SD card
     * @param activityResponse response with URI contained in. Can be null if tree permission is already given.
     */
    private void persistThroughSaf(Intent activityResponse) {
        Uri safUri;
        if (mPrefs.contains(PREF_SDCARD_URI)) {
            // no sorcery can allow you to gain URI to the document representing file you've been provided with
            // you have to find it again using now Document API

            // /storage/volume/Music/some.mp3 will become [storage, volume, music, some.mp3]
            List<String> pathSegments = new ArrayList<>(
                    Arrays.asList(mAudioFile.getFile().getAbsolutePath().split("/")));
            Uri allowedSdRoot = Uri.parse(mPrefs.getString(PREF_SDCARD_URI, ""));
            safUri = findInDocumentTree(DocumentFile.fromTreeUri(this, allowedSdRoot), pathSegments);
        } else {
            Intent originalSafResponse = activityResponse.getParcelableExtra(EXTRA_PARAM_SAF_P2P);
            safUri = originalSafResponse.getData();
        }

        if (safUri == null) {
            // nothing selected or invalid file?
            Toast.makeText(this, R.string.saf_nothing_selected, Toast.LENGTH_LONG).show();
            return;
        }

        try {
            // we don't have fd-related audiotagger write functions, have to use workaround
            // write audio file to temp cache dir
            // jaudiotagger can't work through file descriptor, sadly
            File original = mAudioFile.getFile();
            File temp = File.createTempFile("tmp-media", '.' + Utils.getExtension(original));
            Utils.copy(original, temp); // jtagger writes only header, we should copy the rest
            temp.deleteOnExit(); // in case of exception it will be deleted too
            mAudioFile.setFile(temp);
            AudioFileIO.write(mAudioFile);

            // retrieve FD from SAF URI
            ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(safUri, "rw");
            if (pfd == null) {
                // should not happen
                Log.e(LOG_TAG, "SAF provided incorrect URI!" + safUri);
                return;
            }

            // now read persisted data and write it to real FD provided by SAF
            FileInputStream fis = new FileInputStream(temp);
            byte[] audioContent = TagEditorUtils.readFully(fis);
            FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor());
            fos.write(audioContent);
            fos.close();

            // delete temporary file used
            temp.delete();

            // rescan original file
            MediaScannerConnection.scanFile(this, new String[] { original.getAbsolutePath() }, null, null);
            Toast.makeText(this, R.string.file_written_successfully, Toast.LENGTH_SHORT).show();
        } catch (Exception e) {
            Toast.makeText(this, getString(R.string.saf_write_error) + e.getLocalizedMessage(), Toast.LENGTH_LONG)
                    .show();
            Log.e(LOG_TAG, "Failed to write to file descriptor provided by SAF!", e);
        }
    }

    /**
     * Finds needed file through Document API for SAF. It's not optimized yet - you can still gain wrong URI on
     * files such as "/a/b/c.mp3" and "/b/a/c.mp3", but I consider it complete enough to be usable.
     * @param currentDir - document file representing current dir of search
     * @param remainingPathSegments - path segments that are left to find
     * @return URI for found file. Null if nothing found.
     */
    @Nullable
    private Uri findInDocumentTree(DocumentFile currentDir, List<String> remainingPathSegments) {
        for (DocumentFile file : currentDir.listFiles()) {
            int index = remainingPathSegments.indexOf(file.getName());
            if (index == -1) {
                continue;
            }

            if (file.isDirectory()) {
                remainingPathSegments.remove(file.getName());
                return findInDocumentTree(file, remainingPathSegments);
            }

            if (file.isFile() && index == remainingPathSegments.size() - 1) {
                // got to the last part
                return file.getUri();
            }
        }

        return null;
    }

    /**
     * This plugin also has P2P functionality with others. It provides generic way to
     * read and write tags for the file.
     * <br/>
     * If intent is passed with EXTRA_PARAM_P2P and READ then EXTRA_PARAM_P2P_KEY is considered
     * as an array of field keys to retrieve from file. The values read are written in the same order
     * into answer intent into EXTRA_PARAM_P2P_VAL.
     * <br/>
     * If intent is passed with EXTRA_PARAM_P2P and WRITE then EXTRA_PARAM_P2P_KEY is considered
     * as an array of field keys to write to file. EXTRA_PARAM_P2P_VAL represents values to be written in
     * the same order.
     *
     */
    private void handleP2pIntent() {
        String request = mLaunchIntent.getStringExtra(EXTRA_PARAM_P2P);
        switch (request) {
        case P2P_WRITE_TAG: {
            String[] fields = mLaunchIntent.getStringArrayExtra(EXTRA_PARAM_P2P_KEY);
            String[] values = mLaunchIntent.getStringArrayExtra(EXTRA_PARAM_P2P_VAL);
            for (int i = 0; i < fields.length; ++i) {
                try {
                    FieldKey key = FieldKey.valueOf(fields[i]);
                    mTag.setField(key, values[i]);
                } catch (IllegalArgumentException iae) {
                    Log.e(LOG_TAG, "Invalid tag requested: " + fields[i], iae);
                    Toast.makeText(this, R.string.invalid_tag_requested, Toast.LENGTH_SHORT).show();
                } catch (FieldDataInvalidException e) {
                    // should not happen
                    Log.e(LOG_TAG, "Error writing tag", e);
                }
            }
            writeFile();
            break;
        }
        case P2P_READ_TAG: {
            String[] fields = mLaunchIntent.getStringArrayExtra(EXTRA_PARAM_P2P_KEY);
            ApplicationInfo responseApp = mLaunchIntent.getParcelableExtra(EXTRA_PARAM_PLUGIN_APP);

            String[] values = new String[fields.length];
            for (int i = 0; i < fields.length; ++i) {
                try {
                    FieldKey key = FieldKey.valueOf(fields[i]);
                    values[i] = mTag.getFirst(key);
                } catch (IllegalArgumentException iae) {
                    Log.e(LOG_TAG, "Invalid tag requested: " + fields[i], iae);
                    Toast.makeText(this, R.string.invalid_tag_requested, Toast.LENGTH_SHORT).show();
                }
            }

            Intent response = new Intent(ACTION_LAUNCH_PLUGIN);
            response.putExtra(EXTRA_PARAM_P2P, P2P_READ_TAG);
            response.setPackage(responseApp.packageName);
            response.putExtra(EXTRA_PARAM_P2P_VAL, values);
            startService(response);
            break;
        }
        case P2P_READ_ART: {
            ApplicationInfo responseApp = mLaunchIntent.getParcelableExtra(EXTRA_PARAM_PLUGIN_APP);
            Artwork cover = mTag.getFirstArtwork();
            Uri uri = null;
            try {
                if (cover == null) {
                    Log.w(LOG_TAG, "Artwork is not present for file " + mAudioFile.getFile().getName());
                    break;
                }

                File coversDir = new File(getCacheDir(), "covers");
                if (!coversDir.exists() && !coversDir.mkdir()) {
                    Log.e(LOG_TAG, "Couldn't create dir for covers! Path " + getCacheDir());
                    break;
                }

                // cleanup old images
                for (File oldImg : coversDir.listFiles()) {
                    if (!oldImg.delete()) {
                        Log.w(LOG_TAG, "Couldn't delete old image file! Path " + oldImg);
                    }
                }

                // write artwork to file
                File coverTmpFile = new File(coversDir, UUID.randomUUID().toString());
                FileOutputStream fos = new FileOutputStream(coverTmpFile);
                fos.write(cover.getBinaryData());
                fos.close();

                // create sharable uri
                uri = FileProvider.getUriForFile(this, "com.kanedias.vanilla.audiotag.fileprovider", coverTmpFile);
            } catch (IOException e) {
                Log.e(LOG_TAG, "Couldn't write to cache file", e);
                Toast.makeText(this, e.getLocalizedMessage(), Toast.LENGTH_SHORT).show();
            } finally {
                // share uri if created successfully
                Intent response = new Intent(ACTION_LAUNCH_PLUGIN);
                response.putExtra(EXTRA_PARAM_P2P, P2P_READ_ART);
                response.setPackage(responseApp.packageName);
                if (uri != null) {
                    grantUriPermission(responseApp.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
                    response.putExtra(EXTRA_PARAM_P2P_VAL, uri);
                }
                startService(response);
            }
            break;
        }
        case P2P_WRITE_ART: {
            Uri imgLink = mLaunchIntent.getParcelableExtra(EXTRA_PARAM_P2P_VAL);

            try {
                ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(imgLink, "r");
                if (pfd == null) {
                    return;
                }

                FileInputStream fis = new FileInputStream(pfd.getFileDescriptor());
                byte[] imgBytes = TagEditorUtils.readFully(fis);

                Artwork cover = new AndroidArtwork();
                cover.setBinaryData(imgBytes);
                cover.setMimeType(ImageFormats.getMimeTypeForBinarySignature(imgBytes));
                cover.setDescription("");
                cover.setPictureType(PictureTypes.DEFAULT_ID);

                mTag.deleteArtworkField();
                mTag.setField(cover);
            } catch (IOException | IllegalArgumentException | FieldDataInvalidException e) {
                Log.e(LOG_TAG, "Invalid artwork!", e);
                Toast.makeText(this, R.string.invalid_artwork_provided, Toast.LENGTH_SHORT).show();
            }

            writeFile();
            break;
        }
        }

    }
}