Homer Player is an audiobook player for the elderly. Its simple user interface makes it easy to operate for seniors and people with poor vision (or both). It features simplicity so far you can even use it in "single application mode" (kiosk), to make the Android device a dedicated Audio Book Reader the user cannot "accidentally close and get lost on".
Features:
simplicity: just a list of audiobooks and a "start" button,
flip-to-stop: there's no need to press any buttons to pause playback, just put the device face down on a table,
low-vision friendly interface: book titles are read aloud and high contrast, large UI elements are used,
adjust speed: slow down playback for those hard of hearing.
Single application mode (kiosk):
With this mode enabled the user cannot exit the application so they don't need to know how to use a tablet.
Perfect for building a dedicated audiobook player for your grandparents.
Let me know if you need help, like the app or hate it.
--------------------------------------------------------------------------------
/app/src/main/res/xml/preferences_playback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
15 |
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/logging/UncaughtExceptionLogger.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.logging;
2 |
3 | import androidx.annotation.NonNull;
4 | import androidx.annotation.Nullable;
5 |
6 | import timber.log.Timber;
7 |
8 | public class UncaughtExceptionLogger implements Thread.UncaughtExceptionHandler {
9 |
10 | @Nullable
11 | private final Thread.UncaughtExceptionHandler previousHandler;
12 |
13 | public UncaughtExceptionLogger(@Nullable Thread.UncaughtExceptionHandler previousHandler) {
14 | this.previousHandler = previousHandler;
15 | }
16 |
17 | public static void install() {
18 | UncaughtExceptionLogger handler =
19 | new UncaughtExceptionLogger(Thread.getDefaultUncaughtExceptionHandler());
20 | Thread.setDefaultUncaughtExceptionHandler(handler);
21 | }
22 |
23 | @Override
24 | public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) {
25 | Timber.e(throwable, "Crash!\n");
26 | if (previousHandler != null) previousHandler.uncaughtException(thread, throwable);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_audiobooks_folder.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
19 |
20 |
27 |
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/HomeActivity.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.app.Activity;
4 | import android.content.ComponentName;
5 | import android.content.Context;
6 | import android.content.Intent;
7 | import android.content.pm.PackageManager;
8 | import android.os.Bundle;
9 |
10 | public class HomeActivity extends Activity {
11 |
12 | @Override
13 | protected void onCreate(Bundle savedInstanceState) {
14 | super.onCreate(savedInstanceState);
15 |
16 | startActivity(new Intent(this, MainActivity.class));
17 | }
18 |
19 | public static void setEnabled(Context context, boolean isEnabled) {
20 | ComponentName componentName = new ComponentName(context, HomeActivity.class);
21 | int enabledState = isEnabled
22 | ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
23 | : PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
24 | context.getPackageManager().setComponentEnabledSetting(
25 | componentName, enabledState, PackageManager.DONT_KILL_APP);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Marcin Simonides
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/deviceadmin/GetProvisioningModeActivity.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.deviceadmin;
2 |
3 | import android.content.Intent;
4 | import android.os.Bundle;
5 |
6 | import androidx.annotation.Nullable;
7 | import androidx.annotation.RequiresApi;
8 | import androidx.appcompat.app.AppCompatActivity;
9 |
10 | @RequiresApi(29)
11 | public class GetProvisioningModeActivity extends AppCompatActivity {
12 |
13 | @Override
14 | protected void onCreate(@Nullable Bundle savedInstanceState) {
15 | super.onCreate(savedInstanceState);
16 |
17 | finishWithDeviceOwnerIntent();
18 | }
19 |
20 | private void finishWithDeviceOwnerIntent() {
21 | Intent intent = new Intent();
22 | intent.putExtra(
23 | android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_MODE,
24 | android.app.admin.DevicePolicyManager.PROVISIONING_MODE_FULLY_MANAGED_DEVICE
25 | );
26 | intent.putExtra(
27 | android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_SKIP_EDUCATION_SCREENS,
28 | true
29 | );
30 | setResult(RESULT_OK, intent);
31 | finish();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/SamplesMap.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer;
2 |
3 | import java.util.HashMap;
4 | import java.util.Map;
5 |
6 | import android.net.Uri;
7 |
8 | import javax.inject.Inject;
9 |
10 | public class SamplesMap {
11 | private static final String DEFAULT_LOCALE = "en";
12 | private static final String EN_SAMPLES_URL = "https://homer-player.firebaseapp.com/samples.zip";
13 | private static final String FR_SAMPLES_URL = "https://homer-player.firebaseapp.com/samples-fr.zip";
14 |
15 | private Map map;
16 |
17 | @Inject
18 | public SamplesMap(){
19 | this.map = new HashMap();
20 | // Language codes must match those returned by Locale.getLanguage(), which may not be the ones in the newest ISO 639 standard.
21 | this.map.put("en", EN_SAMPLES_URL);
22 | this.map.put("fr", FR_SAMPLES_URL);
23 | }
24 |
25 | public Uri getSamples(String language){
26 | String url;
27 | if(this.map.containsKey(language)){
28 | url = this.map.get(language);
29 | } else {
30 | url = this.map.get(DEFAULT_LOCALE);
31 | }
32 | return Uri.parse(url);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/filescanner/LegacyFolderProvider.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.filescanner;
2 |
3 | import android.content.Context;
4 | import android.os.Environment;
5 |
6 | import androidx.annotation.NonNull;
7 |
8 | import com.studio4plus.homerplayer.util.CollectionUtils;
9 | import com.studio4plus.homerplayer.util.FilesystemUtil;
10 |
11 | import java.io.File;
12 | import java.util.List;
13 |
14 | public class LegacyFolderProvider implements ScanFilesTask.FolderProvider {
15 |
16 | private static final String AUDIOBOOKS_FOLDER_NAME = "AudioBooks";
17 |
18 | private final Context appContext;
19 |
20 | public LegacyFolderProvider(@NonNull Context appContext) {
21 | this.appContext = appContext;
22 | }
23 |
24 | @Override
25 | @NonNull
26 | public List getFolders() {
27 | List rootDirs = FilesystemUtil.listRootDirs(appContext);
28 | File defaultStorage = Environment.getExternalStorageDirectory();
29 | if (!CollectionUtils.containsByValue(rootDirs, defaultStorage))
30 | rootDirs.add(defaultStorage);
31 |
32 | return CollectionUtils.map(rootDirs, (rootDir) -> new File(rootDir, AUDIOBOOKS_FOLDER_NAME));
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/classic/BookListChildFragment.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.classic;
2 |
3 | import android.animation.Animator;
4 | import android.animation.ValueAnimator;
5 | import androidx.fragment.app.Fragment;
6 |
7 | import com.studio4plus.homerplayer.R;
8 |
9 | /**
10 | * A class implementing a workaround for https://code.google.com/p/android/issues/detail?id=55228
11 | *
12 | * Inspired by http://stackoverflow.com/a/23276145/3892517
13 | */
14 | public class BookListChildFragment extends Fragment {
15 |
16 | @Override
17 | public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) {
18 | final Fragment parent = getParentFragment();
19 |
20 | // Apply the workaround only if this is a child fragment, and the parent
21 | // is being removed.
22 | if (!enter && parent != null && parent.isRemoving()) {
23 | ValueAnimator nullAnimation = new ValueAnimator();
24 | nullAnimation.setIntValues(1, 1);
25 | nullAnimation.setDuration(R.integer.flip_animation_time_half_ms);
26 | return nullAnimation;
27 | } else {
28 | return super.onCreateAnimator(transit, enter, nextAnim);
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/PressReleaseDetector.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import androidx.annotation.NonNull;
4 |
5 | import android.annotation.SuppressLint;
6 | import android.view.MotionEvent;
7 | import android.view.View;
8 |
9 | public class PressReleaseDetector implements View.OnTouchListener {
10 |
11 | public interface Listener {
12 | void onPressed(View v, float x, float y);
13 | void onReleased(View v, float x, float y);
14 | }
15 |
16 | private final Listener listener;
17 | private boolean isPressed;
18 |
19 | public PressReleaseDetector(@NonNull Listener listener) {
20 | this.listener = listener;
21 | }
22 |
23 | @SuppressLint("ClickableViewAccessibility")
24 | @Override
25 | public boolean onTouch(View v, MotionEvent event) {
26 | if (event.getAction() == MotionEvent.ACTION_DOWN) {
27 | isPressed = true;
28 | listener.onPressed(v, event.getX(), event.getY());
29 | return true;
30 | } else if (isPressed && event.getAction() == MotionEvent.ACTION_UP) {
31 | listener.onReleased(v, event.getX(), event.getY());
32 | return true;
33 | }
34 | return false;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/player/PlaybackController.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.player;
2 |
3 | import android.net.Uri;
4 |
5 | public interface PlaybackController {
6 |
7 | interface Observer {
8 | void onDuration(Uri uri, long durationMs);
9 |
10 | /**
11 | * Playback position progressed. Called more or less once per second of playback in media
12 | * time (i.e. affected by the playback speed).
13 | */
14 | void onPlaybackProgressed(long currentPositionMs);
15 |
16 | /**
17 | * Playback ended because it reached the end of track
18 | */
19 | void onPlaybackEnded();
20 |
21 | /**
22 | * Playback stopped on request.
23 | */
24 | void onPlaybackStopped(long currentPositionMs);
25 |
26 | /**
27 | * Error playing file.
28 | */
29 | void onPlaybackError(Uri uri);
30 |
31 | /**
32 | * The player has been released.
33 | */
34 | void onPlayerReleased();
35 | }
36 |
37 | void setObserver(Observer observer);
38 | void start(Uri uri, long positionPosition);
39 | void pause();
40 | void stop();
41 | void release();
42 | long getCurrentPosition();
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/main_activity.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
14 |
21 |
22 |
23 |
29 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/concurrency/BackgroundDeferred.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.concurrency;
2 |
3 | import android.os.Handler;
4 | import androidx.annotation.NonNull;
5 |
6 | import java.util.concurrent.Callable;
7 |
8 | public class BackgroundDeferred extends BaseDeferred implements Runnable {
9 |
10 | private final @NonNull Callable task;
11 | private final @NonNull Handler mainThreadHandler;
12 |
13 | BackgroundDeferred(@NonNull Callable task, @NonNull Handler mainThreadHandler) {
14 | this.task = task;
15 | this.mainThreadHandler = mainThreadHandler;
16 | }
17 |
18 | @Override
19 | public void run() {
20 | try {
21 | final @NonNull V newResult = task.call();
22 | mainThreadHandler.post(new Runnable() {
23 | @Override
24 | public void run() {
25 | setResult(newResult);
26 | }
27 | });
28 | } catch (final Exception e) {
29 | mainThreadHandler.post(new Runnable() {
30 | @Override
31 | public void run() {
32 | setException(e);
33 | }
34 | });
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/util/MediaScannerUtil.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.util;
2 |
3 | import android.content.Context;
4 | import android.media.MediaScannerConnection;
5 | import android.net.Uri;
6 |
7 | import java.io.File;
8 |
9 | public class MediaScannerUtil {
10 |
11 | public static void scanAndDeleteFile(final Context context, final File file) {
12 | MediaScannerConnection.OnScanCompletedListener listener =
13 | new MediaScannerConnection.OnScanCompletedListener() {
14 | @Override
15 | public void onScanCompleted(String path, Uri uri) {
16 | if (path == null)
17 | return;
18 |
19 | File scannedFile = new File(path);
20 | if (scannedFile.equals(file)) {
21 | //noinspection ResultOfMethodCallIgnored
22 | file.delete();
23 | MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
24 | }
25 | }
26 | };
27 |
28 | MediaScannerConnection.scanFile(context, new String[] { file.getAbsolutePath() }, null, listener);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/gitHub/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/preferences_ui.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
19 |
20 |
26 |
27 |
33 |
--------------------------------------------------------------------------------
/app/src/main/res/values/button_styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
17 |
18 |
23 |
24 |
29 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_book_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
17 |
18 |
27 |
28 |
35 |
36 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_no_books.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
18 |
19 |
26 |
27 |
33 |
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/util/CollectionUtils.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.util;
2 |
3 | import androidx.annotation.NonNull;
4 |
5 | import com.google.common.base.Function;
6 |
7 | import java.util.ArrayList;
8 | import java.util.Collection;
9 | import java.util.List;
10 |
11 | public class CollectionUtils {
12 |
13 | @NonNull
14 | public static List map(@NonNull Collection collection, @NonNull Function mapper) {
15 | List result = new ArrayList<>(collection.size());
16 | for (T item : collection) {
17 | result.add(mapper.apply(item));
18 | }
19 | return result;
20 | }
21 |
22 | public static boolean containsByValue(List items, Type needle) {
23 | for (Type item : items)
24 | if (item.equals(needle))
25 | return true;
26 | return false;
27 | }
28 |
29 | public static boolean any(@NonNull Collection collection, @NonNull Predicate predicate) {
30 | for (Type item : collection) {
31 | if (predicate.isTrue(item))
32 | return true;
33 | }
34 | return false;
35 | }
36 |
37 | public static List filter(@NonNull Collection collection, @NonNull Predicate predicate) {
38 | List result = new ArrayList<>(collection.size());
39 | for (Type item : collection) {
40 | if (predicate.isTrue(item)) {
41 | result.add(item);
42 | }
43 | }
44 | return result;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/content/ConfigurationCursor.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.content;
2 |
3 | import android.database.AbstractCursor;
4 |
5 | public class ConfigurationCursor extends AbstractCursor {
6 |
7 | private final static String[] COLUMN_NAMES = { "KioskModeAvailable", "KioskModeEnabled" };
8 | private final boolean[] values;
9 |
10 | ConfigurationCursor(boolean isKioskModeAvailable, boolean isKioskModeEnabled) {
11 | this.values = new boolean[]{ isKioskModeAvailable, isKioskModeEnabled };
12 | }
13 |
14 | @Override
15 | public int getCount() {
16 | return 1;
17 | }
18 |
19 | @Override
20 | public String[] getColumnNames() {
21 | return COLUMN_NAMES;
22 | }
23 |
24 | @Override
25 | public String getString(int i) {
26 | return Boolean.toString(values[i]);
27 | }
28 |
29 | @Override
30 | public short getShort(int i) {
31 | return (short) (values[i] ? 1 : 0);
32 | }
33 |
34 | @Override
35 | public int getInt(int i) {
36 | return values[i] ? 1 : 0;
37 | }
38 |
39 | @Override
40 | public long getLong(int i) {
41 | return values[i] ? 1 : 0;
42 | }
43 |
44 | @Override
45 | public float getFloat(int i) {
46 | return values[i] ? 1 : 0;
47 | }
48 |
49 | @Override
50 | public double getDouble(int i) {
51 | return values[i] ? 1 : 0;
52 | }
53 |
54 | @Override
55 | public boolean isNull(int i) {
56 | return false;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/concurrency/BaseDeferred.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.concurrency;
2 |
3 | import androidx.annotation.NonNull;
4 | import androidx.annotation.Nullable;
5 |
6 | import java.util.ArrayList;
7 | import java.util.List;
8 |
9 | /**
10 | * A straightforward implementation of SimpleFuture.
11 | * It's intended for use only on a single thread.
12 | *
13 | * Note: I don't need the full power of ListenableFutures nor Rx yet.
14 | */
15 | public class BaseDeferred implements SimpleFuture {
16 |
17 | private final @NonNull List> listeners = new ArrayList<>();
18 | private @Nullable V result;
19 | private @Nullable Throwable exception;
20 |
21 | @Override
22 | public void addListener(@NonNull Listener listener) {
23 | listeners.add(listener);
24 | if (result != null)
25 | listener.onResult(result);
26 | else if (exception != null)
27 | listener.onException(exception);
28 | }
29 |
30 | @Override
31 | public void removeListener(@NonNull Listener listener) {
32 | listeners.remove(listener);
33 | }
34 |
35 | protected void setResult(@NonNull V result) {
36 | this.result = result;
37 | for (Listener listener : listeners)
38 | listener.onResult(result);
39 | }
40 |
41 | protected void setException(@NonNull Throwable exception) {
42 | this.exception = exception;
43 | for (Listener listener : listeners)
44 | listener.onException(exception);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/settings/ConfirmDialogPreference.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.settings;
2 |
3 | import android.annotation.TargetApi;
4 | import android.content.Context;
5 |
6 | import androidx.annotation.Nullable;
7 | import androidx.preference.DialogPreference;
8 | import android.util.AttributeSet;
9 |
10 | import com.studio4plus.homerplayer.R;
11 |
12 | class ConfirmDialogPreference extends DialogPreference {
13 |
14 | public interface OnConfirmListener {
15 | void onConfirmed();
16 | }
17 |
18 | @Nullable
19 | private OnConfirmListener listener;
20 |
21 | @TargetApi(21)
22 | public ConfirmDialogPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
23 | super(context, attrs, defStyleAttr, defStyleRes);
24 | }
25 |
26 | public ConfirmDialogPreference(Context context, AttributeSet attrs, int defStyleAttr) {
27 | super(context, attrs, defStyleAttr);
28 | }
29 |
30 | public ConfirmDialogPreference(Context context, AttributeSet attrs) {
31 | super(context, attrs);
32 | }
33 |
34 | public ConfirmDialogPreference(Context context) {
35 | super(context);
36 | }
37 |
38 | @Override
39 | public int getDialogLayoutResource() {
40 | return R.layout.preference_dialog_confirm;
41 | }
42 |
43 | void setOnConfirmListener(OnConfirmListener listener) {
44 | this.listener = listener;
45 | }
46 |
47 | void onDialogClosed(boolean positive) {
48 | if (positive && listener != null)
49 | listener.onConfirmed();
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/hint_horizontal_image.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
17 |
18 |
22 |
23 |
28 |
29 |
33 |
34 |
35 |
36 |
46 |
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/OrientationActivityDelegate.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.app.Activity;
4 | import android.content.SharedPreferences;
5 | import android.preference.PreferenceManager;
6 | import androidx.annotation.NonNull;
7 |
8 | import com.studio4plus.homerplayer.GlobalSettings;
9 |
10 | public class OrientationActivityDelegate
11 | implements SharedPreferences.OnSharedPreferenceChangeListener {
12 |
13 | private final Activity activity;
14 | private final GlobalSettings globalSettings;
15 |
16 | public OrientationActivityDelegate(@NonNull Activity activity, GlobalSettings globalSettings) {
17 | this.activity = activity;
18 | this.globalSettings = globalSettings;
19 | updateOrientation();
20 | }
21 |
22 | public void onStart() {
23 | SharedPreferences sharedPreferences =
24 | PreferenceManager.getDefaultSharedPreferences(activity);
25 | updateOrientation();
26 | sharedPreferences.registerOnSharedPreferenceChangeListener(this);
27 | }
28 |
29 | public void onStop() {
30 | SharedPreferences sharedPreferences =
31 | PreferenceManager.getDefaultSharedPreferences(activity);
32 | sharedPreferences.unregisterOnSharedPreferenceChangeListener(this);
33 | }
34 |
35 | @Override
36 | public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
37 | updateOrientation();
38 | }
39 |
40 | private void updateOrientation() {
41 | activity.setRequestedOrientation(globalSettings.getScreenOrientation());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/Speaker.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.content.Context;
4 | import android.speech.tts.TextToSpeech;
5 |
6 | import java.util.HashMap;
7 | import java.util.Locale;
8 |
9 | class Speaker implements TextToSpeech.OnInitListener {
10 |
11 | // TTS is usually much louder than a regular audio book recording.
12 | private static final String TTS_VOLUME_ADJUSTMENT = "0.5";
13 |
14 | private final Locale locale;
15 | private final TextToSpeech tts;
16 | private final HashMap speechParams = new HashMap<>();
17 |
18 | private boolean ttsReady;
19 | private String pendingSpeech;
20 |
21 | Speaker(Context context) {
22 | this.locale = context.getResources().getConfiguration().locale;
23 | this.tts = new TextToSpeech(context, this);
24 | speechParams.put(TextToSpeech.Engine.KEY_PARAM_VOLUME, TTS_VOLUME_ADJUSTMENT);
25 | }
26 |
27 | @Override
28 | public void onInit(int status) {
29 | if (status == TextToSpeech.SUCCESS) {
30 | ttsReady = true;
31 | tts.setLanguage(locale);
32 | tts.speak(pendingSpeech, TextToSpeech.QUEUE_FLUSH, speechParams);
33 | pendingSpeech = null;
34 | }
35 | }
36 |
37 | public void speak(String text) {
38 | if (ttsReady) {
39 | tts.speak(text, TextToSpeech.QUEUE_FLUSH, speechParams);
40 | } else {
41 | pendingSpeech = text;
42 | }
43 | }
44 |
45 | public void stop() {
46 | tts.stop();
47 | pendingSpeech = null;
48 | }
49 |
50 | public void shutdown() {
51 | ttsReady = false;
52 | tts.shutdown();
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/MediaStoreUpdateObserver.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer;
2 |
3 | import android.database.ContentObserver;
4 | import android.os.Handler;
5 |
6 | import androidx.annotation.NonNull;
7 |
8 | import com.studio4plus.homerplayer.events.MediaStoreUpdateEvent;
9 |
10 | import javax.inject.Inject;
11 |
12 | import de.greenrobot.event.EventBus;
13 |
14 | /**
15 | * Observe for changes to media files and post a MediaStoreUpdateEvent to the event bus to trigger
16 | * a scan for new audiobooks.
17 | *
18 | * The onChange method may be called a number of times as media files are being changed on the
19 | * device. To avoid rescanning often, a rescan is triggered only after RESCAN_DELAY_MS milliseconds
20 | * have passed since the last onChange call.
21 | */
22 | public class MediaStoreUpdateObserver extends ContentObserver {
23 |
24 | private static final int RESCAN_DELAY_MS = 5000;
25 |
26 | @NonNull
27 | private final Handler mainThreadHandler;
28 | @NonNull
29 | private final EventBus eventBus;
30 |
31 | @Inject
32 | public MediaStoreUpdateObserver(@NonNull Handler mainThreadHandler, @NonNull EventBus eventBus) {
33 | super(mainThreadHandler);
34 | this.mainThreadHandler = mainThreadHandler;
35 | this.eventBus = eventBus;
36 | }
37 |
38 | @Override
39 | public void onChange(boolean selfChange) {
40 | mainThreadHandler.removeCallbacks(delayedRescanTask);
41 | mainThreadHandler.postDelayed(delayedRescanTask, RESCAN_DELAY_MS);
42 | }
43 |
44 | private final Runnable delayedRescanTask = new Runnable() {
45 | @Override
46 | public void run() {
47 | eventBus.post(new MediaStoreUpdateEvent());
48 | }
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Homer Player [](https://codeclimate.com/github/msimonides/homerplayer)
2 | ============
3 |
4 | An audio book player for the elderly and visually impaired.
5 |
6 | [Go to the project website](http://msimonides.github.io/homerplayer/)
7 | or
8 | [watch the video](https://www.youtube.com/watch?v=RfLkoLtxzng).
9 |
10 | Project Ended
11 | -------------
12 | This project is no longer being maintained. See [homerplayer2](https://github.com/msimonides/homerplayer2) for the successor of this app.
13 |
14 | Project Goal
15 | ------------
16 | Turn a regular Android tablet into a dedicated audio book player that can be
17 | easily used by visually impaired and the elderly.
18 |
19 | Assumptions
20 | -----------
21 | * used at home,
22 | * focused on audio book playback, not music,
23 | * controlled with imprecise gestures and subject to accidental touch,
24 | * the user can see something but isn't able to make out letters or small UI
25 | controls,
26 | * only a single function of the device (runs in kiosk-like mode, no access to
27 | other applications).
28 |
29 | Status
30 | ------
31 | The main functionality has been implemented and the app is available in the
32 | [Play Store](https://play.google.com/store/apps/details?id=com.studio4plus.homerplayer).
33 |
34 | See the [website](http://msimonides.github.io/homerplayer/features.html) for details
35 | on the main features.
36 |
37 | Contributions
38 | -------------
39 | Translations (incl. Play Store description): Antoine Guillien, Naomi Gumpel,
40 | Vasiliy Petviashvili, Stefan Rotermund, Magdalena Wodyńska.
41 |
42 | Contact
43 | -------
44 | marcin@studio4plus.com
45 |
46 | License
47 | -------
48 | Copyright (c) 2015 Marcin Simonides Licensed under the MIT license.
49 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/preferences_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
13 |
17 |
18 |
22 |
25 |
28 |
32 |
35 |
38 |
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | By using the Homer Player app (the "App") you consent to the collection and
4 | processing of data concerning your use of the App in accordance with this
5 | privacy policy.
6 |
7 | ## Data collection and processing
8 |
9 | The App automatically collects certain usage and device data for the purpose of
10 | improving the App and its user experience.
11 |
12 | The following data is collected when you use the App:
13 |
14 | ### Usage information
15 |
16 | This includes but is not limited to information about how often the App is
17 | being used or which application features are used etc. This data contains
18 | no personally identifiable information and it is processed by a third party
19 | service Sentry.
20 |
21 | ### Stability monitoring
22 |
23 | This is data about application errors that cause the application to stop (so
24 | called "crashes"). Whenever such error occurs data about the application state
25 | at that moment is collected. This data is processed by a third party service
26 | Sentry.
27 |
28 | ### Device data
29 |
30 | Both usage and stability monitoring collects data about the device on which the
31 | application is running. The data includes but is not limited to make and model
32 | of the device, technical specification such as screen resolution, amount of
33 | memory and some state of the device, e.g. the current amount of free memory.
34 |
35 | ## Third party services
36 |
37 | The data described above is collected, stored and processed with the use of the
38 | following third party services:
39 |
40 | - Sentry [privacy policy](https://sentry.io/privacy/)
41 |
42 | ## Changes to this Privacy Policy
43 |
44 | The policy may be modified at any time. Any changes will be posted to this page.
45 |
46 | This policy was last modified on July 23, 2022.
47 |
48 | ## Contact
49 |
50 | You may contact me at marcin@studio4plus.com
51 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/CrashLoopProtection.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer;
2 |
3 | import androidx.annotation.NonNull;
4 |
5 | import javax.inject.Inject;
6 |
7 | import timber.log.Timber;
8 |
9 | // Disable kiosk modes if crash loop is detected.
10 | public class CrashLoopProtection {
11 |
12 | private final static long QUICK_RESTART_THRESHOLD_MS = 5_000;
13 | private final static int QUICK_RESTART_COUNT_DISABLE_KIOSK = 5;
14 |
15 | @NonNull
16 | private final GlobalSettings globalSettings;
17 | @NonNull
18 | private final KioskModeSwitcher kioskModeSwitcher;
19 |
20 | @Inject
21 | public CrashLoopProtection(
22 | @NonNull GlobalSettings globalSettings, @NonNull KioskModeSwitcher kioskModeSwitcher) {
23 | this.globalSettings = globalSettings;
24 | this.kioskModeSwitcher = kioskModeSwitcher;
25 | }
26 |
27 | public void onAppStart() {
28 | long now = System.currentTimeMillis();
29 | long timeSinceLastStart = now - globalSettings.lastStartTimestamp();
30 |
31 | if (timeSinceLastStart < QUICK_RESTART_THRESHOLD_MS) {
32 | int quick_restart_count = globalSettings.quickConsecutiveStartCount();
33 | globalSettings.setStartTimeInfo(now, quick_restart_count + 1);
34 | if (quick_restart_count > QUICK_RESTART_COUNT_DISABLE_KIOSK
35 | && globalSettings.isAnyKioskModeEnabled()) {
36 | Timber.e("Crash loop detected, disabling kiosk mode.");
37 | globalSettings.setFullKioskModeEnabledNow(false);
38 | globalSettings.setSimpleKioskModeEnabledNow(false);
39 | kioskModeSwitcher.onFullKioskModeEnabled(null, false);
40 | kioskModeSwitcher.onSimpleKioskModeEnabled(null, false);
41 | }
42 | } else {
43 | globalSettings.setStartTimeInfo(now, 0);
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/hint_vertical_image.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
12 |
13 |
18 |
19 |
23 |
24 |
34 |
35 |
42 |
43 |
47 |
48 |
49 |
50 |
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/app/crashreporting-sentry/src/main/java/com/studio4plus/homerplayer/crashreporting/CrashReporting.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.crashreporting;
2 |
3 | import android.content.Context;
4 | import android.content.SharedPreferences;
5 | import android.os.Build;
6 |
7 | import androidx.annotation.NonNull;
8 | import androidx.annotation.Nullable;
9 |
10 | import java.util.UUID;
11 |
12 | import io.sentry.Sentry;
13 | import io.sentry.SentryLevel;
14 | import io.sentry.android.core.SentryAndroid;
15 | import io.sentry.android.timber.SentryTimberIntegration;
16 | import io.sentry.protocol.User;
17 |
18 | public class CrashReporting {
19 |
20 | private static final String INSTALLATION_ID_KEY = "installation_id";
21 |
22 | private static String installationId;
23 |
24 | public static void init(@NonNull Context context) {
25 | SharedPreferences prefs =
26 | context.getSharedPreferences("sentry_crashreporting", Context.MODE_PRIVATE);
27 | if (prefs.contains(INSTALLATION_ID_KEY)) {
28 | installationId = prefs.getString(INSTALLATION_ID_KEY, "");
29 | } else {
30 | installationId = UUID.randomUUID().toString();
31 | prefs.edit().putString(INSTALLATION_ID_KEY, installationId).apply();
32 | }
33 | SentryAndroid.init(context, options -> {
34 | options.setDsn(context.getString(R.string.sentry_dsn));
35 | options.addIntegration(
36 | new SentryTimberIntegration(SentryLevel.FATAL, SentryLevel.INFO)
37 | );
38 | });
39 | User user = new User();
40 | user.setId(installationId);
41 | Sentry.setUser(user);
42 | Sentry.setTag("device.brand", Build.BRAND);
43 | }
44 | public static void log(@NonNull String message) {}
45 | public static void log(int priority, @NonNull String tag, @NonNull String msg) {}
46 | public static void logException(@NonNull Throwable e) {
47 | Sentry.captureException(e);
48 | }
49 |
50 | @Nullable
51 | public static String statusForDiagnosticLog() {
52 | return installationId != null ? "Sentry ID: " + installationId : null;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/settings/OpenDocumentTreeUtils.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.settings;
2 |
3 | import android.app.Activity;
4 | import android.content.ActivityNotFoundException;
5 | import android.content.Context;
6 | import android.content.Intent;
7 | import android.net.Uri;
8 | import android.os.storage.StorageManager;
9 | import android.provider.DocumentsContract;
10 |
11 | import androidx.activity.result.ActivityResultLauncher;
12 | import androidx.activity.result.contract.ActivityResultContract;
13 | import androidx.activity.result.contract.ActivityResultContracts;
14 | import androidx.annotation.NonNull;
15 | import androidx.annotation.RequiresApi;
16 | import androidx.appcompat.app.AlertDialog;
17 |
18 | import com.studio4plus.homerplayer.R;
19 |
20 | import org.jetbrains.annotations.Nullable;
21 |
22 | import timber.log.Timber;
23 |
24 | public class OpenDocumentTreeUtils {
25 |
26 | private final static String EXTRA_SHOW_ADVANCED_1 = "android.provider.extra.SHOW_ADVANCED";
27 | private final static String EXTRA_SHOW_ADVANCED_2 = "android.content.extra.SHOW_ADVANCED";
28 |
29 | public static class Contract extends ActivityResultContracts.OpenDocumentTree {
30 | @NonNull
31 | @Override
32 | public Intent createIntent(@NonNull Context context, @Nullable Uri input) {
33 | Intent intent = super.createIntent(context, input);
34 | intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
35 | intent.putExtra(EXTRA_SHOW_ADVANCED_1, true);
36 | intent.putExtra(EXTRA_SHOW_ADVANCED_2, true);
37 | return intent;
38 | }
39 | }
40 |
41 | public static void launchWithErrorHandling(@NonNull Activity activity, @NonNull ActivityResultLauncher launcher) {
42 | try {
43 | launcher.launch(null);
44 | } catch (ActivityNotFoundException e) {
45 | Timber.e(e, "Error launching an activity for ACTION_OPEN_DOCUMENT_TREE");
46 | new AlertDialog.Builder(activity)
47 | .setMessage(R.string.settings_folders_no_opendocumenttree_alert)
48 | .show();
49 | }
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/PermissionUtils.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.app.Activity;
4 | import android.content.Intent;
5 | import android.content.pm.PackageManager;
6 | import android.net.Uri;
7 | import androidx.annotation.NonNull;
8 | import androidx.annotation.StringRes;
9 | import androidx.core.app.ActivityCompat;
10 | import androidx.core.content.ContextCompat;
11 | import androidx.appcompat.app.AlertDialog;
12 |
13 | import com.google.common.base.Predicate;
14 | import com.google.common.collect.Collections2;
15 | import com.studio4plus.homerplayer.R;
16 |
17 | import java.util.Arrays;
18 | import java.util.Collection;
19 |
20 | public class PermissionUtils {
21 |
22 | public static boolean checkAndRequestPermission(
23 | final Activity activity, String[] permissions, int requestCode) {
24 | Collection missingPermissions = Collections2.filter(Arrays.asList(permissions), new Predicate() {
25 | @Override
26 | public boolean apply(@NonNull String permission) {
27 | return ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED;
28 | }
29 | });
30 | if (!missingPermissions.isEmpty()) {
31 | ActivityCompat.requestPermissions(
32 | activity, missingPermissions.toArray(new String[0]), requestCode);
33 | return false;
34 | }
35 | return true;
36 | }
37 |
38 | public static AlertDialog.Builder permissionRationaleDialogBuilder(
39 | Activity activity, @StringRes int rationaleMessage) {
40 | return new AlertDialog.Builder(activity)
41 | .setMessage(rationaleMessage)
42 | .setTitle(R.string.permission_rationale_title)
43 | .setIcon(R.mipmap.ic_launcher);
44 | }
45 |
46 | public static void openAppSettings(Activity activity) {
47 | activity.startActivity(new Intent(
48 | android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
49 | Uri.parse("package:" + activity.getApplication().getPackageName())));
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/model/ColourScheme.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.model;
2 |
3 | import androidx.annotation.AttrRes;
4 |
5 | import com.studio4plus.homerplayer.R;
6 |
7 | import java.util.ArrayList;
8 | import java.util.List;
9 | import java.util.Random;
10 |
11 | // Use the first batch from Kenneth Kelly's max contrast color palette.
12 | // See https://eleanormaclure.files.wordpress.com/2011/03/colour-coding.pdf
13 | // More documents on color summarised here: http://stackoverflow.com/a/4382138/3892517
14 | public enum ColourScheme {
15 | VIVID_YELLOW(R.attr.bookVividYellowBackground, R.attr.bookVividYellowTextColor),
16 | STRONG_PURPLE(R.attr.bookStrongPurpleBackground, R.attr.bookStrongPurpleTextColor),
17 | VIVID_ORANGE(R.attr.bookVividOrangeBackground, R.attr.bookVividOrangeTextColor),
18 | VERY_LIGHT_BLUE(R.attr.bookVeryLightBlueBackground, R.attr.bookVeryLightBlueTextColor),
19 | VIVID_RED(R.attr.bookVividRedBackground, R.attr.bookVividRedTextColor),
20 | GREYISH_YELLOW(R.attr.bookGreyishYellowBackground, R.attr.bookGreyishYellowTextColor),
21 | MEDIUM_GREY(R.attr.bookMediumGreyBackground, R.attr.bookMediumGreyTextColor);
22 |
23 | @AttrRes
24 | public final int backgroundColorAttrId;
25 | @AttrRes
26 | public final int textColourAttrId;
27 |
28 | ColourScheme(@AttrRes int backgroundColourAttrId, @AttrRes int textColourAttrId) {
29 | this.backgroundColorAttrId = backgroundColourAttrId;
30 | this.textColourAttrId = textColourAttrId;
31 | }
32 |
33 | private static Random random;
34 |
35 | public static ColourScheme getRandom(List avoidColours) {
36 | int totalColours = ColourScheme.values().length;
37 | List availableColourSchemes = new ArrayList<>(totalColours);
38 | for (ColourScheme colour : ColourScheme.values()) {
39 | if (!avoidColours.contains(colour))
40 | availableColourSchemes.add(colour);
41 | }
42 |
43 | return availableColourSchemes.get(getRandom().nextInt(availableColourSchemes.size()));
44 | }
45 |
46 | private static Random getRandom() {
47 | if (random == null)
48 | random = new Random();
49 | return random;
50 | }
51 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/demosamples/Unzipper.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.demosamples;
2 |
3 | import androidx.annotation.NonNull;
4 |
5 | import java.io.BufferedOutputStream;
6 | import java.io.File;
7 | import java.io.FileOutputStream;
8 | import java.io.IOException;
9 | import java.io.InputStream;
10 | import java.io.OutputStream;
11 | import java.util.zip.ZipEntry;
12 | import java.util.zip.ZipInputStream;
13 |
14 | import timber.log.Timber;
15 |
16 | public class Unzipper {
17 |
18 | public static boolean unzip(@NonNull InputStream zipStream, @NonNull File destinationFolder) {
19 | try(ZipInputStream zip = new ZipInputStream(zipStream)) {
20 | String canonicalDstPath = destinationFolder.getCanonicalFile().toString();
21 | ZipEntry entry = zip.getNextEntry();
22 | while (entry != null) {
23 | File file = (new File(canonicalDstPath, entry.getName())).getCanonicalFile();
24 | if (!file.toString().startsWith(canonicalDstPath)) {
25 | // This should never happen with the samples ZIP file.
26 | throw new IllegalArgumentException("ZIP entry points outside target directory: " + entry.getName());
27 | }
28 |
29 | if (entry.isDirectory()) {
30 | if (!file.mkdirs())
31 | throw new IOException("Unable to create directory: " + file.getAbsolutePath());
32 | } else {
33 | copyData(zip, file);
34 | }
35 | zip.closeEntry();
36 | entry = zip.getNextEntry();
37 | }
38 | return true;
39 | } catch (IOException e) {
40 | Timber.e(e, "Error unzipping file");
41 | return false;
42 | }
43 | }
44 |
45 | private static void copyData(@NonNull ZipInputStream zip, @NonNull File destinationFile) throws IOException {
46 | byte[] buffer = new byte[16384];
47 | int readCount;
48 | try (OutputStream output = new BufferedOutputStream(new FileOutputStream(destinationFile))) {
49 | while ((readCount = zip.read(buffer, 0, buffer.length)) != -1) {
50 | output.write(buffer, 0, readCount);
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/main/res/animator/bounce.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
15 |
16 |
22 |
28 |
29 |
35 |
41 |
42 |
48 |
54 |
55 |
61 |
67 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/classic/ClassicMainUi.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.classic;
2 |
3 | import androidx.annotation.NonNull;
4 | import androidx.fragment.app.Fragment;
5 | import androidx.fragment.app.FragmentManager;
6 | import androidx.fragment.app.FragmentTransaction;
7 | import androidx.appcompat.app.AppCompatActivity;
8 |
9 | import android.net.Uri;
10 | import android.widget.Toast;
11 |
12 | import com.studio4plus.homerplayer.R;
13 | import com.studio4plus.homerplayer.ui.BookListUi;
14 | import com.studio4plus.homerplayer.ui.MainUi;
15 | import com.studio4plus.homerplayer.ui.NoBooksUi;
16 |
17 | import javax.inject.Inject;
18 |
19 | // TODO: ideally this would be a View.
20 | class ClassicMainUi implements MainUi {
21 |
22 | private final @NonNull AppCompatActivity activity;
23 |
24 | @Inject
25 | ClassicMainUi(@NonNull AppCompatActivity activity) {
26 | this.activity = activity;
27 | showPage(new ClassicInitUi(), false);
28 | }
29 |
30 | @NonNull @Override
31 | public BookListUi switchToBookList(boolean animate) {
32 | ClassicBookList bookList = new ClassicBookList();
33 | showPage(bookList, animate);
34 | return bookList;
35 | }
36 |
37 | @NonNull @Override
38 | public NoBooksUi switchToNoBooks(boolean animate) {
39 | ClassicNoBooksUi noBooks = new ClassicNoBooksUi();
40 | showPage(noBooks, animate);
41 | return noBooks;
42 | }
43 |
44 | @NonNull @Override
45 | public ClassicPlaybackUi switchToPlayback(boolean animate) {
46 | return new ClassicPlaybackUi(activity, this, animate);
47 | }
48 |
49 | @Override
50 | public void onPlaybackError(Uri uri) {
51 | // TODO: The Uri probably isn't meaningful to anyone.
52 | String message = activity.getString(R.string.playbackErrorToast, uri.toString());
53 | Toast.makeText(activity, message, Toast.LENGTH_LONG).show();
54 | }
55 |
56 | void showPlayback(@NonNull FragmentPlayback playbackUi, boolean animate) {
57 | showPage(playbackUi, animate);
58 | }
59 |
60 | private void showPage(@NonNull Fragment pageFragment, boolean animate) {
61 | FragmentManager fragmentManager = activity.getSupportFragmentManager();
62 | FragmentTransaction transaction = fragmentManager.beginTransaction();
63 | transaction.replace(R.id.mainContainer, pageFragment);
64 | transaction.commitNow();
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Regular
5 | High-contrast
6 |
7 |
8 | CLASSIC_REGULAR
9 | CLASSIC_HIGH_CONTRAST
10 |
11 |
12 | @string/pref_jump_back_entry_disabled
13 | 5s
14 | 15s
15 | 30s
16 | 45s
17 |
18 |
19 | 0
20 | 5
21 | 15
22 | 30
23 | 45
24 |
25 |
26 | @string/pref_sleep_timer_entry_disabled
27 | 30s
28 | 5 min
29 | 10 min
30 | 15 min
31 | 20 min
32 |
33 |
34 | 0
35 | 30
36 | 300
37 | 600
38 | 900
39 | 1200
40 |
41 |
42 | Landscape Auto
43 | Locked Landscape
44 | Locked Landscape Reversed
45 |
46 |
47 | LANDSCAPE_AUTO
48 | LANDSCAPE_LOCKED
49 | LANDSCAPE_REVERSE_LOCKED
50 |
51 |
52 | Very fast
53 | Fast
54 | Normal
55 | Moderate
56 | Slow
57 | Very slow
58 |
59 |
60 | 2.0
61 | 1.5
62 | 1.0
63 | 0.8
64 | 0.65
65 | 0.5
66 |
67 |
--------------------------------------------------------------------------------
/app/src/main/res/values/color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #00000000
4 |
5 | #ffffff
6 | #008856
7 | #ffffffff
8 | #ff008800
9 | #ff33aa33
10 | #ffffffff
11 | #ffbb2200
12 | #ffdd5544
13 | #ffffffff
14 | #2f459c
15 | #ff000000
16 | #ebeb0c
17 | @color/classicButtonFFRewindBackground
18 | #e0303030
19 |
20 | #ffb300
21 | #803e75
22 | #ff6800
23 | #a6bdd7
24 | #c10020
25 | #cea262
26 | #817066
27 |
28 | #ffffff
29 | #000000
30 | #000000
31 | #56FB1F
32 | #4BE418
33 | #ffffff
34 | #FF6740
35 | #E05835
36 | #ffffff
37 | #0C7BDC
38 | #ff000000
39 | #ebeb0c
40 | @color/highContrastButtonFFRewindBackground
41 | #f0707070
42 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # By default, the flags in this file are appended to flags specified
3 | # in /home/marcin/Android/Sdk/tools/proguard/proguard-android.txt
4 | # You can edit the include path and order by changing the proguardFiles
5 | # directive in build.gradle.
6 | #
7 | # For more details, see
8 | # http://developer.android.com/guide/developing/tools/proguard.html
9 |
10 | # Add any project specific keep options here:
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
19 | # EventBus
20 | -keepclassmembers class ** {
21 | public void onEvent*(**);
22 | }
23 |
24 | # Settings fragments that are referenced with the app:fragment property on preferences
25 | # are not recognized by ProGuard as being used and are removed. Keep them.
26 | -keep class com.studio4plus.homerplayer.ui.settings.**
27 |
28 | # Guava
29 | -dontwarn sun.misc.Unsafe
30 | ## https://github.com/google/guava/issues/2926#issuecomment-325455128
31 | ## https://stackoverflow.com/questions/9120338/proguard-configuration-for-guava-with-obfuscation-and-optimization
32 | -dontwarn com.google.common.base.**
33 | -dontwarn com.google.errorprone.annotations.**
34 | -dontwarn com.google.j2objc.annotations.**
35 | -dontwarn java.lang.ClassValue
36 | -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
37 | # Added for guava 23.5-android
38 | -dontwarn afu.org.checkerframework.**
39 | -dontwarn org.checkerframework.**
40 |
41 | # Required to preserve the Flurry SDK
42 | -keep class com.flurry.** { *; }
43 | -dontwarn com.flurry.**
44 | -keepattributes *Annotation*,EnclosingMethod,Signature
45 | -keepclasseswithmembers class * {
46 | public (android.content.Context, android.util.AttributeSet, int);
47 | }
48 |
49 | # Google Play Services library
50 | -keep class * extends java.util.ListResourceBundle {
51 | protected Object[][] getContents();
52 | }
53 |
54 | -keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable {
55 | public static final *** NULL;
56 | }
57 |
58 | -keepnames @com.google.android.gms.common.annotation.KeepName class *
59 | -keepclassmembernames class * {
60 | @com.google.android.gms.common.annotation.KeepName *;
61 | }
62 |
63 | -keepnames class * implements android.os.Parcelable {
64 | public static final ** CREATOR;
65 | }
66 | # -- end Flurry config --
67 |
68 | -keepattributes SourceFile, LineNumberTable
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/service/NotificationUtil.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.service;
2 |
3 | import android.annotation.TargetApi;
4 | import android.app.NotificationChannel;
5 | import android.app.NotificationManager;
6 | import android.app.PendingIntent;
7 | import android.content.Context;
8 | import android.content.Intent;
9 | import android.os.Build;
10 |
11 | import androidx.core.app.NotificationCompat;
12 |
13 | import com.google.common.base.Preconditions;
14 | import com.studio4plus.homerplayer.R;
15 | import com.studio4plus.homerplayer.ui.MainActivity;
16 |
17 | public class NotificationUtil {
18 |
19 | // This channel is also used for samples download notifications but I guess
20 | // it's better to reuse the channel instead of providing another one just for
21 | // one-time action.
22 | private static final String PLAYBACK_SERVICE_CHANNEL_ID = "playback";
23 | private static final int FLAG_IMMUTABLE =
24 | Build.VERSION.SDK_INT < 23 ? 0 : PendingIntent.FLAG_IMMUTABLE;
25 |
26 | static NotificationCompat.Builder createForegroundServiceNotification(
27 | Context context, int stringId, int drawableId) {
28 | Intent activityIntent = new Intent(context, MainActivity.class);
29 | activityIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
30 | PendingIntent intent = PendingIntent.getActivity(
31 | context, 0, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE);
32 |
33 | return new NotificationCompat.Builder(context, PLAYBACK_SERVICE_CHANNEL_ID)
34 | .setContentTitle(context.getResources().getString(stringId))
35 | .setContentIntent(intent)
36 | .setSmallIcon(drawableId)
37 | .setOngoing(true);
38 | }
39 |
40 | @TargetApi(26)
41 | public static class API26 {
42 | public static void registerPlaybackServiceChannel(Context context) {
43 | NotificationManager manager =
44 | (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
45 | Preconditions.checkNotNull(manager);
46 |
47 | NotificationChannel channel = new NotificationChannel(
48 | PLAYBACK_SERVICE_CHANNEL_ID,
49 | context.getString(R.string.notificationChannelPlayback),
50 | NotificationManager.IMPORTANCE_LOW);
51 | manager.createNotificationChannel(channel);
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/SnippetPlayer.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.content.Context;
4 | import android.net.Uri;
5 | import android.util.Log;
6 |
7 | import com.studio4plus.homerplayer.crashreporting.CrashReporting;
8 | import com.studio4plus.homerplayer.model.AudioBook;
9 | import com.studio4plus.homerplayer.player.PlaybackController;
10 | import com.studio4plus.homerplayer.player.Player;
11 |
12 | import java.io.File;
13 |
14 | import de.greenrobot.event.EventBus;
15 | import timber.log.Timber;
16 |
17 | /**
18 | * Plays the current audiobook for a short amount of time. Just to demonstrate.
19 | */
20 | public class SnippetPlayer implements PlaybackController.Observer {
21 |
22 | private static final long PLAYBACK_TIME_MS = 5000;
23 |
24 | final private PlaybackController playbackController;
25 | private long startPositionMs = -1;
26 | private boolean isPlaying = false;
27 |
28 | public SnippetPlayer(Context context, EventBus eventBus, float playbackSpeed) {
29 | Player player = new Player(context, eventBus);
30 | player.setPlaybackSpeed(playbackSpeed);
31 | playbackController = player.createPlayback();
32 | playbackController.setObserver(this);
33 | }
34 |
35 | public void play(AudioBook audioBook) {
36 | AudioBook.Position position = audioBook.getLastPosition();
37 |
38 | isPlaying = true;
39 | playbackController.start(position.uri, position.seekPosition);
40 | }
41 |
42 | public void stop() {
43 | playbackController.stop();
44 | }
45 |
46 | public boolean isPlaying() {
47 | return isPlaying;
48 | }
49 |
50 | @Override
51 | public void onDuration(Uri uri, long durationMs) {}
52 |
53 | @Override
54 | public void onPlaybackProgressed(long currentPositionMs) {
55 | if (startPositionMs < 0) {
56 | startPositionMs = currentPositionMs;
57 | } else {
58 | if (currentPositionMs - startPositionMs > PLAYBACK_TIME_MS) {
59 | playbackController.stop();
60 | }
61 | }
62 | }
63 |
64 | @Override
65 | public void onPlaybackEnded() {}
66 |
67 | @Override
68 | public void onPlaybackStopped(long currentPositionMs) {}
69 |
70 | @Override
71 | public void onPlaybackError(Uri uri) {
72 | Timber.i("Unable to play snippet: %s", uri.toString());
73 | }
74 |
75 | @Override
76 | public void onPlayerReleased() {
77 | isPlaying = false;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/content/ConfigurationContentProvider.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.content;
2 |
3 | import android.content.ContentProvider;
4 | import android.content.ContentValues;
5 | import android.database.Cursor;
6 | import android.net.Uri;
7 | import androidx.annotation.NonNull;
8 | import androidx.annotation.Nullable;
9 |
10 | import com.studio4plus.homerplayer.GlobalSettings;
11 | import com.studio4plus.homerplayer.HomerPlayerApplication;
12 | import com.studio4plus.homerplayer.KioskModeSwitcher;
13 |
14 | import javax.inject.Inject;
15 |
16 | /**
17 | * Makes certain configuration settings available to other apps (most notably: the adb shell).
18 | * Don't expose any sensitive information through this class.
19 | */
20 | public class ConfigurationContentProvider extends ContentProvider {
21 |
22 | @Inject public KioskModeSwitcher kioskModeSwitcher;
23 | @Inject public GlobalSettings globalSettings;
24 |
25 | @Override
26 | public boolean onCreate() {
27 | return true;
28 | }
29 |
30 | private void injectDependenciesIfNecessary(){
31 | // onCreate is called before the application object is initialized therefore
32 | // Dagger injection is run by the first operation on the content provider.
33 | if (globalSettings == null)
34 | HomerPlayerApplication.getComponent(getContext()).inject(this);
35 | }
36 |
37 | @Nullable
38 | @Override
39 | public Cursor query(
40 | @NonNull Uri uri,
41 | @Nullable String[] projection,
42 | @Nullable String selection,
43 | @Nullable String[] selectionArgs,
44 | @Nullable String sortOrder) {
45 | injectDependenciesIfNecessary();
46 | return new ConfigurationCursor(
47 | kioskModeSwitcher.isLockTaskPermitted(),
48 | globalSettings.isFullKioskModeEnabled());
49 | }
50 |
51 | @Nullable
52 | @Override
53 | public String getType(@NonNull Uri uri) {
54 | return null;
55 | }
56 |
57 | @Nullable
58 | @Override
59 | public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
60 | return null;
61 | }
62 |
63 | @Override
64 | public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
65 | return 0;
66 | }
67 |
68 | @Override
69 | public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
70 | return 0;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
12 | set DEFAULT_JVM_OPTS=
13 |
14 | set DIRNAME=%~dp0
15 | if "%DIRNAME%" == "" set DIRNAME=.
16 | set APP_BASE_NAME=%~n0
17 | set APP_HOME=%DIRNAME%
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windowz variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 | if "%@eval[2+2]" == "4" goto 4NT_args
53 |
54 | :win9xME_args
55 | @rem Slurp the command line arguments.
56 | set CMD_LINE_ARGS=
57 | set _SKIP=2
58 |
59 | :win9xME_args_slurp
60 | if "x%~1" == "x" goto execute
61 |
62 | set CMD_LINE_ARGS=%*
63 | goto execute
64 |
65 | :4NT_args
66 | @rem Get arguments from the 4NT Shell from JP Software
67 | set CMD_LINE_ARGS=%$
68 |
69 | :execute
70 | @rem Setup the command line
71 |
72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if "%ERRORLEVEL%"=="0" goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
85 | exit /b 1
86 |
87 | :mainEnd
88 | if "%OS%"=="Windows_NT" endlocal
89 |
90 | :omega
91 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/util/TlsSSLSocketFactory.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.util;
2 |
3 | import androidx.annotation.NonNull;
4 |
5 | import java.io.IOException;
6 | import java.net.InetAddress;
7 | import java.net.Socket;
8 | import java.net.UnknownHostException;
9 | import java.security.KeyManagementException;
10 | import java.security.NoSuchAlgorithmException;
11 |
12 | import javax.net.ssl.SSLContext;
13 | import javax.net.ssl.SSLSocket;
14 | import javax.net.ssl.SSLSocketFactory;
15 |
16 |
17 | // From: https://blog.dev-area.net/2015/08/13/android-4-1-enable-tls-1-1-and-tls-1-2/
18 | public class TlsSSLSocketFactory extends SSLSocketFactory {
19 |
20 | @NonNull
21 | private final SSLSocketFactory factory;
22 |
23 | public TlsSSLSocketFactory() throws KeyManagementException, NoSuchAlgorithmException {
24 | SSLContext context = SSLContext.getInstance("TLS");
25 | context.init(null, null, null);
26 | factory = context.getSocketFactory();
27 | }
28 |
29 | @Override
30 | public String[] getDefaultCipherSuites() {
31 | return factory.getDefaultCipherSuites();
32 | }
33 |
34 | @Override
35 | public String[] getSupportedCipherSuites() {
36 | return factory.getSupportedCipherSuites();
37 | }
38 |
39 | @Override
40 | public Socket createSocket(Socket socket, String host, int port, boolean autoClose)
41 | throws IOException {
42 | return enableTLSOnSocket(factory.createSocket(socket, host, port, autoClose));
43 | }
44 |
45 | @Override
46 | public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
47 | return enableTLSOnSocket(factory.createSocket(host, port));
48 | }
49 |
50 | @Override
51 | public Socket createSocket(String host, int port, InetAddress inetAddress, int localPort)
52 | throws IOException, UnknownHostException {
53 | return enableTLSOnSocket(factory.createSocket(host, port, inetAddress, localPort));
54 | }
55 |
56 | @Override
57 | public Socket createSocket(InetAddress inetAddress, int port) throws IOException {
58 | return enableTLSOnSocket(factory.createSocket(inetAddress, port));
59 | }
60 |
61 | @Override
62 | public Socket createSocket(
63 | InetAddress inetAddress, int port, InetAddress localInetAddress, int localPort)
64 | throws IOException {
65 | return enableTLSOnSocket(
66 | factory.createSocket(inetAddress, port, localInetAddress, localPort));
67 | }
68 |
69 | private Socket enableTLSOnSocket(Socket socket) {
70 | if(socket instanceof SSLSocket) {
71 | ((SSLSocket)socket).setEnabledProtocols(new String[] {"TLSv1.1", "TLSv1.2"});
72 | }
73 | return socket;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/FFRewindTimer.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.os.Handler;
4 | import android.os.SystemClock;
5 |
6 | import java.util.ArrayList;
7 | import java.util.List;
8 |
9 | public class FFRewindTimer implements Runnable {
10 |
11 | public interface Observer {
12 | void onTimerUpdated(long displayTimeMs);
13 | void onTimerLimitReached();
14 | }
15 |
16 | private static final int MIN_TICK_INTERVAL_MS = 50;
17 |
18 | private final Handler handler;
19 | private final List observers = new ArrayList<>();
20 | private final long maxTimeMs;
21 | private long lastTickAt;
22 | private long displayTimeMs;
23 | private int speedMsPerS = 1000;
24 |
25 | public FFRewindTimer(Handler handler, long baseDisplayTimeMs, long maxTimeMs) {
26 | this.handler = handler;
27 | this.maxTimeMs = maxTimeMs;
28 | this.displayTimeMs = baseDisplayTimeMs;
29 | this.lastTickAt = SystemClock.uptimeMillis();
30 | }
31 |
32 | public void addObserver(Observer observer) {
33 | this.observers.add(observer);
34 | }
35 |
36 | public void removeObserver(Observer observer) {
37 | this.observers.remove(observer);
38 | }
39 |
40 | public long getDisplayTimeMs() {
41 | return displayTimeMs;
42 | }
43 |
44 | public void changeSpeed(int speedMsPerS) {
45 | stop();
46 | this.lastTickAt = SystemClock.uptimeMillis();
47 | this.speedMsPerS = speedMsPerS;
48 | run();
49 | }
50 |
51 | @Override
52 | public void run() {
53 | long now = SystemClock.uptimeMillis();
54 | boolean keepRunning = update(now);
55 |
56 | if (keepRunning) {
57 | long nextTickAt = lastTickAt + Math.max(Math.abs(speedMsPerS), MIN_TICK_INTERVAL_MS);
58 | handler.postAtTime(this, nextTickAt);
59 | lastTickAt = now;
60 | }
61 | }
62 |
63 | public void stop() {
64 | handler.removeCallbacks(this);
65 | }
66 |
67 | private boolean update(long now) {
68 | long elapsedMs = now - lastTickAt;
69 | displayTimeMs += (1000 * elapsedMs) / speedMsPerS;
70 |
71 | boolean limitReached = false;
72 | if (displayTimeMs < 0) {
73 | displayTimeMs = 0;
74 | limitReached = true;
75 | } else if (displayTimeMs > maxTimeMs) {
76 | displayTimeMs = maxTimeMs;
77 | limitReached = true;
78 | }
79 |
80 | int count = observers.size();
81 | for (int i = 0; i < count; ++i) {
82 | Observer observer = observers.get(i);
83 | observer.onTimerUpdated(displayTimeMs);
84 | if (limitReached)
85 | observer.onTimerLimitReached();
86 | }
87 |
88 | return !limitReached;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/deviceadmin/HomerPlayerDeviceAdmin.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.deviceadmin;
2 |
3 | import android.annotation.TargetApi;
4 | import android.app.admin.DeviceAdminReceiver;
5 | import android.app.admin.DevicePolicyManager;
6 | import android.content.ComponentName;
7 | import android.content.Context;
8 | import android.content.Intent;
9 | import android.os.Build;
10 |
11 | import androidx.annotation.NonNull;
12 |
13 | import com.studio4plus.homerplayer.HomerPlayerApplication;
14 | import com.studio4plus.homerplayer.events.DeviceAdminChangeEvent;
15 |
16 | import de.greenrobot.event.EventBus;
17 |
18 | public class HomerPlayerDeviceAdmin extends DeviceAdminReceiver {
19 |
20 | @Override
21 | public void onEnabled(@NonNull Context context, @NonNull Intent intent) {
22 | if (Build.VERSION.SDK_INT >= 21)
23 | API21.enableLockTask(context);
24 | EventBus eventBus = HomerPlayerApplication.getComponent(context).getEventBus();
25 | eventBus.post(new DeviceAdminChangeEvent(true));
26 | }
27 |
28 | @Override
29 | public void onDisabled(@NonNull Context context, @NonNull Intent intent) {
30 | EventBus eventBus = HomerPlayerApplication.getComponent(context).getEventBus();
31 | eventBus.post(new DeviceAdminChangeEvent(false));
32 | }
33 |
34 | public static boolean isDeviceOwner(@NonNull Context context) {
35 | return Build.VERSION.SDK_INT >= 21 && API21.isDeviceOwner(context);
36 | }
37 |
38 | public static void clearDeviceOwner(@NonNull Context context) {
39 | if (Build.VERSION.SDK_INT >= 21)
40 | API21.clearDeviceOwnerAndAdmin(context);
41 | }
42 |
43 | @TargetApi(21)
44 | private static class API21 {
45 |
46 | public static boolean isDeviceOwner(@NonNull Context context) {
47 | DevicePolicyManager dpm =
48 | (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
49 | return dpm.isDeviceOwnerApp(context.getPackageName());
50 | }
51 |
52 | public static void clearDeviceOwnerAndAdmin(@NonNull Context context) {
53 | DevicePolicyManager dpm =
54 | (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
55 | dpm.clearDeviceOwnerApp(context.getPackageName());
56 | ComponentName adminComponentName = new ComponentName(context, HomerPlayerDeviceAdmin.class);
57 | dpm.removeActiveAdmin(adminComponentName);
58 | }
59 |
60 | public static void enableLockTask(@NonNull Context context) {
61 | DevicePolicyManager dpm =
62 | (DevicePolicyManager) context.getSystemService(Context.DEVICE_POLICY_SERVICE);
63 | ComponentName adminComponentName = new ComponentName(context, HomerPlayerDeviceAdmin.class);
64 | if (dpm.isAdminActive(adminComponentName) &&
65 | dpm.isDeviceOwnerApp(context.getPackageName()))
66 | dpm.setLockTaskPackages(adminComponentName, new String[]{context.getPackageName()});
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/classic/ClassicPlaybackUi.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.classic;
2 |
3 | import android.app.Activity;
4 | import androidx.annotation.NonNull;
5 | import androidx.annotation.Nullable;
6 |
7 | import com.studio4plus.homerplayer.GlobalSettings;
8 | import com.studio4plus.homerplayer.HomerPlayerApplication;
9 | import com.studio4plus.homerplayer.ui.PlaybackUi;
10 | import com.studio4plus.homerplayer.ui.SoundBank;
11 | import com.studio4plus.homerplayer.ui.UiControllerPlayback;
12 |
13 | import java.util.EnumMap;
14 |
15 | import javax.inject.Inject;
16 |
17 | public class ClassicPlaybackUi implements PlaybackUi {
18 |
19 | @Inject public SoundBank soundBank;
20 | @Inject public GlobalSettings globalSettings;
21 |
22 | private final @NonNull FragmentPlayback fragment;
23 | private final @NonNull ClassicMainUi mainUi;
24 | private final boolean animateOnInit;
25 |
26 | private @Nullable SoundBank.Sound ffRewindSound;
27 |
28 | ClassicPlaybackUi(
29 | @NonNull Activity activity, @NonNull ClassicMainUi mainUi, boolean animateOnInit) {
30 | this.fragment = new FragmentPlayback();
31 | this.mainUi = mainUi;
32 | this.animateOnInit = animateOnInit;
33 | HomerPlayerApplication.getComponent(activity).inject(this);
34 |
35 | if (globalSettings.isFFRewindSoundEnabled())
36 | ffRewindSound = soundBank.getSound(SoundBank.SoundId.FF_REWIND);
37 | }
38 |
39 | @Override
40 | public void initWithController(@NonNull UiControllerPlayback controller) {
41 | fragment.setController(controller);
42 | mainUi.showPlayback(fragment, animateOnInit);
43 | }
44 |
45 | @Override
46 | public void onPlaybackProgressed(long playbackPositionMs) {
47 | fragment.onPlaybackProgressed(playbackPositionMs);
48 | }
49 |
50 | @Override
51 | public void onPlaybackStopping() {
52 | fragment.onPlaybackStopping();
53 | }
54 |
55 | @Override
56 | public void onFFRewindSpeed(SpeedLevel speedLevel) {
57 | if (ffRewindSound != null) {
58 | if (speedLevel == SpeedLevel.STOP) {
59 | SoundBank.stopTrack(ffRewindSound.track);
60 | } else {
61 | int soundPlaybackFactor = SPEED_LEVEL_SOUND_RATE.get(speedLevel);
62 | ffRewindSound.track.setPlaybackRate(ffRewindSound.sampleRate * soundPlaybackFactor);
63 | ffRewindSound.track.play();
64 | }
65 |
66 | }
67 | }
68 |
69 | @Override
70 | public void onVolumeChanged(int min, int max, int current) {
71 | fragment.onVolumeChanged(min, max, current);
72 | }
73 |
74 | private static final EnumMap SPEED_LEVEL_SOUND_RATE =
75 | new EnumMap<>(SpeedLevel.class);
76 |
77 | static {
78 | // No value for STOP.
79 | SPEED_LEVEL_SOUND_RATE.put(SpeedLevel.REGULAR, 1);
80 | SPEED_LEVEL_SOUND_RATE.put(SpeedLevel.FAST, 2);
81 | SPEED_LEVEL_SOUND_RATE.put(SpeedLevel.FASTEST, 4);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/settings/BaseSettingsFragment.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.settings;
2 |
3 | import android.content.ActivityNotFoundException;
4 | import android.content.Intent;
5 | import android.content.SharedPreferences;
6 | import android.net.Uri;
7 | import androidx.annotation.NonNull;
8 | import androidx.annotation.StringRes;
9 | import androidx.appcompat.app.ActionBar;
10 | import androidx.appcompat.app.AppCompatActivity;
11 | import androidx.preference.ListPreference;
12 | import androidx.preference.Preference;
13 | import androidx.preference.PreferenceFragmentCompat;
14 | import androidx.preference.PreferenceManager;
15 | import android.widget.Toast;
16 |
17 | import com.google.common.base.Preconditions;
18 | import com.studio4plus.homerplayer.R;
19 |
20 | import java.util.Objects;
21 |
22 | abstract class BaseSettingsFragment
23 | extends PreferenceFragmentCompat
24 | implements SharedPreferences.OnSharedPreferenceChangeListener {
25 |
26 | @Override
27 | public void onStart() {
28 | super.onStart();
29 | getSharedPreferences().registerOnSharedPreferenceChangeListener(this);
30 |
31 | final ActionBar actionBar = ((AppCompatActivity) requireActivity()).getSupportActionBar();
32 | Preconditions.checkNotNull(actionBar);
33 | actionBar.setTitle(getTitle());
34 |
35 | }
36 |
37 | @Override
38 | public void onStop() {
39 | getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
40 | super.onStop();
41 | }
42 |
43 |
44 | @Override
45 | public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
46 | }
47 |
48 | @NonNull
49 | protected SharedPreferences getSharedPreferences() {
50 | return PreferenceManager.getDefaultSharedPreferences(Objects.requireNonNull(getActivity()));
51 | }
52 |
53 | @StringRes
54 | protected abstract int getTitle();
55 |
56 | protected void updateListPreferenceSummary(@NonNull SharedPreferences sharedPreferences,
57 | @NonNull String key,
58 | int default_value_res_id) {
59 | String stringValue = sharedPreferences.getString(key, getString(default_value_res_id));
60 | ListPreference preference = getPreference(key);
61 | int index = preference.findIndexOfValue(stringValue);
62 | if (index < 0)
63 | index = 0;
64 | preference.setSummary(preference.getEntries()[index]);
65 | }
66 |
67 | protected void openUrl(@NonNull String url) {
68 | Intent i = new Intent(Intent.ACTION_VIEW);
69 | i.setData(Uri.parse(url));
70 | try {
71 | startActivity(i);
72 | }
73 | catch(ActivityNotFoundException noActivity) {
74 | Preconditions.checkNotNull(getView());
75 | Toast.makeText(getView().getContext(),
76 | R.string.pref_no_browser_toast, Toast.LENGTH_LONG).show();
77 | }
78 | }
79 |
80 | @NonNull
81 | protected T getPreference(@NonNull CharSequence key) {
82 | T preference = findPreference(key);
83 | Preconditions.checkNotNull(preference);
84 | return preference;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ApplicationComponent.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer;
2 |
3 | import android.content.Context;
4 | import android.content.res.Resources;
5 | import android.media.AudioManager;
6 |
7 | import com.studio4plus.homerplayer.analytics.AnalyticsTracker;
8 | import com.studio4plus.homerplayer.battery.BatteryStatusProvider;
9 | import com.studio4plus.homerplayer.content.ConfigurationContentProvider;
10 | import com.studio4plus.homerplayer.model.AudioBookManager;
11 | import com.studio4plus.homerplayer.model.DemoSamplesInstaller;
12 | import com.studio4plus.homerplayer.player.Player;
13 | import com.studio4plus.homerplayer.service.AudioBookPlayerModule;
14 | import com.studio4plus.homerplayer.service.DemoSamplesInstallerService;
15 | import com.studio4plus.homerplayer.service.PlaybackService;
16 | import com.studio4plus.homerplayer.ui.BatteryStatusIndicator;
17 | import com.studio4plus.homerplayer.ui.KioskModeHandler;
18 | import com.studio4plus.homerplayer.ui.classic.ClassicPlaybackUi;
19 | import com.studio4plus.homerplayer.ui.classic.FragmentBookItem;
20 | import com.studio4plus.homerplayer.ui.classic.ClassicBookList;
21 | import com.studio4plus.homerplayer.ui.classic.ClassicNoBooksUi;
22 | import com.studio4plus.homerplayer.ui.settings.AudiobooksFolderManager;
23 | import com.studio4plus.homerplayer.ui.settings.KioskSettingsFragment;
24 | import com.studio4plus.homerplayer.ui.settings.MainSettingsFragment;
25 | import com.studio4plus.homerplayer.ui.settings.PlaybackSettingsFragment;
26 | import com.studio4plus.homerplayer.ui.classic.FragmentPlayback;
27 | import com.studio4plus.homerplayer.ui.settings.SettingsFoldersActivity;
28 |
29 | import javax.inject.Singleton;
30 |
31 | import dagger.Component;
32 | import de.greenrobot.event.EventBus;
33 |
34 | @Singleton
35 | @ApplicationScope
36 | @Component(modules = { ApplicationModule.class, AudioBookManagerModule.class, AudioBookPlayerModule.class })
37 | public interface ApplicationComponent {
38 | void inject(BatteryStatusProvider batteryStatusProvider);
39 | void inject(BatteryStatusIndicator batteryStatusIndicator);
40 | void inject(DemoSamplesInstallerService demoSamplesInstallerService);
41 | void inject(ClassicBookList fragment);
42 | void inject(ClassicNoBooksUi fragment);
43 | void inject(ClassicPlaybackUi playbackUi);
44 | void inject(ConfigurationContentProvider provider);
45 | void inject(FragmentBookItem fragment);
46 | void inject(FragmentPlayback fragment);
47 | void inject(HomerPlayerApplication application);
48 | void inject(KioskSettingsFragment fragment);
49 | void inject(MainSettingsFragment fragment);
50 | void inject(PlaybackService playbackService);
51 | void inject(PlaybackSettingsFragment fragment);
52 | void inject(SettingsFoldersActivity activity);
53 |
54 | Player createAudioBookPlayer();
55 | DemoSamplesInstaller createDemoSamplesInstaller();
56 |
57 | AnalyticsTracker getAnalyticsTracker();
58 | AudioBookManager getAudioBookManager();
59 | AudiobooksFolderManager getAudiobooksFolderManager();
60 | AudioManager getAudioManager();
61 | Context getContext();
62 | EventBus getEventBus();
63 | GlobalSettings getGlobalSettings();
64 | KioskModeHandler getKioskModeHandler();
65 | KioskModeSwitcher getKioskModeSwitcher();
66 | MediaStoreUpdateObserver getMediaStoreUpdateObserver();
67 | Resources getResources();
68 | }
69 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/battery/BatteryStatusProvider.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.battery;
2 |
3 | import android.content.BroadcastReceiver;
4 | import android.content.Context;
5 | import android.content.Intent;
6 | import android.content.IntentFilter;
7 | import android.os.BatteryManager;
8 | import androidx.annotation.NonNull;
9 | import androidx.annotation.Nullable;
10 |
11 | import com.studio4plus.homerplayer.ApplicationScope;
12 | import com.studio4plus.homerplayer.events.BatteryStatusChangeEvent;
13 |
14 | import javax.inject.Inject;
15 |
16 | import de.greenrobot.event.EventBus;
17 |
18 | public class BatteryStatusProvider extends BroadcastReceiver {
19 |
20 | private final IntentFilter batteryStatusIntentFilter;
21 | private final Context applicationContext;
22 | private final EventBus eventBus;
23 |
24 | @Nullable
25 | private BatteryStatus lastStatus;
26 |
27 | @Inject
28 | public BatteryStatusProvider(@ApplicationScope Context applicationContext, EventBus eventBus) {
29 | this.applicationContext = applicationContext;
30 | this.eventBus = eventBus;
31 | batteryStatusIntentFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
32 | }
33 |
34 | public void start() {
35 | Intent batteryStatusIntent = applicationContext.registerReceiver(this, batteryStatusIntentFilter);
36 | if (batteryStatusIntent != null)
37 | updateBatteryStatus(getBatteryStatus(batteryStatusIntent));
38 | }
39 |
40 | public void stop() {
41 | applicationContext.unregisterReceiver(this);
42 | }
43 |
44 | @Override
45 | public void onReceive(Context context, Intent intent) {
46 | if (batteryStatusIntentFilter.matchAction(intent.getAction())) {
47 | updateBatteryStatus(getBatteryStatus(intent));
48 | }
49 | }
50 |
51 | @Nullable
52 | public BatteryStatus getLastStatus() {
53 | return lastStatus;
54 | }
55 |
56 | private void updateBatteryStatus(BatteryStatus batteryStatus) {
57 | lastStatus = batteryStatus;
58 | eventBus.postSticky(new BatteryStatusChangeEvent(batteryStatus));
59 | }
60 |
61 | private BatteryStatus getBatteryStatus(@NonNull Intent batteryStatusIntent) {
62 | int status = batteryStatusIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
63 | boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
64 | status == BatteryManager.BATTERY_STATUS_FULL;
65 |
66 | ChargeLevel chargeLevel;
67 | if (status == BatteryManager.BATTERY_STATUS_FULL) {
68 | chargeLevel = ChargeLevel.FULL;
69 | } else {
70 | int level = batteryStatusIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
71 | int scale = batteryStatusIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
72 |
73 | float chargePercent = level / (float) scale;
74 | chargeLevel = fromPercentage(chargePercent);
75 | }
76 |
77 | return new BatteryStatus(chargeLevel, isCharging);
78 | }
79 |
80 | private static ChargeLevel fromPercentage(float percentage) {
81 | if (percentage < 0.2f)
82 | return ChargeLevel.CRITICAL;
83 | else if (percentage < 0.333f)
84 | return ChargeLevel.LEVEL_1;
85 | else if (percentage < 0.666f)
86 | return ChargeLevel.LEVEL_2;
87 | else if (percentage < 1.0f)
88 | return ChargeLevel.LEVEL_3;
89 | else
90 | return ChargeLevel.FULL;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/util/FilesystemUtil.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.util;
2 |
3 | import android.annotation.TargetApi;
4 | import android.content.Context;
5 | import android.os.Build;
6 |
7 | import java.io.BufferedReader;
8 | import java.io.File;
9 | import java.io.FileReader;
10 | import java.io.IOException;
11 | import java.util.ArrayList;
12 | import java.util.List;
13 |
14 | public class FilesystemUtil {
15 |
16 | public static List listRootDirs(Context context) {
17 | List rootDirs = listStorageMounts();
18 | for (File rootDir : listSemiPermanentRootDirs(context)) {
19 | if (!rootDirs.contains(rootDir))
20 | rootDirs.add(rootDir);
21 | }
22 |
23 | return rootDirs;
24 | }
25 |
26 | private static File getFSRootForPath(File path) {
27 | while (path != null && path.isDirectory()) {
28 | long fsSize = path.getTotalSpace();
29 | File parent = path.getParentFile();
30 | if (parent == null || parent.getTotalSpace() != fsSize)
31 | return path;
32 | path = parent;
33 | }
34 | return path;
35 | }
36 |
37 | // Returns all system mount points that start with /storage.
38 | // This is likely to list attached SD cards, including those that are hidden from
39 | // Context.getExternalFilesDir()..
40 | // Some of the returned files may not be accessible due to permissions.
41 | private static List listStorageMounts() {
42 | List mounts = new ArrayList<>();
43 | File mountsFile = new File("/proc/mounts");
44 | try {
45 | BufferedReader reader = new BufferedReader(new FileReader(mountsFile));
46 | String line;
47 | while ((line = reader.readLine()) != null) {
48 | String[] fields = line.split(" +");
49 | if (fields.length >= 2 && fields[1].startsWith("/storage")) {
50 | mounts.add(new File(fields[1]));
51 | }
52 | }
53 | } catch (IOException e) {
54 | // Ignore, just return as much as is accumulated in mounts.
55 | }
56 | return mounts;
57 | }
58 |
59 | // Returns a list of file system roots on all semi-permanent storage mounts.
60 | // Semi-permanent storage is removable medium that is part of the device (e.g. an SD slot
61 | // inside the battery compartment) and therefore unlikely to be removed often.
62 | // Storage medium that is easily accessible by the user (e.g. an external SD card slot) is
63 | // treated as portable storage.
64 | // The Context.getExternalFilesDir() method only lists semi-permanent storage devices.
65 | //
66 | // See http://source.android.com/devices/storage/traditional.html#multiple-external-storage-devices
67 | private static List listSemiPermanentRootDirs(Context context) {
68 | File[] filesDirs;
69 | if (Build.VERSION.SDK_INT < 19)
70 | filesDirs = new File[]{ context.getExternalFilesDir(null) };
71 | else
72 | filesDirs = API19.getExternalFilesDirs(context);
73 |
74 | List rootDirs = new ArrayList<>(filesDirs.length);
75 | for (File path : filesDirs) {
76 | File root = getFSRootForPath(path);
77 | if (root != null)
78 | rootDirs.add(root);
79 | }
80 | return rootDirs;
81 | }
82 |
83 | @TargetApi(19)
84 | private static class API19 {
85 | public static File[] getExternalFilesDirs(Context context) {
86 | return context.getExternalFilesDirs(null);
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/HintOverlay.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.view.View;
4 | import android.view.ViewStub;
5 | import android.view.animation.AlphaAnimation;
6 | import android.view.animation.Animation;
7 | import android.widget.ImageView;
8 | import android.widget.TextView;
9 |
10 | import com.studio4plus.homerplayer.R;
11 |
12 | public class HintOverlay {
13 |
14 | private final View parentView;
15 | private final int viewStubId;
16 | private final int textResourceId;
17 | private final int imageResourceId;
18 |
19 | public HintOverlay(View parentView, int viewStubId, int textResourceId, int imageResourceId) {
20 | this.parentView = parentView;
21 | this.viewStubId = viewStubId;
22 | this.textResourceId = textResourceId;
23 | this.imageResourceId = imageResourceId;
24 | }
25 |
26 | public void show() {
27 | ViewStub stub = (ViewStub) parentView.findViewById(viewStubId);
28 | if (stub != null) {
29 | final View hintOverlay = stub.inflate();
30 | hintOverlay.setVisibility(View.VISIBLE);
31 |
32 | ((ImageView) hintOverlay.findViewById(R.id.image)).setImageResource(imageResourceId);
33 | ((TextView) hintOverlay.findViewById(R.id.text)).setText(
34 | parentView.getResources().getString(textResourceId));
35 |
36 | Animation animation = new AlphaAnimation(0, 1);
37 | animation.setDuration(750);
38 | animation.setStartOffset(500);
39 |
40 | animation.setAnimationListener(new Animation.AnimationListener() {
41 | @Override
42 | public void onAnimationStart(Animation animation) {
43 | }
44 |
45 | @Override
46 | public void onAnimationEnd(Animation animation) {
47 | hintOverlay.setOnClickListener(new HideHintClickListener(hintOverlay));
48 | }
49 |
50 | @Override
51 | public void onAnimationRepeat(Animation animation) {
52 | }
53 | });
54 | hintOverlay.startAnimation(animation);
55 | hintOverlay.setOnClickListener(new BlockClickListener());
56 | }
57 | }
58 |
59 | private static class BlockClickListener implements View.OnClickListener {
60 |
61 | @Override
62 | public void onClick(View v) {
63 | // Do nothing.
64 | }
65 | }
66 |
67 | private static class HideHintClickListener implements View.OnClickListener {
68 |
69 | private final View hintOverlay;
70 |
71 | HideHintClickListener(View hintOverlay) {
72 | this.hintOverlay = hintOverlay;
73 | }
74 |
75 | @Override
76 | public void onClick(View v) {
77 | Animation animation = new AlphaAnimation(1, 0);
78 | animation.setDuration(300);
79 | animation.setAnimationListener(new Animation.AnimationListener() {
80 | @Override
81 | public void onAnimationStart(Animation animation) {
82 | hintOverlay.setOnClickListener(null);
83 | }
84 |
85 | @Override
86 | public void onAnimationEnd(Animation animation) {
87 | hintOverlay.setVisibility(View.GONE);
88 | }
89 |
90 | @Override
91 | public void onAnimationRepeat(Animation animation) {
92 | }
93 | });
94 | hintOverlay.startAnimation(animation);
95 | }
96 | }
97 |
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/KioskModeHandler.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.annotation.TargetApi;
4 | import android.app.Activity;
5 | import android.os.Build;
6 | import android.view.View;
7 |
8 | import androidx.annotation.NonNull;
9 |
10 | import com.studio4plus.homerplayer.GlobalSettings;
11 | import com.studio4plus.homerplayer.events.KioskModeChanged;
12 |
13 | import javax.inject.Inject;
14 | import javax.inject.Singleton;
15 |
16 | import de.greenrobot.event.EventBus;
17 |
18 | @Singleton
19 | public class KioskModeHandler {
20 |
21 | private final GlobalSettings globalSettings;
22 | private boolean keepNavigation = false;
23 |
24 | private boolean isLockEnabled = false;
25 |
26 | @Inject
27 | public KioskModeHandler(GlobalSettings settings, EventBus eventBus) {
28 | this.globalSettings = settings;
29 | eventBus.register(this);
30 | }
31 |
32 | public void setKeepNavigation(Boolean keepNavigation) {
33 | this.keepNavigation = keepNavigation;
34 | }
35 |
36 | public void onActivityStart(@NonNull Activity activity) {
37 | setUiFlagsAndLockTask(activity);
38 | }
39 |
40 | public void onFocusGained(@NonNull Activity activity) {
41 | setUiFlagsAndLockTask(activity);
42 | }
43 |
44 | @SuppressWarnings("unused")
45 | public void onEvent(KioskModeChanged event) {
46 | if (event.type == KioskModeChanged.Type.FULL)
47 | lockTask(event.activity, event.isEnabled);
48 | setNavigationVisibility(event.activity, !event.isEnabled);
49 | }
50 |
51 | private void setUiFlagsAndLockTask(@NonNull Activity activity) {
52 | int visibilitySetting = View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
53 | if (!keepNavigation) {
54 | visibilitySetting |= View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
55 | | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN;
56 | }
57 |
58 | activity.getWindow().getDecorView().setSystemUiVisibility(visibilitySetting);
59 | if (globalSettings.isAnyKioskModeEnabled())
60 | setNavigationVisibility(activity, false);
61 |
62 | if (globalSettings.isFullKioskModeEnabled())
63 | lockTask(activity, true);
64 | }
65 |
66 | private void setNavigationVisibility(@NonNull Activity activity, boolean show) {
67 | if (Build.VERSION.SDK_INT < 19 || keepNavigation)
68 | return;
69 |
70 | int flags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
71 | View.SYSTEM_UI_FLAG_FULLSCREEN |
72 | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
73 |
74 | View decorView = activity.getWindow().getDecorView();
75 | int visibilitySetting = decorView.getSystemUiVisibility();
76 | if (show)
77 | visibilitySetting &= ~flags;
78 | else
79 | visibilitySetting |= flags;
80 |
81 | decorView.setSystemUiVisibility(visibilitySetting);
82 | }
83 |
84 | private void lockTask(@NonNull Activity activity, boolean isLocked) {
85 | if (isLockEnabled != isLocked) {
86 | isLockEnabled = isLocked;
87 | if (isLocked)
88 | API21.startLockTask(activity);
89 | else
90 | API21.stopLockTask(activity);
91 | }
92 | }
93 |
94 | @TargetApi(21)
95 | private static class API21 {
96 | static void startLockTask(Activity activity) {
97 | activity.startLockTask();
98 | }
99 |
100 | static void stopLockTask(Activity activity) {
101 | activity.stopLockTask();
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/BatteryStatusIndicator.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.graphics.drawable.AnimationDrawable;
4 | import android.graphics.drawable.Drawable;
5 | import android.view.View;
6 | import android.widget.ImageView;
7 |
8 | import androidx.annotation.Nullable;
9 |
10 | import com.studio4plus.homerplayer.battery.BatteryStatus;
11 | import com.studio4plus.homerplayer.R;
12 | import com.studio4plus.homerplayer.battery.ChargeLevel;
13 | import com.studio4plus.homerplayer.events.BatteryStatusChangeEvent;
14 |
15 | import java.util.EnumMap;
16 | import java.util.Objects;
17 |
18 | import de.greenrobot.event.EventBus;
19 |
20 | public class BatteryStatusIndicator {
21 |
22 | private final ImageView indicatorView;
23 | private final EventBus eventBus;
24 |
25 | @Nullable
26 | private Integer currentDrawable;
27 |
28 | private static final EnumMap BATTERY_DRAWABLE =
29 | new EnumMap<>(ChargeLevel.class);
30 |
31 | private static final EnumMap CHARGING_DRAWABLE =
32 | new EnumMap<>(ChargeLevel.class);
33 |
34 | static {
35 | BATTERY_DRAWABLE.put(ChargeLevel.CRITICAL, R.drawable.battery_critical);
36 | BATTERY_DRAWABLE.put(ChargeLevel.LEVEL_1, R.drawable.battery_red_1);
37 | BATTERY_DRAWABLE.put(ChargeLevel.LEVEL_2, R.drawable.battery_2);
38 | BATTERY_DRAWABLE.put(ChargeLevel.LEVEL_3, R.drawable.battery_3);
39 | BATTERY_DRAWABLE.put(ChargeLevel.FULL, R.drawable.battery_3);
40 |
41 | CHARGING_DRAWABLE.put(ChargeLevel.CRITICAL, R.drawable.battery_charging_0);
42 | CHARGING_DRAWABLE.put(ChargeLevel.LEVEL_1, R.drawable.battery_charging_0);
43 | CHARGING_DRAWABLE.put(ChargeLevel.LEVEL_2, R.drawable.battery_charging_1);
44 | CHARGING_DRAWABLE.put(ChargeLevel.LEVEL_3, R.drawable.battery_charging_2);
45 | CHARGING_DRAWABLE.put(ChargeLevel.FULL, R.drawable.battery_3);
46 | }
47 |
48 | public BatteryStatusIndicator(
49 | ImageView indicatorView, EventBus eventBus, @Nullable BatteryStatus status) {
50 | this.indicatorView = indicatorView;
51 | this.eventBus = eventBus;
52 | this.eventBus.registerSticky(this);
53 | if (status != null) {
54 | updateBatteryStatus(status);
55 | }
56 | }
57 |
58 | public void startAnimations() {
59 | Drawable indicatorDrawable = indicatorView.getDrawable();
60 | if (indicatorDrawable instanceof AnimationDrawable)
61 | ((AnimationDrawable) indicatorDrawable).start();
62 | }
63 |
64 | public void shutdown() {
65 | // TODO: find an automatic way to unregister
66 | eventBus.unregister(this);
67 | }
68 |
69 | private void updateBatteryStatus(BatteryStatus batteryStatus) {
70 | Integer statusDrawable = batteryStatus.isCharging
71 | ? CHARGING_DRAWABLE.get(batteryStatus.chargeLevel)
72 | : BATTERY_DRAWABLE.get(batteryStatus.chargeLevel);
73 | // Don't update the drawables if not needed to avoid restarting the animation which may
74 | // look a bit weird.
75 | if (Objects.equals(statusDrawable, currentDrawable)) return;
76 |
77 | currentDrawable = statusDrawable;
78 | if (statusDrawable == null) {
79 | indicatorView.setVisibility(View.GONE);
80 | } else {
81 | if (indicatorView.getVisibility() != View.VISIBLE)
82 | indicatorView.setVisibility(View.VISIBLE);
83 | indicatorView.setImageResource(statusDrawable);
84 | startAnimations();
85 | }
86 | }
87 |
88 | @SuppressWarnings("unused")
89 | public void onEvent(BatteryStatusChangeEvent batteryEvent) {
90 | updateBatteryStatus(batteryEvent.batteryStatus);
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
17 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
35 |
36 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
54 |
55 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
73 |
74 |
80 |
81 |
82 |
83 |
84 |
85 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/classic/FragmentBookItem.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui.classic;
2 |
3 | import android.os.Bundle;
4 |
5 | import androidx.annotation.AttrRes;
6 | import androidx.annotation.ColorInt;
7 | import androidx.annotation.NonNull;
8 | import androidx.annotation.Nullable;
9 | import android.util.TypedValue;
10 | import android.view.LayoutInflater;
11 | import android.view.View;
12 | import android.view.ViewGroup;
13 | import android.widget.Button;
14 | import android.widget.TextView;
15 |
16 | import com.google.common.base.Preconditions;
17 | import com.studio4plus.homerplayer.HomerPlayerApplication;
18 | import com.studio4plus.homerplayer.R;
19 | import com.studio4plus.homerplayer.model.AudioBook;
20 | import com.studio4plus.homerplayer.model.AudioBookManager;
21 | import com.studio4plus.homerplayer.ui.UiControllerBookList;
22 |
23 | import javax.inject.Inject;
24 | import javax.inject.Named;
25 |
26 | public class FragmentBookItem extends BookListChildFragment {
27 |
28 | public static FragmentBookItem newInstance(String bookId) {
29 | FragmentBookItem newFragment = new FragmentBookItem();
30 | Bundle args = new Bundle();
31 | args.putString(ARG_BOOK_ID, bookId);
32 | newFragment.setArguments(args);
33 | return newFragment;
34 | }
35 |
36 | @Inject public AudioBookManager audioBookManager;
37 | @Inject @Named("AUDIOBOOKS_DIRECTORY") public String audioBooksDirectoryName;
38 |
39 | private @Nullable UiControllerBookList controller;
40 |
41 | @Override
42 | public View onCreateView(
43 | @NonNull LayoutInflater inflater,
44 | @Nullable ViewGroup container,
45 | @Nullable Bundle savedInstanceState) {
46 | View view = inflater.inflate(R.layout.fragment_book_item, container, false);
47 | HomerPlayerApplication.getComponent(view.getContext()).inject(this);
48 |
49 | Bundle args = getArguments();
50 | final String bookId = args.getString(ARG_BOOK_ID);
51 | if (bookId != null) {
52 | AudioBook book = audioBookManager.getById(bookId);
53 | TextView textView = view.findViewById(R.id.title);
54 | textView.setText(book.getTitle());
55 |
56 | @ColorInt int textColour = getColor(book.getColourScheme().textColourAttrId);
57 | textView.setTextColor(textColour);
58 | view.setBackgroundColor(getColor(book.getColourScheme().backgroundColorAttrId));
59 |
60 | if (book.isDemoSample()) {
61 | TextView copyBooksInstruction =
62 | view.findViewById(R.id.copyBooksInstruction);
63 | copyBooksInstruction.setTextColor(textColour);
64 | copyBooksInstruction.setVisibility(View.VISIBLE);
65 | }
66 |
67 | final Button startButton = (Button) view.findViewById(R.id.startButton);
68 | startButton.setOnClickListener(new View.OnClickListener() {
69 | @Override
70 | public void onClick(View v) {
71 | Preconditions.checkNotNull(controller);
72 | controller.playCurrentAudiobook();
73 | startButton.setEnabled(false);
74 | }
75 | });
76 | }
77 |
78 | return view;
79 | }
80 |
81 | public String getAudioBookId() {
82 | return getArguments().getString(ARG_BOOK_ID);
83 | }
84 |
85 | void setController(@NonNull UiControllerBookList controller) {
86 | this.controller = controller;
87 | }
88 |
89 | @ColorInt
90 | private int getColor(@AttrRes int attributeId) {
91 | TypedValue value = new TypedValue();
92 | getContext().getTheme().resolveAttribute(attributeId, value, true);
93 | return getResources().getColor(value.resourceId);
94 | }
95 |
96 | private static final String ARG_BOOK_ID = "bookId";
97 | }
98 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/filescanner/FileScanner.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.filescanner;
2 |
3 | import static com.studio4plus.homerplayer.util.CollectionUtils.map;
4 |
5 | import android.content.Context;
6 |
7 | import androidx.annotation.NonNull;
8 | import androidx.documentfile.provider.DocumentFile;
9 |
10 | import com.studio4plus.homerplayer.ApplicationScope;
11 | import com.studio4plus.homerplayer.GlobalSettings;
12 | import com.studio4plus.homerplayer.concurrency.BackgroundExecutor;
13 | import com.studio4plus.homerplayer.concurrency.SimpleFuture;
14 | import com.studio4plus.homerplayer.demosamples.DemoSamplesFolderProvider;
15 | import com.studio4plus.homerplayer.ui.settings.AudiobooksFolderManager;
16 |
17 | import java.util.Arrays;
18 | import java.util.Collection;
19 | import java.util.Collections;
20 | import java.util.List;
21 | import java.util.concurrent.Callable;
22 |
23 | import javax.inject.Inject;
24 | import javax.inject.Named;
25 |
26 | @ApplicationScope
27 | public class FileScanner {
28 |
29 | public static final Collection SUPPORTED_SUFFIXES = Arrays.asList(".m4a", ".m4b", ".mp3", ".ogg");
30 |
31 | private final GlobalSettings globalSettings;
32 | private final BackgroundExecutor ioExecutor;
33 | private final Context applicationContext;
34 | private final AudiobooksFolderManager folderManager;
35 |
36 | @Inject
37 | public FileScanner(
38 | @Named("IO_EXECUTOR") BackgroundExecutor ioExecutor,
39 | @NonNull GlobalSettings globalSettings,
40 | @NonNull Context applicationContext,
41 | @NonNull AudiobooksFolderManager folderManager) {
42 | this.ioExecutor = ioExecutor;
43 | this.globalSettings = globalSettings;
44 | this.applicationContext = applicationContext;
45 | this.folderManager = folderManager;
46 | }
47 |
48 | public SimpleFuture> scanAudioBooksDirectories() {
49 | DemoSamplesFolderProvider samplesFolderProvider = new DemoSamplesFolderProvider(applicationContext);
50 | ScanFilesTask.FolderProvider demoFolderProvider =
51 | () -> Collections.singletonList(samplesFolderProvider.demoFolder());
52 | if (globalSettings.legacyFileAccessMode()) {
53 | final Callable> task =
54 | new ScanWithFallbackTask(
55 | new ScanFilesTask(new LegacyFolderProvider(applicationContext), false),
56 | new ScanFilesTask(demoFolderProvider, true));
57 | return ioExecutor.postTask(task);
58 | }
59 | List audiobooksFolders = folderManager.getCurrentFolders();
60 | final Callable> task;
61 | if (!audiobooksFolders.isEmpty()) {
62 | task = new ScanDocumentTreeTask(applicationContext, map(audiobooksFolders, DocumentFile::getUri));
63 | } else {
64 | task = new ScanFilesTask(demoFolderProvider, true);
65 | }
66 | return ioExecutor.postTask(task);
67 | }
68 |
69 | private static class ScanWithFallbackTask implements Callable> {
70 |
71 | private final Callable> scanMainContentTask;
72 | private final Callable> scanDemoSamplesTask;
73 |
74 | private ScanWithFallbackTask(
75 | Callable> scanMainContentTask,
76 | Callable> scanDemoSamplesTask) {
77 | this.scanMainContentTask = scanMainContentTask;
78 | this.scanDemoSamplesTask = scanDemoSamplesTask;
79 | }
80 |
81 | @Override
82 | public List call() throws Exception {
83 | List mainContent = scanMainContentTask.call();
84 | if (mainContent.isEmpty()) {
85 | return scanDemoSamplesTask.call();
86 | }
87 | return mainContent;
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/app/src/main/java/com/studio4plus/homerplayer/ui/SoundBank.java:
--------------------------------------------------------------------------------
1 | package com.studio4plus.homerplayer.ui;
2 |
3 | import android.content.res.Resources;
4 | import android.media.AudioFormat;
5 | import android.media.AudioManager;
6 | import android.media.AudioTrack;
7 | import android.os.Build;
8 |
9 | import com.google.common.base.Preconditions;
10 | import com.studio4plus.homerplayer.R;
11 | import com.studio4plus.homerplayer.crashreporting.CrashReporting;
12 |
13 | import java.io.IOException;
14 | import java.io.InputStream;
15 | import java.nio.ByteBuffer;
16 | import java.nio.ByteOrder;
17 | import java.util.EnumMap;
18 |
19 | import javax.inject.Inject;
20 |
21 | public class SoundBank {
22 |
23 | public enum SoundId {
24 | FF_REWIND
25 | }
26 |
27 | public static class Sound {
28 | public final AudioTrack track;
29 | public final long frameCount;
30 | public final int sampleRate;
31 |
32 | public Sound(AudioTrack track, long frameCount, int sampleRate) {
33 | this.track = track;
34 | this.frameCount = frameCount;
35 | this.sampleRate = sampleRate;
36 | }
37 | }
38 |
39 | private final EnumMap tracks = new EnumMap<>(SoundId.class);
40 |
41 | public static void stopTrack(AudioTrack track) {
42 | // https://code.google.com/p/android/issues/detail?id=155984
43 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
44 | track.pause();
45 | track.flush();
46 | } else {
47 | track.stop();
48 | }
49 | }
50 |
51 | @Inject
52 | public SoundBank(Resources resources) {
53 | Sound sound = createSoundFromWavResource(resources, R.raw.rewind_sound, true);
54 | if (sound != null)
55 | tracks.put(SoundId.FF_REWIND, sound);
56 | }
57 |
58 | public Sound getSound(SoundId soundId) {
59 | return tracks.get(soundId);
60 | }
61 |
62 | private static Sound createSoundFromWavResource(
63 | Resources resources, int resourceId, boolean isLooping) {
64 | try {
65 | byte[] data = loadResourceData(resources.openRawResource(resourceId));
66 | ByteBuffer buffer = ByteBuffer.wrap(data);
67 | buffer.order(ByteOrder.LITTLE_ENDIAN);
68 |
69 | int channelCount = buffer.getShort(WAVE_CHANNELS_OFFSET);
70 | int sampleRate = buffer.getInt(WAVE_SAMPLERATE_OFFSET);
71 |
72 | int sizeInBytes = data.length - WAVE_DATA_OFFSET;
73 | AudioTrack track = new AudioTrack(
74 | AudioManager.STREAM_MUSIC,
75 | sampleRate,
76 | channelCount == 1 ? AudioFormat.CHANNEL_OUT_MONO : AudioFormat.CHANNEL_OUT_STEREO,
77 | AudioFormat.ENCODING_PCM_16BIT,
78 | sizeInBytes,
79 | AudioTrack.MODE_STATIC);
80 | track.write(data, WAVE_DATA_OFFSET, sizeInBytes);
81 |
82 | final int frameCount = sizeInBytes / channelCount / 2; // assumes PCM_16BIT (2 bytes)
83 | if (isLooping)
84 | track.setLoopPoints(0, frameCount, -1);
85 |
86 | return new Sound(track, frameCount, sampleRate);
87 | } catch (IOException e) {
88 | CrashReporting.logException(e);
89 | return null;
90 | }
91 | }
92 |
93 | private static byte[] loadResourceData(InputStream inputStream) throws IOException {
94 | final int length = inputStream.available();
95 | byte[] bytes = new byte[length];
96 | final int bytesRead = inputStream.read(bytes);
97 | Preconditions.checkState(bytesRead == length);
98 | return bytes;
99 | }
100 |
101 | private static final int WAVE_CHANNELS_OFFSET = 22;
102 | private static final int WAVE_SAMPLERATE_OFFSET = 24;
103 | private static final int WAVE_DATA_OFFSET = 44;
104 | }
105 |
--------------------------------------------------------------------------------