diff --git a/.gitignore b/.gitignore index afbdab33..f922c90d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,51 @@ -.gradle +# Crashlytics configuations +com_crashlytics_export_strings.xml + +# Generated files +.gradle/ +build/ +bin/ +gen/ +out/ +*.apk +*.ap_ + +# Local configuration file (sdk path, etc) /local.properties -/.idea/workspace.xml -/.idea/libraries + +# Signing files +.signing/ + +# User-specific configurations +.idea/libraries/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/vcs.xml +.idea/caches/ +.idea/codeStyles/ +.idea/shelf/ + +# OS-specific files .DS_Store -/build +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Google Services (e.g. APIs or Firebase) +app/google-services.json diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 217af471..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index e7bedf33..00000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index e206d70d..00000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 2071d1a6..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 3b312839..00000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index b0a270f5..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index eb3197b3..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/scopes/scope_settings.xml b/.idea/scopes/scope_settings.xml deleted file mode 100644 index 922003b8..00000000 --- a/.idea/scopes/scope_settings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index c63efbb2..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..9c1ef22e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,110 @@ +language: android +env: + global: + - ANDROID_API=29 + - EMULATOR_API=24 + - ANDROID_BUILD_TOOLS=29.0.2 + +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.m2 + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.android/build-cache + +android: + components: + - tools + - android-$EMULATOR_API + - platform-tools + - tools + - build-tools-$ANDROID_BUILD_TOOLS + - android-$ANDROID_API + - extra-android-support + - extra-android-m2repository + - extra-google-m2repository + - sys-img-armeabi-v7a-android-$EMULATOR_API + licenses: + - ".+" + +before_install: + - yes | sdkmanager "platforms;android-28" + +# Emulator Management: Update SDK, Create, Start and Wait +before_script: + - echo "y" | android update sdk -a --no-ui --filter android-$EMULATOR_API + - echo "y" | android update sdk -a --no-ui --filter sys-img-armeabi-v7a-android-$EMULATOR_API + - android list targets | grep -E '^id:' | awk -F '"' '{$1=""; print $2}' # list all targets + - echo no | android create avd --force -n test -t android-$EMULATOR_API --abi armeabi-v7a + - emulator -avd test -no-skin -no-window & + - android-wait-for-emulator + - adb shell input keyevent 82 & + - adb wait-for-device get-serialno + - cd ${TRAVIS_BUILD_DIR} + - chmod +x gradlew + - ./gradlew --version + - ./gradlew clean + +script: + - | + ./gradlew build assembleAndroidTest -PtestCoverageEnabled='true' + retval=$? + if [ $retval -ne 0 ]; then + echo "error on assembling, exit code: "$retval + exit $retval + fi + +# See https://stackoverflow.com/q/21294945/1429387 +# Instead of this (which doesn't give any output during tests execution): +# - ./gradlew connectedCheck -PdisablePreDex --continue --stacktrace --debug +# run: + - | + ./gradlew :app:installDebug :app:installDebugAndroidTest -PtestCoverageEnabled='true' + retval=$? + if [ $retval -ne 0 ]; then + echo "error on install, exit code: "$retval + exit $retval + fi + +# +# Run only tests, marked with a special annotation: +# See https://d.android.com/reference/android/support/test/runner/AndroidJUnitRunner +# For testing one method only: +# - adb shell am instrument -w -r -e debug false -e coverage true -e class by.naxa.soundrecorder.MainActivityTests#testGreet by.naxa.soundrecorder/androidx.test.runner.AndroidJUnitRunner + - adb shell pm list instrumentation + - | + adb shell am instrument -w -r -e executionMode travisTest -e coverage true by.naxa.soundrecorder.test/androidx.test.runner.AndroidJUnitRunner |& tee build/adb-test.log + retval=$? + if [ $retval -ne 0 ]; then + echo "error in adb, exit code: "$retval + exit $retval + fi + +# adb doesn't propagate exit code from tests, see https://code.google.com/p/android/issues/detail?id=3254 +# So we need to parse saved terminal log + - | + cat build/adb-test.log | grep "INSTRUMENTATION_STATUS: stack=" | grep -v "org.junit.AssumptionViolatedException" + if [ $? -eq 0 ]; then + echo "Test failure found" + exit 1 + else + cat build/adb-test.log | grep "OK (" + fi + +# Copy coverage data from the emulator + - | + adb shell "rm /sdcard/Download/coverage.ec" + adb shell "run-as by.naxa.soundrecorder cp /data/user/0/by.naxa.soundrecorder/files/coverage.ec /sdcard/Download" +# copy to "build" folder, where it will be found by Sonar scanner + - cd app/build + - adb pull "/sdcard/Download/coverage.ec" + - cd ../.. + +notifications: + email: false + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..85c18fcb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +Contributors are more than welcome. How can you contribute? + +### Report bugs + +We want our app to be as stable as possible thus your bug reports are immensely valuable. File [GitHub Issues](https://github.com/naXa777/SoundRecorder/issues) for anything that is unexpectedly broken. + +* App version +* Device model +* Android version +* Steps to reproduce the bug +* Expected behavior +* Actual behavior (a screenshot and/or log file may be helpful) + +### Contribute Code [![Open Source Helpers](https://www.codetriage.com/naxa777/soundrecorder/badges/users.svg)](https://www.codetriage.com/naxa777/soundrecorder) + +We have labeled tasks you can help with as [![GitHub issues by-label](https://img.shields.io/github/issues/naXa777/SoundRecorder/help%20wanted.svg)](https://github.com/naXa777/SoundRecorder/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) and [![GitHub issues by-label](https://img.shields.io/github/issues/naXa777/SoundRecorder/good%20first%20issue.svg)](https://github.com/naXa777/SoundRecorder/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Just pick up an issue that you're interested in and start coding. If you have a great idea you really want to implement, start by logging an issue for us. We'll let you know if it fits with our product direction and then you can start development. When you're ready open a Pull Request with a description of your changes. + +See [Git Essentials](#git-essentials) for a simple bugfix workflow. + +### Translate + +You don't have to be a programmer if you want to translate this application in your own language or improve existing translations. +You can translate SoundRecorder using [POEditor](https://poeditor.com/join/project/IuPsne4VcJ) - a collaborative translation platform. + +[![POEditor](https://poeditor.com/public/images/logo_small.png)](https://poeditor.com/join/project/IuPsne4VcJ) + +### Automate Testing + +Testing is imperative to the health of the project. There's a configured CI pipeline ([Travis CI](https://travis-ci.com/naXa777/SoundRecorder)) intended for running unit tests and instrumented tests on every commit to the repository, but unfortunately, there're very few tests at the moment. + +Please follow standard guidelines if you want to contribute a test: + +1. Android Developers - [Test apps on Android](https://d.android.com/training/testing/) +2. Android Studio - [Test your app](https://d.android.com/studio/test/) +3. GitHub - [Android testing samples](https://github.com/googlesamples/android-testing) + + +## Git Essentials + +Workflows can vary, but here is a very simple workflow for contributing a bug fix: + +1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository. + +2. Clone the fork: + + $ git clone git@github.com:YOUR_USERNAME/SoundRecorder.git + $ git remote add upstream https://github.com/naXa777/SoundRecorder.git + + Read why do you need to [Configure a remote](https://help.github.com/articles/configuring-a-remote-for-a-fork/) if you're interested. + +3. Prepare a feature branch: + + $ git checkout -b issue-123-keyword master + +4. Do development and then commit your changes: + + $ git commit -m "fix #123 - Description of what I had changed" + $ git push + + A quick note: See Chris Beams' guide to writing good commit messages - [How to Write a Git Commit Message](https://chris.beams.io/posts/git-commit/). + +5. Open a pull request. + + Read [Creating a pull request from a fork](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) for details. diff --git a/README.md b/README.md index 6e2d0116..b7d4c29b 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,52 @@ -Easy Sound Recorder -============= +# Easy Sound Recorder 2 +[![API](https://img.shields.io/badge/API-16%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=16) [![Build Status](https://travis-ci.com/naXa777/SoundRecorder.svg?branch=master&style=flat)](https://travis-ci.com/naXa777/SoundRecorder) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/1b4c1e2546784537b6bef070769c34bb)](https://www.codacy.com/app/naXa777/SoundRecorder?utm_source=github.com&utm_medium=referral&utm_content=naXa777/SoundRecorder&utm_campaign=Badge_Grade) -

A simple sound recording app implementing Material Design.

+A simple sound recording Android app implementing Material Design. -[![Android app on Google Play](https://developer.android.com/images/brand/en_app_rgb_wo_60.png)](https://play.google.com/store/apps/details?id=com.danielkim.soundrecorder) [![Android app on F-Droid](https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Get_it_on_F-Droid.svg/200px-Get_it_on_F-Droid.svg.png)](https://f-droid.org/repository/browse/?fdid=com.danielkim.soundrecorder) +![Icon](/app/src/main/res/mipmap-hdpi/ic_launcher.png) [![Android app on Google Play](https://d.android.com/images/brand/en_app_rgb_wo_60.png)](https://play.google.com/store/apps/details?id=by.naxa.soundrecorder) -Screenshots: +## Contributing -![alt tag](http://i.imgur.com/4W5fj0Il.png) ![alt tag](http://i.imgur.com/7ggcFQzl.png) ![alt tag](http://i.imgur.com/RqD8S3Il.png) ![alt tag](http://i.imgur.com/H6ScO21l.png) +Contributors are more than welcome. Feel free to report bugs, fix bugs, implement new features, improve translations, increase test coverage, or write documentation. +Please, read [Contributing guidelines](/CONTRIBUTING.md) before opening new [issues](https://github.com/naXa777/SoundRecorder/issues) or submitting [pull requests](https://github.com/naXa777/SoundRecorder/pulls) to this repository. -Credits / Libraries used: +## Screenshots -https://github.com/makovkastar/FloatingActionButton +![Imgur](https://i.imgur.com/wxCXesJl.png) ![Imgur](https://i.imgur.com/86sehcjl.png) +![Imgur](https://i.imgur.com/p9Pn9Qgl.png) ![Imgur](https://i.imgur.com/LthDOjHl.png) +![Imgur](https://i.imgur.com/KCODDi8l.png) ![Imgur](https://i.imgur.com/rxeQUDIl.png) +![Imgur](https://i.imgur.com/U6w7dnXl.png) ![Imgur](https://i.imgur.com/ZGRnroNl.png) -https://github.com/MohammadAG/Android-SoundRecorder +## Building -https://github.com/astuetz/PagerSlidingTabStrip +If you want to run the app locally, do the following: +1. Download or clone the repository +2. Import the project in your IDE (we use Gradle + Android Studio to build) +3. Setup Crashlytics (you need Firebase and Fabric.io accounts for this) +4. You should now be able to build and run the app. + +See [Add Firebase to your Android project](https://firebase.google.com/docs/android/setup) and [Get started with Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/get-started?platform=android) if you need more help. + +## Dev environment + +- [Android Studio](https://d.android.com/studio/preview/) 3.4 is used for development +- [Gradle](https://gradle.org/install/) 5.4 is used to build the project +- [Android SDK 9.0](https://d.android.com/studio/releases/platforms#9.0) (Pie), API level 28 +- Java 1.6 + +## Permissions needed for the app are: + +- record audio +- write to external storage (to store recordings) +- read from external storage (to playback recordings) +- internet access (for stats collection) + +Since February 2017 Google enforces a strict privacy policy requirement for apps using sensitive permissions (the RECORD_AUDIO permission). See [Privacy Policy](https://soundrecorder.bitbucket.io/privacy_policy.html) of Easy Sound Recorder 2. + +## Credits / Libraries used: + +- [Java MP4 Parser](https://github.com/sannies/mp4parser) +- [Circular Progress Bar](https://github.com/yuriy-budiyev/circular-progress-bar) +- [Material Components for Android](https://github.com/material-components/material-components-android) diff --git a/app/.gitignore b/app/.gitignore index 796b96d1..09937e99 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,4 @@ /build +/release +# Fabric plugin +fabric.properties diff --git a/app/app.iml b/app/app.iml index 1cdca55a..89619316 100644 --- a/app/app.iml +++ b/app/app.iml @@ -22,7 +22,7 @@ - + @@ -62,13 +62,6 @@ - - - - - - - @@ -76,45 +69,39 @@ + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index ae3d3494..a8742302 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,31 +1,57 @@ apply plugin: 'com.android.application' +apply plugin: 'io.fabric' +apply plugin: 'com.google.gms.google-services' android { - compileSdkVersion 21 - buildToolsVersion '25.0.0' + compileSdkVersion 29 + buildToolsVersion '29.0.2' defaultConfig { - applicationId "com.danielkim.soundrecorder" + applicationId 'by.naxa.soundrecorder' minSdkVersion 16 - targetSdkVersion 21 - versionCode 130 - versionName "1.3.0" + targetSdkVersion 28 + versionCode 202 + versionName "2.0.2" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { + testCoverageEnabled = true + } } - lintOptions{ + lintOptions { disable 'MissingTranslation' + abortOnError false } } dependencies { - compile 'com.android.support:appcompat-v7:21.0.+' - compile 'com.android.support:cardview-v7:21.0.+' - compile 'com.android.support:recyclerview-v7:21.0.+' - compile 'com.melnykov:floatingactionbutton:1.1.0' - compile 'com.jpardogo.materialtabstrip:library:1.0.6' + implementation 'androidx.appcompat:appcompat:1.0.+' + implementation 'androidx.cardview:cardview:1.0.+' + implementation 'androidx.recyclerview:recyclerview:1.0.+' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.+' + implementation 'com.google.android.material:material:1.1.+' + implementation 'com.googlecode.mp4parser:isoparser:1.1.22' + implementation 'com.budiyev.android:circular-progress-bar:1.2.0' + + // detect memory leaks + debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.6.1' + releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.6.1' + debugImplementation 'com.squareup.leakcanary:leakcanary-support-fragment:1.6.1' + + androidTestImplementation 'androidx.annotation:annotation:1.0.+' + androidTestImplementation 'androidx.test:runner:1.1.+' + androidTestImplementation 'androidx.test:rules:1.1.+' + androidTestImplementation('androidx.test.espresso:espresso-core:3.1.+', { + exclude group: 'com.android.support', module: 'support-annotations' + exclude group: 'androidx.annotation', module: 'annotation' + }) + + // Firebase + implementation 'com.google.firebase:firebase-analytics:17.2.1' + implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' } diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 00000000..63a8188c --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,51 @@ +{ + "project_info": { + "project_number": "", + "project_id": "" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789012:android:1234567890123456", + "android_client_info": { + "package_name": "by.naxa.soundrecorder" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + }, + { + "client_id": "", + "client_type": 1, + "android_info": { + "package_name": "by.naxa.soundrecorder", + "certificate_hash": "" + } + } + ], + "api_key": [ + { + "current_key": "" + } + ], + "services": { + "analytics_service": { + "status": 2, + "analytics_property": { + "tracking_id": "" + } + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 1 + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/androidTest/java/by/naxa/soundrecorder/ApplicationTest.java b/app/src/androidTest/java/by/naxa/soundrecorder/ApplicationTest.java new file mode 100644 index 00000000..bfbca7db --- /dev/null +++ b/app/src/androidTest/java/by/naxa/soundrecorder/ApplicationTest.java @@ -0,0 +1,26 @@ +package by.naxa.soundrecorder; + +import android.content.Context; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import static org.junit.Assert.assertEquals; + +/** + * Test your app + */ +@RunWith(AndroidJUnit4.class) +public class ApplicationTest { + + @Test + public void useAppContext() { + // Context of the app under test + Context appContext = InstrumentationRegistry.getTargetContext(); + assertEquals("by.naxa.soundrecorder", appContext.getPackageName()); + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/by/naxa/soundrecorder/MainActivityTests.java b/app/src/androidTest/java/by/naxa/soundrecorder/MainActivityTests.java new file mode 100644 index 00000000..3647a0a9 --- /dev/null +++ b/app/src/androidTest/java/by/naxa/soundrecorder/MainActivityTests.java @@ -0,0 +1,25 @@ +package by.naxa.soundrecorder; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; +import by.naxa.soundrecorder.activities.MainActivity; + +/** + * Test your app + */ +@RunWith(AndroidJUnit4.class) +public class MainActivityTests { + + @Rule + public ActivityTestRule mActivityRule = new ActivityTestRule<>(MainActivity.class); + + @Test + public void testGreet() { + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/ApplicationTest.java b/app/src/androidTest/java/com/danielkim/soundrecorder/ApplicationTest.java deleted file mode 100644 index 17e285cb..00000000 --- a/app/src/androidTest/java/com/danielkim/soundrecorder/ApplicationTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.danielkim.soundrecorder; - -import android.app.Application; -import android.test.ApplicationTestCase; - -/** - * Testing Fundamentals - */ -public class ApplicationTest extends ApplicationTestCase { - public ApplicationTest() { - super(Application.class); - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ee713ca..ab0770bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,44 +1,59 @@ + package="by.naxa.soundrecorder"> + + + + + + + android:theme="@style/AppTheme"> + android:label="@string/app_name"> + + + + + + + android:value=".activities.MainActivity" /> + - + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/danielkim/soundrecorder/DBHelper.java b/app/src/main/java/by/naxa/soundrecorder/DBHelper.java similarity index 91% rename from app/src/main/java/com/danielkim/soundrecorder/DBHelper.java rename to app/src/main/java/by/naxa/soundrecorder/DBHelper.java index 6f0bb7a3..10fd30dc 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/DBHelper.java +++ b/app/src/main/java/by/naxa/soundrecorder/DBHelper.java @@ -1,4 +1,4 @@ -package com.danielkim.soundrecorder; +package by.naxa.soundrecorder; import android.content.ContentValues; import android.content.Context; @@ -7,16 +7,12 @@ import android.database.sqlite.SQLiteOpenHelper; import android.provider.BaseColumns; -import com.danielkim.soundrecorder.listeners.OnDatabaseChangedListener; - -import java.util.Comparator; +import by.naxa.soundrecorder.listeners.OnDatabaseChangedListener; /** * Created by Daniel on 12/29/2014. */ public class DBHelper extends SQLiteOpenHelper { - private Context mContext; - private static final String LOG_TAG = "DBHelper"; private static OnDatabaseChangedListener mOnDatabaseChangedListener; @@ -58,7 +54,6 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { public DBHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); - mContext = context; } public static void setOnDatabaseChangedListener(OnDatabaseChangedListener listener) { @@ -103,18 +98,6 @@ public int getCount() { return count; } - public Context getContext() { - return mContext; - } - - public class RecordingComparator implements Comparator { - public int compare(RecordingItem item1, RecordingItem item2) { - Long o1 = item1.getTime(); - Long o2 = item2.getTime(); - return o2.compareTo(o1); - } - } - public long addRecording(String recordingName, String filePath, long length) { SQLiteDatabase db = getWritableDatabase(); diff --git a/app/src/main/java/by/naxa/soundrecorder/RecorderState.java b/app/src/main/java/by/naxa/soundrecorder/RecorderState.java new file mode 100644 index 00000000..c5ea5b86 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/RecorderState.java @@ -0,0 +1,8 @@ +package by.naxa.soundrecorder; + +public enum RecorderState { + STOPPED, + PREPARING, + RECORDING, + PAUSED +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/RecordingItem.java b/app/src/main/java/by/naxa/soundrecorder/RecordingItem.java similarity index 98% rename from app/src/main/java/com/danielkim/soundrecorder/RecordingItem.java rename to app/src/main/java/by/naxa/soundrecorder/RecordingItem.java index 5c4b7705..2dc598e5 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/RecordingItem.java +++ b/app/src/main/java/by/naxa/soundrecorder/RecordingItem.java @@ -1,4 +1,4 @@ -package com.danielkim.soundrecorder; +package by.naxa.soundrecorder; import android.os.Parcel; import android.os.Parcelable; diff --git a/app/src/main/java/by/naxa/soundrecorder/SoundRecorderApplication.java b/app/src/main/java/by/naxa/soundrecorder/SoundRecorderApplication.java new file mode 100644 index 00000000..3f8ff972 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/SoundRecorderApplication.java @@ -0,0 +1,52 @@ +package by.naxa.soundrecorder; + +import android.app.Application; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import com.squareup.leakcanary.LeakCanary; + + +public class SoundRecorderApplication extends Application { + + public static final String NIGHT_MODE = "NIGHT_MODE"; + private boolean isNightModeEnabled = false; + + private static SoundRecorderApplication singleton = null; + + // Here we make a singleton instance of SoundRecorderApplication + public static SoundRecorderApplication getInstance() { + if (singleton == null) { + singleton = new SoundRecorderApplication(); + } + return singleton; + } + + @Override + public void onCreate() { + super.onCreate(); + singleton = this; + SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + this.isNightModeEnabled = mPrefs.getBoolean(NIGHT_MODE, false); + + if (LeakCanary.isInAnalyzerProcess(this)) { + // This process is dedicated to LeakCanary for heap analysis. + // You should not init your app in this process. + return; + } + LeakCanary.install(this); + } + + public boolean isNightModeEnabled() { + return isNightModeEnabled; + } + + public void setIsNightModeEnabled(boolean isNightModeEnabled) { + this.isNightModeEnabled = isNightModeEnabled; + + SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putBoolean(NIGHT_MODE, isNightModeEnabled); + editor.apply(); + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/activities/MainActivity.java b/app/src/main/java/by/naxa/soundrecorder/activities/MainActivity.java new file mode 100644 index 00000000..9e9a40d3 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/activities/MainActivity.java @@ -0,0 +1,169 @@ +package by.naxa.soundrecorder.activities; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.viewpager.widget.ViewPager; + +import com.crashlytics.android.Crashlytics; +import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.tabs.TabLayout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.SoundRecorderApplication; +import by.naxa.soundrecorder.fragments.FileViewerFragment; +import by.naxa.soundrecorder.fragments.RecordFragment; +import by.naxa.soundrecorder.util.EventBroadcaster; + +public class MainActivity extends AppCompatActivity { + + private static final String LOG_TAG = MainActivity.class.getSimpleName(); + + private BroadcastReceiver mMessageReceiver = null; + public static final List REQUEST_INTENTS = Collections.singletonList(MediaStore.Audio.Media.RECORD_SOUND_ACTION); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (SoundRecorderApplication.getInstance().isNightModeEnabled()) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); //For night mode theme + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); //For day mode theme + } + setContentView(R.layout.activity_main); + + final ViewPager pager = findViewById(R.id.pager); + setupViewPager(pager); + final TabLayout tabs = findViewById(R.id.tabs); + tabs.setupWithViewPager(pager); + + Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + setSupportActionBar(toolbar); + } + + final View root = findViewById(R.id.main_activity); + mMessageReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final String message = intent.getStringExtra(EventBroadcaster.MESSAGE); + Snackbar.make(root, message, Snackbar.LENGTH_LONG).show(); + } + }; + + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + if (REQUEST_INTENTS.contains(getIntent().getAction())) { + setResult(Activity.RESULT_CANCELED, null); + finish(); + } + } + + private void setupViewPager(ViewPager viewPager) { + final MyAdapter adapter = new MyAdapter(getSupportFragmentManager()); + adapter.addFragment(RecordFragment.newInstance(), getString(R.string.tab_title_record)); + adapter.addFragment(FileViewerFragment.newInstance(), getString(R.string.tab_title_saved_recordings)); + viewPager.setAdapter(adapter); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + // Handle presses on the action bar items + switch (item.getItemId()) { + case R.id.action_settings: + Intent i = new Intent(this, SettingsActivity.class); + startActivity(i); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + public class MyAdapter extends FragmentPagerAdapter { + private final List fragments = new ArrayList<>(); + private final List titles = new ArrayList<>(); + + MyAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int position) { + return fragments.get(position); + } + + @Override + public int getCount() { + return titles.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return titles.get(position); + } + + void addFragment(Fragment fragment, String title) { + fragments.add(fragment); + titles.add(title); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (SoundRecorderApplication.getInstance().isNightModeEnabled()) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); //For night mode theme + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); //For day mode theme + } + LocalBroadcastManager.getInstance(this).registerReceiver( + mMessageReceiver, + new IntentFilter(EventBroadcaster.SHOW_SNACKBAR) + ); + } + + @Override + protected void onPause() { + super.onPause(); + try { + LocalBroadcastManager.getInstance(this).unregisterReceiver(mMessageReceiver); + } catch (Exception exc) { + Crashlytics.logException(exc); + Log.e(LOG_TAG, "Error unregistering MessageReceiver", exc); + } + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/activities/SettingsActivity.java b/app/src/main/java/by/naxa/soundrecorder/activities/SettingsActivity.java new file mode 100644 index 00000000..ec6f9047 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/activities/SettingsActivity.java @@ -0,0 +1,63 @@ +package by.naxa.soundrecorder.activities; + +import android.os.Bundle; +import android.preference.PreferenceActivity; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.widget.Toolbar; + +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.SoundRecorderApplication; +import by.naxa.soundrecorder.fragments.SettingsFragment; + +/** + * A {@link PreferenceActivity} that presents a set of application settings. + * On handset devices, settings are presented as a single list. + *

+ * See + * Material Design: Android Settings for design guidelines and the Settings API Guide + * for more information on developing a Settings UI. + * + * Created by Daniel on 5/22/2017. + */ +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (SoundRecorderApplication.getInstance().isNightModeEnabled()) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); //For night mode theme + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); //For day mode theme + } + setContentView(R.layout.activity_preferences); + setupActionBar(); + + getFragmentManager() + .beginTransaction() + .replace(R.id.container, new SettingsFragment()) + .commit(); + } + + /** + * Set up the {@link android.app.ActionBar}. + */ + private void setupActionBar() { + final Toolbar toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + setSupportActionBar(toolbar); + } + + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.action_settings); + // Show the Up button in the action bar. + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowHomeEnabled(true); + } + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/adapters/FileViewerAdapter.java b/app/src/main/java/by/naxa/soundrecorder/adapters/FileViewerAdapter.java new file mode 100644 index 00000000..c5d2e6d4 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/adapters/FileViewerAdapter.java @@ -0,0 +1,350 @@ +package by.naxa.soundrecorder.adapters; + +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.text.Editable; +import android.text.format.DateUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import com.crashlytics.android.Crashlytics; +import com.google.android.material.textfield.TextInputEditText; + +import java.io.File; +import java.util.ArrayList; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.FileProvider; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentTransaction; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import by.naxa.soundrecorder.BuildConfig; +import by.naxa.soundrecorder.DBHelper; +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.RecordingItem; +import by.naxa.soundrecorder.fragments.PlaybackFragment; +import by.naxa.soundrecorder.listeners.OnDatabaseChangedListener; +import by.naxa.soundrecorder.listeners.OnSingleClickListener; +import by.naxa.soundrecorder.util.EventBroadcaster; +import by.naxa.soundrecorder.util.Paths; +import by.naxa.soundrecorder.util.TimeUtils; +import io.fabric.sdk.android.Fabric; + +/** + * Created by Daniel on 12/29/2014. + */ +public class FileViewerAdapter extends RecyclerView.Adapter + implements OnDatabaseChangedListener { + + private static final String LOG_TAG = "FileViewerAdapter"; + + private DBHelper mDatabase; + + private Context mContext; + private final LinearLayoutManager llm; + + public FileViewerAdapter(Context context, LinearLayoutManager linearLayoutManager) { + super(); + mContext = context; + mDatabase = new DBHelper(mContext); + DBHelper.setOnDatabaseChangedListener(this); + llm = linearLayoutManager; + } + + @Override + public void onBindViewHolder(@NonNull final RecordingsViewHolder holder, int position) { + + RecordingItem item = getItem(position); + long itemDuration = item.getLength(); + + holder.vName.setText(item.getName()); + holder.vLength.setText(TimeUtils.formatDuration(itemDuration)); + holder.vDateAdded.setText( + DateUtils.formatDateTime( + mContext, + item.getTime(), + DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_YEAR + ) + ); + + // define an on click listener to open PlaybackFragment + holder.cardView.setOnClickListener(new OnSingleClickListener() { + @Override + public void onSingleClick(View view) { + try { + PlaybackFragment playbackFragment = + new PlaybackFragment().newInstance(getItem(holder.getPosition())); + + FragmentTransaction transaction = ((FragmentActivity) mContext) + .getSupportFragmentManager() + .beginTransaction(); + + playbackFragment.show(transaction, "dialog_playback"); + } catch (Exception e) { + Log.e(LOG_TAG, "exception", e); + Crashlytics.logException(e); + } + } + }); + + holder.options.setOnClickListener(new OnSingleClickListener() { + @Override + public void onSingleClick(View view) { + presentFileOptions(holder); + } + }); + + holder.cardView.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + return presentFileOptions(holder); + } + }); + } + + private boolean presentFileOptions(@NonNull final RecordingsViewHolder holder) { + final ArrayList entries = new ArrayList<>(); + entries.add(mContext.getString(R.string.dialog_file_share)); + entries.add(mContext.getString(R.string.dialog_file_rename)); + entries.add(mContext.getString(R.string.dialog_file_delete)); + + final CharSequence[] items = entries.toArray(new CharSequence[entries.size()]); + + + // File delete confirm + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + builder.setTitle(mContext.getString(R.string.dialog_title_options)); + builder.setItems(items, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int item) { + if (item == 0) { + shareFileDialog(holder.getPosition()); + } else if (item == 1) { + renameFileDialog(holder.getPosition()); + } else if (item == 2) { + deleteFileDialog(holder.getPosition()); + } + } + }); + builder.setCancelable(true); + builder.setNegativeButton(mContext.getString(R.string.dialog_action_cancel), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + + AlertDialog alert = builder.create(); + alert.show(); + + return true; + } + + @Override + @NonNull + public RecordingsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + + View itemView = LayoutInflater. + from(parent.getContext()). + inflate(R.layout.card_view, parent, false); + + mContext = parent.getContext(); + + return new RecordingsViewHolder(itemView); + } + + static class RecordingsViewHolder extends RecyclerView.ViewHolder { + TextView vName; + TextView vLength; + TextView vDateAdded; + View cardView; + ImageView options; + + RecordingsViewHolder(View v) { + super(v); + vName = v.findViewById(R.id.file_name_text); + vLength = v.findViewById(R.id.file_length_text); + vDateAdded = v.findViewById(R.id.file_date_added_text); + cardView = v.findViewById(R.id.card_view); + options = v.findViewById(R.id.optionsImageView); + } + } + + @Override + public int getItemCount() { + return mDatabase.getCount(); + } + + public RecordingItem getItem(int position) { + return mDatabase.getItemAt(position); + } + + @Override + public void onNewDatabaseEntryAdded() { + //item added to top of the list + notifyItemInserted(getItemCount() - 1); + llm.scrollToPosition(getItemCount() - 1); + } + + @Override + //TODO + public void onDatabaseEntryRenamed() { + + } + + public void remove(int position) { + //remove item from database, recyclerview and storage + + //delete file from storage + File file = new File(getItem(position).getFilePath()); + if (!file.delete()) { + Toast.makeText(mContext, + String.format(mContext.getString(R.string.toast_file_delete_failed), + getItem(position).getName()), + Toast.LENGTH_LONG).show(); + return; + } + + Toast.makeText( + mContext, + String.format( + mContext.getString(R.string.toast_file_delete), + getItem(position).getName() + ), + Toast.LENGTH_SHORT + ).show(); + + mDatabase.removeItemWithId(getItem(position).getId()); + notifyItemRemoved(position); + } + + //TODO + public void removeOutOfApp(String filePath) { + //user deletes a saved recording out of the application through another application + } + + /** + * rename a file + */ + public void rename(int position, String name) { + final String mFilePath = Paths.combine( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + Paths.SOUND_RECORDER_FOLDER, name); + final File f = new File(mFilePath); + + if (f.exists() && !f.isDirectory()) { + //file name is not unique, cannot rename file. + Toast.makeText(mContext, + String.format(mContext.getString(R.string.toast_file_exists), name), + Toast.LENGTH_LONG).show(); + } else { + //file name is unique, rename file + File oldFilePath = new File(getItem(position).getFilePath()); + if (!oldFilePath.renameTo(f)) { + Toast.makeText(mContext, + String.format(mContext.getString(R.string.toast_file_rename_failed), name), + Toast.LENGTH_LONG).show(); + return; + } + mDatabase.renameItem(getItem(position), name, mFilePath); + notifyItemChanged(position); + } + } + + private void shareFileDialog(int position) { + Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + final Uri uri = FileProvider.getUriForFile(mContext, + BuildConfig.APPLICATION_ID + ".fileprovider", + new File(getItem(position).getFilePath())); + shareIntent.putExtra(Intent.EXTRA_STREAM, uri); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.setType("audio/mp4"); + mContext.startActivity(Intent.createChooser(shareIntent, mContext.getText(R.string.send_to))); + } + + private void renameFileDialog(final int position) { + // File rename dialog + AlertDialog.Builder renameFileBuilder = new AlertDialog.Builder(mContext); + + LayoutInflater inflater = LayoutInflater.from(mContext); + View view = inflater.inflate(R.layout.dialog_rename_file, null); + + final TextInputEditText input = view.findViewById(R.id.new_name); + + renameFileBuilder.setTitle(mContext.getString(R.string.dialog_title_rename)); + renameFileBuilder.setCancelable(true); + renameFileBuilder.setPositiveButton(mContext.getString(R.string.dialog_action_ok), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + try { + final Editable editable = input.getText(); + if (editable == null) + return; + final String value = editable.toString().trim() + ".mp4"; + rename(position, value); + } catch (Exception e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + Log.e(LOG_TAG, "exception", e); + EventBroadcaster.send(mContext, mContext.getString(R.string.error_rename_file)); + } + + dialog.cancel(); + } + }); + renameFileBuilder.setNegativeButton(mContext.getString(R.string.dialog_action_cancel), + new CancelDialogListener()); + + renameFileBuilder.setView(view); + AlertDialog alert = renameFileBuilder.create(); + final Window window = alert.getWindow(); + if (window != null) { + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + alert.show(); + } + + private void deleteFileDialog(final int position) { + // File delete confirm + AlertDialog.Builder confirmDelete = new AlertDialog.Builder(mContext); + confirmDelete.setTitle(mContext.getString(R.string.dialog_title_delete)); + confirmDelete.setMessage(mContext.getString(R.string.dialog_text_delete)); + confirmDelete.setCancelable(true); + confirmDelete.setPositiveButton(mContext.getString(R.string.dialog_action_yes_delete), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + try { + //remove item from database, recyclerview, and storage + remove(position); + } catch (Exception e) { + Log.e(LOG_TAG, "exception", e); + } + + dialog.cancel(); + } + }); + confirmDelete.setNegativeButton(mContext.getString(R.string.dialog_action_no), + new CancelDialogListener()); + + AlertDialog alert = confirmDelete.create(); + alert.show(); + } + + static final class CancelDialogListener implements DialogInterface.OnClickListener { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/FileViewerFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/FileViewerFragment.java new file mode 100644 index 00000000..6372a749 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/fragments/FileViewerFragment.java @@ -0,0 +1,137 @@ +package by.naxa.soundrecorder.fragments; + +import android.os.Bundle; +import android.os.Environment; +import android.os.FileObserver; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.adapters.FileViewerAdapter; +import by.naxa.soundrecorder.util.Paths; + +/** + * Created by Daniel on 12/23/2014. + */ +public class FileViewerFragment extends Fragment { + private static final String LOG_TAG = "FileViewerFragment"; + + private FileViewerAdapter mFileViewerAdapter; + private RecyclerView mRecyclerView; + private TextView mTextView; + private RecyclerView.AdapterDataObserver adapterDataObserver; + + public static FileViewerFragment newInstance() { + FileViewerFragment f = new FileViewerFragment(); + Bundle b = new Bundle(); + f.setArguments(b); + + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + observer.startWatching(); + } + + @Override + public void onDestroy() { + observer.stopWatching(); + super.onDestroy(); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_file_viewer, container, false); + + mRecyclerView = v.findViewById(R.id.recyclerView); + mTextView = v.findViewById(R.id.noRecordView); + + mRecyclerView.setHasFixedSize(true); + //newest to oldest order (database stores from oldest to newest) + final LinearLayoutManager llm = new LinearLayoutManager( + getActivity(), RecyclerView.VERTICAL, true); + llm.setStackFromEnd(true); + + mRecyclerView.setLayoutManager(llm); + mRecyclerView.setItemAnimator(new DefaultItemAnimator()); + + mFileViewerAdapter = new FileViewerAdapter(getActivity(), llm); + mRecyclerView.setAdapter(mFileViewerAdapter); + changeVisibilityRecycleView(); + adapterDataObserver = new RecyclerView.AdapterDataObserver() { + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + changeVisibilityRecycleView(); + super.onItemRangeInserted(positionStart, itemCount); + } + + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + changeVisibilityRecycleView(); + super.onItemRangeRemoved(positionStart, itemCount); + } + }; + + mFileViewerAdapter.registerAdapterDataObserver(adapterDataObserver); + return v; + } + + /** + * Change visibility of RecycleView if no item is present(from {@link FileViewerAdapter}) + * and show TextView with information about no available item. If any item is present, + * visibility for RecycleView is set to visible and for TextView to gone. + */ + private void changeVisibilityRecycleView() { + if (mFileViewerAdapter.getItemCount() == 0) { + mRecyclerView.setVisibility(View.GONE); + mTextView.setVisibility(View.VISIBLE); + } else if(mRecyclerView.getVisibility() != View.VISIBLE) { + mRecyclerView.setVisibility(View.VISIBLE); + mTextView.setVisibility(View.GONE); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mFileViewerAdapter.unregisterAdapterDataObserver(adapterDataObserver); + mFileViewerAdapter = null; + } + + private final FileObserver observer = + new FileObserver(Paths.combine( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + Paths.SOUND_RECORDER_FOLDER)) { + // set up a file observer to watch this directory on sd card + @Override + public void onEvent(int event, String file) { + if (event == FileObserver.DELETE) { + // user deletes a recording file out of the app + final String filePath = Paths.combine( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + Paths.SOUND_RECORDER_FOLDER, file); + Log.d(LOG_TAG, "File deleted [" + filePath + "]"); + + // remove file from database and recyclerview + mFileViewerAdapter.removeOutOfApp(filePath); + } + } + }; +} + + + + diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/LicensesFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/LicensesFragment.java new file mode 100644 index 00000000..19968c1b --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/fragments/LicensesFragment.java @@ -0,0 +1,33 @@ +package by.naxa.soundrecorder.fragments; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDialogFragment; +import by.naxa.soundrecorder.R; + +/** + * Created by Daniel on 1/3/2015. + */ +public class LicensesFragment extends AppCompatDialogFragment { + + @Override + @NonNull + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final LayoutInflater dialogInflater = requireActivity().getLayoutInflater(); + View openSourceLicensesView = dialogInflater.inflate(R.layout.fragment_licenses, null); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()); + dialogBuilder.setView(openSourceLicensesView) + .setTitle((getString(R.string.dialog_title_licenses))) + .setNeutralButton(android.R.string.ok, null); + + return dialogBuilder.create(); + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/PlaybackFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/PlaybackFragment.java new file mode 100644 index 00000000..774c91ce --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/fragments/PlaybackFragment.java @@ -0,0 +1,624 @@ +package by.naxa.soundrecorder.fragments; + +import android.Manifest; +import android.app.Dialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.graphics.ColorFilter; +import android.graphics.LightingColorFilter; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.crashlytics.android.Crashlytics; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import java.io.IOException; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatDialogFragment; +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.RecordingItem; +import by.naxa.soundrecorder.listeners.HeadsetListener; +import by.naxa.soundrecorder.listeners.OnSingleClickListener; +import by.naxa.soundrecorder.util.AudioManagerCompat; +import by.naxa.soundrecorder.util.EventBroadcaster; +import by.naxa.soundrecorder.util.ScreenLock; +import by.naxa.soundrecorder.util.TimeUtils; +import io.fabric.sdk.android.Fabric; + +import static android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY; +import static android.media.AudioManager.ACTION_HEADSET_PLUG; +import static androidx.core.content.ContextCompat.checkSelfPermission; + +/** + * Implementation of a MediaPlayer inside DialogFragment. + * + * System integration is implemented by handling Audio Focus through {@link AudioManager} + * and with a {@link HeadsetListener} -- an implementation of + * {@link BroadcastReceiver} that pauses playback when headphones are disconnected. + * + * Created by Daniel on 1/1/2015. + */ +public class PlaybackFragment extends AppCompatDialogFragment { + + private static final String LOG_TAG = "PlaybackFragment"; + private static final String ARG_ITEM = "recording_item"; + private static final int MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 3; + + private RecordingItem item; + + private Handler mHandler = new Handler(); + private HeadsetListener mHeadsetListener; + + private MediaPlayer mMediaPlayer = null; + + private SeekBar mSeekBar = null; + private FloatingActionButton mPlayButton = null; + private TextView mCurrentProgressTextView = null; + private TextView mFileLengthTextView = null; + + //stores whether or not the mediaplayer is currently playing audio + private volatile boolean isPlaying = false; + + // Stores the length of the file in milliseconds + private long itemDurationMs = 0; + + /** + * Whether this PlaybackFragment has focus from {@link AudioManager} to play audio + * @see #requestAudioFocus() + * @see #abandonAudioFocus() + */ + private boolean mFocused = false; + /** + * Whether playback should continue once {@link AudioManager} returns focus to this PlaybackFragment + */ + private boolean mResumeOnFocusGain = false; + /** + * The volume scalar to set when {@link AudioManager} causes this PlaybackFragment to duck + */ + private static final float DUCK_VOLUME = 0.2f; + + public PlaybackFragment newInstance(RecordingItem item) { + PlaybackFragment f = new PlaybackFragment(); + Bundle b = new Bundle(); + b.putParcelable(ARG_ITEM, item); + f.setArguments(b); + + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Bundle args; + if (savedInstanceState == null) { + // not restart + args = getArguments(); + } else { + // restart + args = savedInstanceState; + } + if (args == null) { + throw new IllegalArgumentException("Bundle args required"); + } + + item = args.getParcelable(ARG_ITEM); + if (item == null) + return; + + itemDurationMs = item.getLength(); + if (Fabric.isInitialized()) { + Crashlytics.setLong("recording_item_duration", itemDurationMs); + Crashlytics.setString("recording_item_name", item.getName()); + Crashlytics.setString("recording_item_file_path", item.getFilePath()); + Crashlytics.setBool("is_playing", isPlaying); + Crashlytics.setBool("holding_audio_focus", mFocused); + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + + Dialog dialog = super.onCreateDialog(savedInstanceState); + + final AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + final View view = requireActivity().getLayoutInflater().inflate(R.layout.fragment_media_playback, null); + + final TextView fileNameTextView = view.findViewById(R.id.file_name_text_view); + mFileLengthTextView = view.findViewById(R.id.file_length_text_view); + mCurrentProgressTextView = view.findViewById(R.id.current_progress_text_view); + + mSeekBar = view.findViewById(R.id.seekbar); + ColorFilter filter = new LightingColorFilter + (getResources().getColor(R.color.primary), getResources().getColor(R.color.primary)); + mSeekBar.getProgressDrawable().setColorFilter(filter); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + mSeekBar.getThumb().setColorFilter(filter); + } + + mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (mMediaPlayer != null && fromUser) { + mMediaPlayer.seekTo(progress); + mHandler.removeCallbacks(mRunnable); + + final int currentPosition = mMediaPlayer.getCurrentPosition(); + mCurrentProgressTextView.setText(TimeUtils.formatDuration(currentPosition)); + updateSeekBar(); + } else if (mMediaPlayer == null && fromUser) { + prepareMediaPlayerFromPoint(progress); + updateSeekBar(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + if (mMediaPlayer != null) { + // remove message Handler from updating progress bar + mHandler.removeCallbacks(mRunnable); + } + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + if (mMediaPlayer != null) { + mHandler.removeCallbacks(mRunnable); + mMediaPlayer.seekTo(seekBar.getProgress()); + + final int currentPosition = mMediaPlayer.getCurrentPosition(); + mCurrentProgressTextView.setText(TimeUtils.formatDuration(currentPosition)); + updateSeekBar(); + } + } + }); + + mPlayButton = view.findViewById(R.id.fab_play); + mPlayButton.setOnClickListener(new OnSingleClickListener() { + @Override + public void onSingleClick(View v) { + isPlaying = onPlay(isPlaying); + if (Fabric.isInitialized()) { + Crashlytics.setBool("is_playing", isPlaying); + } + } + }); + + fileNameTextView.setText(item.getName()); + mFileLengthTextView.setText(TimeUtils.formatDuration(itemDurationMs)); + + builder.setView(view); + + // request a window without the title + final Window window = dialog.getWindow(); + if (window != null) { + window.requestFeature(Window.FEATURE_NO_TITLE); + } + + return builder.create(); + } + + /** + * Attach a HeadsetListener to respond to headset events. + */ + private void attachHeadsetListener(Context context) { + mHeadsetListener = new HeadsetListener(this); + IntentFilter filter = new IntentFilter(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + filter.addAction(ACTION_HEADSET_PLUG); + } + filter.addAction(ACTION_AUDIO_BECOMING_NOISY); + if (context != null) { + context.registerReceiver(mHeadsetListener, filter); + } else { + Log.wtf(LOG_TAG, "attachHeadsetListener(): context is null."); + } + } + + /** + * Detach a HeadsetListener to respond to headset events. + */ + private void detachHeadsetListener() { + final Context context = getContext(); + if (context != null) { + context.unregisterReceiver(mHeadsetListener); + } else { + Log.wtf(LOG_TAG, "detachHeadsetListener(): getContext() returned null."); + } + mHeadsetListener = null; + } + + @Override + public void onStart() { + super.onStart(); + + //set transparent background + Window window = getDialog().getWindow(); + if (window != null) { + window.setBackgroundDrawableResource(android.R.color.transparent); + } + + //disable buttons from dialog + AlertDialog alertDialog = (AlertDialog) getDialog(); + alertDialog.getButton(Dialog.BUTTON_POSITIVE).setEnabled(false); + alertDialog.getButton(Dialog.BUTTON_NEGATIVE).setEnabled(false); + alertDialog.getButton(Dialog.BUTTON_NEUTRAL).setEnabled(false); + } + + @Override + public void onPause() { + super.onPause(); + + if (mMediaPlayer != null) { + stopPlaying(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (mMediaPlayer != null) { + stopPlaying(); + } + } + + /** + * @param context a reference to the newly created Activity after each configuration change. + */ + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (mHeadsetListener == null) { + attachHeadsetListener(context); + } + } + + /** + * Set the MediaPlayer and Headset listener to null so we don't accidentally + * leak the Activity instance. + */ + @Override + public void onDetach() { + super.onDetach(); + if (mHeadsetListener != null) { + detachHeadsetListener(); + } + } + + public void tapStartButton() { + isPlaying = onPlay(false); + if (Fabric.isInitialized()) { + Crashlytics.setBool("is_playing", isPlaying); + } + } + + public void tapStopButton() { + isPlaying = onPlay(true); + if (Fabric.isInitialized()) { + Crashlytics.setBool("is_playing", isPlaying); + } + } + + // Play start/stop + private boolean onPlay(boolean isPlaying) { + if (!isPlaying) { + if (getContext() == null) { + // PlaybackFragment is not attached to a Context + return isPlaying; + } + + if (checkSelfPermission(getContext(), Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + // Permission is not granted -> request the permission + this.requestPermissions( + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, + MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE); + return isPlaying; + } + startOrResumePlaying(); + } else { + //pause the MediaPlayer + pausePlaying(); + abandonAudioFocus(); + } + return !isPlaying; + } + + private void startOrResumePlaying() { + if (mMediaPlayer == null) { + // if currently MediaPlayer is not playing audio + startPlaying(); // from the beginning + } else { + resumePlaying(); + } + } + + private void startPlaying() { + mPlayButton.setImageResource(R.drawable.ic_media_pause); + mMediaPlayer = new MediaPlayer(); + + try { + mMediaPlayer.setDataSource(item.getFilePath()); + mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mMediaPlayer.prepare(); + mSeekBar.setMax(mMediaPlayer.getDuration()); + requestAudioFocus(); + + mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + mMediaPlayer.start(); + } + }); + } catch (IOException e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + Log.e(LOG_TAG, "prepare() failed"); + EventBroadcaster.send(getContext(), R.string.error_prepare_playback); + } + + mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + stopPlaying(); + } + }); + + updateSeekBar(); + ScreenLock.keepScreenOn(getActivity()); + } + + private void prepareMediaPlayerFromPoint(int progress) { + //set mediaPlayer to start from middle of the audio file + + mMediaPlayer = new MediaPlayer(); + + try { + mMediaPlayer.setDataSource(item.getFilePath()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final AudioAttributes attributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .build(); + mMediaPlayer.setAudioAttributes(attributes); + } else { + mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + } + mMediaPlayer.prepare(); + mSeekBar.setMax(mMediaPlayer.getDuration()); + mMediaPlayer.seekTo(progress); + requestAudioFocus(); + + mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mp) { + stopPlaying(); + } + }); + + } catch (IOException e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + Log.e(LOG_TAG, "prepare() failed"); + } + + ScreenLock.keepScreenOn(getActivity()); + } + + /** + * Pauses audio playback + */ + private void pausePlaying() { + Log.i(LOG_TAG, "pausePlaying(), isPlaying = " + isPlaying); + mResumeOnFocusGain = false; + if (!isPlaying) + return; + + mPlayButton.setImageResource(R.drawable.ic_media_play); + mHandler.removeCallbacks(mRunnable); + if (mMediaPlayer != null) { + mMediaPlayer.pause(); + } else { + Log.wtf(LOG_TAG, "mMediaPlayer is null"); + } + } + + /** + * Resumes audio playback + */ + private void resumePlaying() { + Log.i(LOG_TAG, "resumePlaying(), isPlaying = " + isPlaying); + if (isPlaying) + return; + + mPlayButton.setImageResource(R.drawable.ic_media_pause); + mHandler.removeCallbacks(mRunnable); + if (mMediaPlayer != null) { + if (requestAudioFocus()) + mMediaPlayer.start(); + } else { + Log.wtf(LOG_TAG, "mMediaPlayer is null"); + } + updateSeekBar(); + } + + /** + * Stops audio playback + */ + private void stopPlaying() { + Log.i(LOG_TAG, "stopPlaying()"); + if (mMediaPlayer == null) + return; + + mPlayButton.setImageResource(R.drawable.ic_media_play); + mHandler.removeCallbacks(mRunnable); + mMediaPlayer.stop(); + mMediaPlayer.reset(); + mMediaPlayer.release(); + mMediaPlayer = null; + + abandonAudioFocus(); + + isPlaying = false; + if (Fabric.isInitialized()) { + Crashlytics.setBool("is_playing", isPlaying); + } + + mCurrentProgressTextView.setText(mFileLengthTextView.getText()); + mSeekBar.setProgress(mSeekBar.getMax()); + + ScreenLock.allowScreenTurnOff(getActivity()); + } + + /** + * Gain Audio focus from the system if we don't already have it + * @return whether we have gained focus (or already had it) + */ + private boolean requestAudioFocus() { + if (!mFocused && getContext() != null) { + mFocused = AudioManagerCompat.getInstance(getContext()) + .requestAudioFocus(focusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN); + } + if (Fabric.isInitialized()) { + Crashlytics.setBool("holding_audio_focus", mFocused); + Crashlytics.log(Log.INFO, LOG_TAG, mFocused + ? "PlaybackFragment gained audio focus." + : "PlaybackFragment did not gain audio focus."); + } + return mFocused; + } + + private int abandonAudioFocus() { + final int result; + + final Context context = getContext(); + if (context == null) { + Log.wtf(LOG_TAG, "abandonAudioFocus(): getContext() returned null."); + result = AudioManager.AUDIOFOCUS_REQUEST_FAILED; + } else { + result = AudioManagerCompat.getInstance(context).abandonAudioFocus(focusChangeListener); + } + mFocused = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + + if (Fabric.isInitialized()) { + Crashlytics.setBool("holding_audio_focus", mFocused); + Crashlytics.log(Log.INFO, LOG_TAG, mFocused + ? "PlaybackFragment did not abandon audio focus." + : "PlaybackFragment abandoned audio focus."); + } + return result; + } + + private final AudioManager.OnAudioFocusChangeListener focusChangeListener = new AudioManager.OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(int focusChange) { + Log.d(LOG_TAG, "AudioManager.OnAudioFocusChangeListener#onAudioFocusChange " + focusChange); + + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS: + Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS: Pausing playback."); + mFocused = false; + pausePlaying(); + isPlaying = false; + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: Pausing playback."); + boolean resume = isPlaying || mResumeOnFocusGain; + mFocused = false; + pausePlaying(); + isPlaying = false; + mResumeOnFocusGain = resume; + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: Letting system duck."); + } else { + Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: Ducking."); + if (mMediaPlayer != null) { + mMediaPlayer.setVolume(DUCK_VOLUME, DUCK_VOLUME); + } + } + break; + case AudioManager.AUDIOFOCUS_GAIN: + Log.i(LOG_TAG, "AudioManager.AUDIOFOCUS_GAIN: Increasing volume"); + mMediaPlayer.setVolume(1f, 1f); + if (mResumeOnFocusGain) { + resumePlaying(); + isPlaying = true; + } + mResumeOnFocusGain = false; + break; + default: + Log.i(LOG_TAG, "Ignoring AudioFocus state change"); + break; + } + if (Fabric.isInitialized()) { + Crashlytics.setBool("holding_audio_focus", mFocused); + Crashlytics.setBool("is_playing", isPlaying); + } + } + }; + + //updating mSeekBar + private Runnable mRunnable = new Runnable() { + @Override + public void run() { + if (mMediaPlayer != null) { + int currentPosition = mMediaPlayer.getCurrentPosition(); + mSeekBar.setProgress(currentPosition); + mCurrentProgressTextView.setText(TimeUtils.formatDuration(currentPosition)); + updateSeekBar(); + } + } + }; + + private void updateSeekBar() { + mHandler.postDelayed(mRunnable, 1000); + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, + @NonNull int[] grantResults) { + switch (requestCode) { + case MY_PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE: { + // If request is cancelled, the result arrays are empty. + if (grantResults.length > 0 + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // permission was granted, yay! + startOrResumePlaying(); + } else { + EventBroadcaster.send(getContext(), + R.string.error_no_permission_granted_for_playback); + } + break; + } + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(ARG_ITEM, item); + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/RecordFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/RecordFragment.java new file mode 100644 index 00000000..b65c0cff --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/fragments/RecordFragment.java @@ -0,0 +1,455 @@ +package by.naxa.soundrecorder.fragments; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.IBinder; +import android.os.SystemClock; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Chronometer; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.budiyev.android.circularprogressbar.CircularProgressBar; +import com.crashlytics.android.Crashlytics; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import java.io.File; + +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.RecorderState; +import by.naxa.soundrecorder.activities.MainActivity; +import by.naxa.soundrecorder.listeners.OnSingleClickListener; +import by.naxa.soundrecorder.services.RecordingService; +import by.naxa.soundrecorder.util.Command; +import by.naxa.soundrecorder.util.EventBroadcaster; +import by.naxa.soundrecorder.util.MyIntentBuilder; +import by.naxa.soundrecorder.util.Paths; +import by.naxa.soundrecorder.util.PermissionsHelper; +import by.naxa.soundrecorder.util.ScreenLock; + +/** + * A simple {@link Fragment} subclass. + * Activities that contain this fragment must implement the + * to handle interaction events. + * Use the {@link RecordFragment#newInstance} factory method to + * create an instance of this fragment. + */ +public class RecordFragment extends Fragment { + private static final String LOG_TAG = RecordFragment.class.getSimpleName(); + private static final int MY_PERMISSIONS_REQUEST_RECORD_AUDIO = 1; + private static final int MY_PERMISSIONS_REQUEST_RECORD_AUDIO_RESUME = 2; + + //Recording controls + private FloatingActionButton mRecordButton = null; + private MaterialButton mPauseButton = null; + private boolean isRecordButtonInState1 = true; // true = record, false = stop + private boolean isPauseButtonInState1 = true; // true = pause, false = resume + + // ProgressBar around Chronometer + private CircularProgressBar mProgressBar; + + private TextView mRecordingPrompt; + private int mRecordPromptCount = 0; + + private Chronometer mChronometer = null; + long timeWhenPaused = 0; //stores time when user clicks pause button + + // Defines callbacks for service binding, passed to bindService() + private ServiceConnection mConnection; + private RecordingService mRecordingService; + private BroadcastReceiver mMessageReceiver = null; + + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @return A new instance of fragment Record_Fragment. + */ + public static RecordFragment newInstance() { + RecordFragment f = new RecordFragment(); + Bundle b = new Bundle(); + f.setArguments(b); + + return f; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mMessageReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + final RecorderState newState = (RecorderState) intent.getSerializableExtra( + EventBroadcaster.NEW_STATE); + if (RecorderState.STOPPED.equals(newState)) { + updateUI(newState, SystemClock.elapsedRealtime()); + // Recorder was successful and started via a request for audio; set result and finish + if (intent.getStringExtra(EventBroadcaster.LAST_AUDIO_LOCATION) != null && MainActivity.REQUEST_INTENTS.contains(requireActivity().getIntent().getAction())) { + + getActivity().setResult(Activity.RESULT_OK, new Intent().setData(Uri.fromFile(new File(intent.getStringExtra(EventBroadcaster.LAST_AUDIO_LOCATION))))); + getActivity().finish(); + + } + } else if (RecorderState.RECORDING.equals(newState)) { + long chronometerTime = intent.getLongExtra( + EventBroadcaster.CHRONOMETER_TIME, + SystemClock.elapsedRealtime() + ); + updateUI(newState, chronometerTime); + } + } + }; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + final Intent intent = new Intent(context, RecordingService.class); + tryBindService(context, intent); + } + + @Override + public void onDetach() { + super.onDetach(); + tryUnbindService(getContext()); + } + + private void tryBindService(Context context, Intent intent) { + if (mConnection == null) { + mConnection = new ServiceConnection() { + + @Override + public void onServiceConnected(ComponentName className, IBinder service) { + // We've bound to LocalService, cast the IBinder and get LocalService instance + RecordingService.LocalBinder binder = (RecordingService.LocalBinder) service; + mRecordingService = binder.getService(); + Log.i(LOG_TAG, "RecordFragment ServiceConnection#onServiceConnected"); + + long chronometerTime = SystemClock.elapsedRealtime() - mRecordingService.getTotalDurationMillis(); + updateUI(mRecordingService.getState(), chronometerTime); + } + + @Override + public void onServiceDisconnected(ComponentName arg0) { + Log.i(LOG_TAG, "RecordFragment ServiceConnection#onServiceDisconnected"); + mRecordingService = null; + } + }; + + context.bindService(intent, mConnection, Context.BIND_AUTO_CREATE); + } + } + + private void tryUnbindService(Context context) { + if (context == null) { + Log.w(LOG_TAG, "tryUnbindService: context is null"); + } + if (mConnection != null) { + context.unbindService(mConnection); + mConnection = null; + } + } + + @Override + @Nullable + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View recordView = inflater.inflate(R.layout.fragment_record, container, false); + + mChronometer = recordView.findViewById(R.id.chronometer); + //update recording prompt text + mRecordingPrompt = recordView.findViewById(R.id.recording_status_text); + + mRecordButton = recordView.findViewById(R.id.btnRecord); + mRecordButton.setOnClickListener(createRecordButtonClickListener()); + + mProgressBar = recordView.findViewById(R.id.recordProgressBar); + mPauseButton = recordView.findViewById(R.id.btnPause); + mPauseButton.setVisibility(View.GONE); //hide pause button before recording starts + mPauseButton.setOnClickListener(createPauseButtonClickListener()); + + return recordView; + } + + private final Chronometer.OnChronometerTickListener listener = new Chronometer.OnChronometerTickListener() { + @Override + public void onChronometerTick(Chronometer chronometer) { + if (mRecordPromptCount == 0) { + mRecordingPrompt.setText(getString(R.string.record_in_progress) + "."); + } else if (mRecordPromptCount == 1) { + mRecordingPrompt.setText(getString(R.string.record_in_progress) + ".."); + } else if (mRecordPromptCount == 2) { + mRecordingPrompt.setText(getString(R.string.record_in_progress) + "..."); + mRecordPromptCount = -1; + } + + ++mRecordPromptCount; + } + }; + + private View.OnClickListener createPauseButtonClickListener() { + return new OnSingleClickListener() { + @Override + public void onSingleClick(View v) { + if (isPauseButtonInState1) { + // pause recording + mRecordingService.pauseRecording(); + long chronometerTime = SystemClock.elapsedRealtime() - mRecordingService.getTotalDurationMillis(); + updateUI(RecorderState.PAUSED, chronometerTime); + } else { + if (PermissionsHelper.checkAndRequestPermissions( + RecordFragment.this, + MY_PERMISSIONS_REQUEST_RECORD_AUDIO + )) { + resumeRecording(); + } + } + + } + }; + } + + private View.OnClickListener createRecordButtonClickListener() { + return new OnSingleClickListener() { + @Override + public void onSingleClick(View v) { + if (isRecordButtonInState1) { + if (PermissionsHelper.checkAndRequestPermissions( + RecordFragment.this, MY_PERMISSIONS_REQUEST_RECORD_AUDIO)) { + startRecording(); + } + } else { + stopRecording(); + } + } + }; + } + + private Intent getStartServiceIntent() { + return MyIntentBuilder + .getInstance(requireActivity(), RecordingService.class) + .setCommand(Command.START) + .build(); + } + + private Intent getStopServiceIntent() { + return MyIntentBuilder + .getInstance(requireActivity(), RecordingService.class) + .setCommand(Command.STOP) + .build(); + } + + private void updateUI(RecorderState state, long chronometerBaseTime) { + if (getActivity() == null || !isAdded()) { + Log.i(LOG_TAG, "RecordFragment#updateUI: RecordFragment is not attached to an Activity"); + return; + } + Log.i(LOG_TAG, "RecordFragment#updateUI: new state is " + state + ", time is " + chronometerBaseTime + " ms"); + + switch (state) { + case STOPPED: + mRecordButton.show(); + mRecordButton.setImageResource(R.drawable.ic_mic_white_36dp); + mPauseButton.setVisibility(View.GONE); + timeWhenPaused = 0; + mRecordingPrompt.setText(getString(R.string.record_prompt)); + + isPauseButtonInState1 = true; + isRecordButtonInState1 = true; + + mChronometer.setOnChronometerTickListener(null); + mChronometer.setBase(SystemClock.elapsedRealtime()); + mChronometer.stop(); + + mProgressBar.setIndeterminate(false); + break; + + case PREPARING: + mRecordingPrompt.setText(getString(R.string.wait)); + mProgressBar.setIndeterminate(true); + break; + + case RECORDING: + mRecordButton.setImageResource(R.drawable.ic_media_stop); + mPauseButton.setCompoundDrawablesWithIntrinsicBounds + (R.drawable.ic_media_pause, 0, 0, 0); + mPauseButton.setText(getString(R.string.pause_recording_button).toUpperCase()); + mPauseButton.setVisibility(View.VISIBLE); + mRecordingPrompt.setText(getString(R.string.record_in_progress)); + + isPauseButtonInState1 = true; + isRecordButtonInState1 = false; + + mChronometer.setBase(chronometerBaseTime); + mChronometer.setOnChronometerTickListener(listener); + mChronometer.start(); + + mProgressBar.setIndeterminate(true); + break; + + case PAUSED: + mRecordButton.setImageResource(R.drawable.ic_media_stop); + mPauseButton.setCompoundDrawablesWithIntrinsicBounds + (R.drawable.ic_media_play, 0, 0, 0); + mPauseButton.setText(getString(R.string.resume_recording_button).toUpperCase()); + mPauseButton.setVisibility(View.VISIBLE); + mRecordingPrompt.setText(getString(R.string.record_paused)); + + isPauseButtonInState1 = false; + isRecordButtonInState1 = false; + + mChronometer.setOnChronometerTickListener(null); + mChronometer.setBase(chronometerBaseTime); + mChronometer.stop(); + + mProgressBar.setIndeterminate(false); + break; + } + } + + /** + * Stop recording + */ + private void stopRecording() { + final FragmentActivity activity = getActivity(); + if (activity == null) { + Log.wtf(LOG_TAG, "RecordFragment failed to stop recording, getActivity() returns null."); + return; + } + activity.startService(getStopServiceIntent()); + tryUnbindService(activity); + + ScreenLock.allowScreenTurnOff(activity); + } + + /** + * Start recording + */ + private boolean startRecording() { + final File folder = new File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + Paths.SOUND_RECORDER_FOLDER + ); + if (!folder.exists()) { + // a folder for sound recordings doesn't exist -> create the folder + boolean ok = Paths.isExternalStorageWritable() && folder.mkdir(); + if (!ok) { + EventBroadcaster.send(getContext(), R.string.error_mkdir); + return false; + } + } + + final FragmentActivity activity = getActivity(); + if (activity == null) { + Log.wtf(LOG_TAG, "RecordFragment#startRecording(): failed to start recording, getActivity() returns null."); + return false; + } + + // start RecordingService in foreground + final Intent intent = getStartServiceIntent(); + final ComponentName componentName; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + componentName = activity.startForegroundService(intent); + } else { + componentName = activity.startService(intent); + } + tryBindService(activity, intent); + ScreenLock.keepScreenOn(activity); + + if (componentName != null) { + updateUI(RecorderState.PREPARING, SystemClock.elapsedRealtime()); + } + + return true; + } + + /** + * Resume recording + */ + private void resumeRecording() { + long chronometerTime = SystemClock.elapsedRealtime() - mRecordingService.getTotalDurationMillis(); + mRecordingService.startRecording(); + updateUI(RecorderState.PREPARING, chronometerTime); + } + + @Override + public void onResume() { + super.onResume(); + LocalBroadcastManager.getInstance(requireContext()).registerReceiver( + mMessageReceiver, + new IntentFilter(EventBroadcaster.CHANGE_STATE) + ); + } + + @Override + public void onPause() { + super.onPause(); + try { + LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mMessageReceiver); + } catch (Exception exc) { + Crashlytics.logException(exc); + Log.e(LOG_TAG, "Error unregistering MessageReceiver", exc); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, + @NonNull int[] grantResults) { + switch (requestCode) { + case MY_PERMISSIONS_REQUEST_RECORD_AUDIO: { + // If request is cancelled, the result arrays are empty. + if (allPermissionsGranted(grantResults)) { + // permissions were granted, yay! + startRecording(); + } else { + EventBroadcaster.send(getContext(), R.string.error_no_permission_granted_record); + } + break; + } + + case MY_PERMISSIONS_REQUEST_RECORD_AUDIO_RESUME: { + // If request is cancelled, the result arrays are empty. + if (allPermissionsGranted(grantResults)) { + // permission was granted, yay! + resumeRecording(); + } else { + EventBroadcaster.send(getContext(), R.string.error_no_permission_granted_record); + } + break; + } + } + } + + private boolean allPermissionsGranted(int[] grantResults) { + if (grantResults == null || grantResults.length == 0) { + return false; + } + + boolean ok = true; + for (int grantResult : grantResults) { + ok &= (grantResult == PackageManager.PERMISSION_GRANTED); + } + return ok; + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/fragments/SettingsFragment.java b/app/src/main/java/by/naxa/soundrecorder/fragments/SettingsFragment.java new file mode 100644 index 00000000..993aa170 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/fragments/SettingsFragment.java @@ -0,0 +1,80 @@ +package by.naxa.soundrecorder.fragments; + +import android.os.Bundle; +import android.preference.CheckBoxPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.SwitchPreference; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDelegate; + +import by.naxa.soundrecorder.BuildConfig; +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.SoundRecorderApplication; +import by.naxa.soundrecorder.activities.SettingsActivity; +import by.naxa.soundrecorder.util.MySharedPreferences; + +/** + * This fragment shows general preferences. + * Created by Daniel on 5/22/2017. + */ +public class SettingsFragment extends PreferenceFragment { + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.preferences); + + final CheckBoxPreference highQualityPref = (CheckBoxPreference) findPreference( + getResources().getString(R.string.pref_high_quality_key)); + highQualityPref.setChecked(MySharedPreferences.getPrefHighQuality(getActivity())); + highQualityPref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + MySharedPreferences.setPrefHighQuality(getActivity(), (boolean) newValue); + return true; + } + }); + + final Preference aboutPref = findPreference("pref_about"); + aboutPref.setSummary(getString(R.string.pref_about_desc, BuildConfig.VERSION_NAME)); + + final Preference licensesPref = findPreference("pref_licenses"); + licensesPref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + new LicensesFragment().show( + ((SettingsActivity) getActivity()).getSupportFragmentManager() + .beginTransaction(), "dialog_licenses"); + return true; + } + }); + + final SwitchPreference darkModePref = (SwitchPreference) findPreference( + getResources().getString(R.string.pref_dark_mode_key)); + + if (AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES) { + darkModePref.setChecked(true); + } + + darkModePref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + if(darkModePref.isChecked()) { + SoundRecorderApplication.getInstance().setIsNightModeEnabled(false); + Toast.makeText(getActivity(), "Dark Mode is OFF", Toast.LENGTH_SHORT).show(); + darkModePref.setChecked(false); + getActivity().finish(); + } else { + SoundRecorderApplication.getInstance().setIsNightModeEnabled(true); + Toast.makeText(getActivity(), "Dark Mode is ON", Toast.LENGTH_SHORT).show(); + darkModePref.setChecked(true); + getActivity().finish(); + } + return false; + } + }); + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/listeners/HeadsetListener.java b/app/src/main/java/by/naxa/soundrecorder/listeners/HeadsetListener.java new file mode 100644 index 00000000..a5815596 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/listeners/HeadsetListener.java @@ -0,0 +1,61 @@ +package by.naxa.soundrecorder.listeners; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.crashlytics.android.Crashlytics; + +import by.naxa.soundrecorder.fragments.PlaybackFragment; +import io.fabric.sdk.android.Fabric; + +import static android.content.Intent.ACTION_HEADSET_PLUG; +import static android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY; + +/** + * Receives headset connect and disconnect intents so that playback + * may be paused when headset (earphone, headphone, or wireless speaker) + * is disconnected or when audio becomes too noisy. + */ +public class HeadsetListener extends BroadcastReceiver { + + private static final String LOG_TAG = HeadsetListener.class.getName(); + private PlaybackFragment mInstance; + + boolean shouldResumeOnHeadphonesConnect = false; + + public HeadsetListener(PlaybackFragment instance) { + mInstance = instance; + } + + @Override + public void onReceive(Context context, Intent intent) { + if (isInitialStickyBroadcast()) return; + + Log.i(LOG_TAG, "onReceive: " + intent.toString()); + + final boolean plugged, unplugged; + + if (ACTION_HEADSET_PLUG.equals(intent.getAction())) { + plugged = intent.getIntExtra("state", -1) == 1; + unplugged = intent.getIntExtra("state", -1) == 0; + } else { + plugged = unplugged = false; + } + + final boolean becomingNoisy = ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction()); + + if (Fabric.isInitialized()) { + Crashlytics.setBool("plugged", plugged); + Crashlytics.setBool("unplugged", unplugged); + Crashlytics.setBool("becoming_noisy", becomingNoisy); + } + + if (unplugged || becomingNoisy) { + mInstance.tapStopButton(); + } else if (plugged && shouldResumeOnHeadphonesConnect) { + mInstance.tapStartButton(); + } + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/listeners/OnDatabaseChangedListener.java b/app/src/main/java/by/naxa/soundrecorder/listeners/OnDatabaseChangedListener.java similarity index 81% rename from app/src/main/java/com/danielkim/soundrecorder/listeners/OnDatabaseChangedListener.java rename to app/src/main/java/by/naxa/soundrecorder/listeners/OnDatabaseChangedListener.java index 8600190e..d17c96cc 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/listeners/OnDatabaseChangedListener.java +++ b/app/src/main/java/by/naxa/soundrecorder/listeners/OnDatabaseChangedListener.java @@ -1,4 +1,4 @@ -package com.danielkim.soundrecorder.listeners; +package by.naxa.soundrecorder.listeners; /** * Created by Daniel on 1/3/2015. diff --git a/app/src/main/java/by/naxa/soundrecorder/listeners/OnSingleClickListener.java b/app/src/main/java/by/naxa/soundrecorder/listeners/OnSingleClickListener.java new file mode 100644 index 00000000..6e2183cb --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/listeners/OnSingleClickListener.java @@ -0,0 +1,44 @@ +package by.naxa.soundrecorder.listeners; + +import android.os.SystemClock; +import android.view.View; + +/** + * Mis-clicking prevention, using threshold of 600 ms + * Source: https://stackoverflow.com/a/20672997/1429387 + * + * 处理快速在某个控件上双击2次(或多次)会导致onClick被触发2次(或多次)的问题 + * 通过判断2次click事件的时间间隔进行过滤 + *

+ * 子类通过实现{@link #onSingleClick}响应click事件 + */ +public abstract class OnSingleClickListener implements View.OnClickListener { + /** + * 最短click事件的时间间隔 + */ + private static final long MIN_CLICK_INTERVAL = 600; + /** + * 上次click的时间 + */ + private long mLastClickTime; + + /** + * click响应函数 + * + * @param v The view that was clicked. + */ + public abstract void onSingleClick(View v); + + @Override + public final void onClick(View v) { + long currentClickTime = SystemClock.uptimeMillis(); + long elapsedTime = currentClickTime - mLastClickTime; + + if (elapsedTime <= MIN_CLICK_INTERVAL) + return; + mLastClickTime = currentClickTime; + + onSingleClick(v); + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/services/RecordingService.java b/app/src/main/java/by/naxa/soundrecorder/services/RecordingService.java new file mode 100644 index 00000000..73447df0 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/services/RecordingService.java @@ -0,0 +1,410 @@ +package by.naxa.soundrecorder.services; + +import android.app.Service; +import android.content.Intent; +import android.media.MediaRecorder; +import android.os.Binder; +import android.os.Environment; +import android.os.IBinder; +import android.os.SystemClock; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import com.coremedia.iso.boxes.Container; +import com.crashlytics.android.Crashlytics; +import com.googlecode.mp4parser.authoring.Movie; +import com.googlecode.mp4parser.authoring.Track; +import com.googlecode.mp4parser.authoring.builder.DefaultMp4Builder; +import com.googlecode.mp4parser.authoring.container.mp4.MovieCreator; +import com.googlecode.mp4parser.authoring.tracks.AppendTrack; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; + +import by.naxa.soundrecorder.DBHelper; +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.RecorderState; +import by.naxa.soundrecorder.util.Command; +import by.naxa.soundrecorder.util.EventBroadcaster; +import by.naxa.soundrecorder.util.MyIntentBuilder; +import by.naxa.soundrecorder.util.MySharedPreferences; +import by.naxa.soundrecorder.util.NotificationCompatPie; +import by.naxa.soundrecorder.util.Paths; +import io.fabric.sdk.android.Fabric; + +/** + * Created by Daniel on 12/28/2014. + */ +public class RecordingService extends Service { + + private static final String LOG_TAG = "RecordingService"; + + private String mFileName = null; + private String mFilePath = null; + + private MediaRecorder mRecorder = null; + + private DBHelper mDatabase; + + private long mStartingTimeMillis = 0; + private long mElapsedMillis = 0; + + private volatile RecorderState state = RecorderState.STOPPED; + private int tempFileCount = 0; + + private ArrayList filesPaused = new ArrayList<>(); + private ArrayList pauseDurations = new ArrayList<>(); + + // Binder given to clients + private final IBinder mBinder = new LocalBinder(); + + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onCreate() { + super.onCreate(); + mDatabase = new DBHelper(getApplicationContext()); + if (Fabric.isInitialized()) + Crashlytics.setString("recorder_state", state.toString()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + boolean containsCommand = MyIntentBuilder.containsCommand(intent); + Log.d(LOG_TAG, String.format( + "Service in [%s] state. cmdId: [%s]. startId: [%d]", + state, + containsCommand ? MyIntentBuilder.getCommand(intent) : "N/A", + startId)); + routeIntentToCommand(intent); + + // We want this service to continue running until it is explicitly stopped, so return sticky + return START_STICKY; + } + + private void routeIntentToCommand(@Nullable Intent intent) { + if (intent != null) { + // process command + if (MyIntentBuilder.containsCommand(intent)) { + processCommand(MyIntentBuilder.getCommand(intent)); + } + // process message + if (MyIntentBuilder.containsMessage(intent)) { + processMessage(MyIntentBuilder.getMessage(intent)); + } + } + } + + private void processMessage(String message) { + try { + Log.d(LOG_TAG, String.format("doMessage: message from client: '%s'", message)); + // TODO + } catch (Exception e) { + Log.e(LOG_TAG, "processMessage: exception", e); + } + } + + private void processCommand(@Command int command) { + try { + switch (command) { + case Command.START: + startRecording(); + break; + case Command.PAUSE: + pauseRecording(); + break; + case Command.STOP: + stopService(); + break; + } + } catch (Exception e) { + Log.e(LOG_TAG, "processCommand: exception", e); + } + } + + public void stopService() { + Log.d(LOG_TAG, "RecordingService#stopService()"); + stopRecording(); + stopForeground(true); + stopSelf(); + } + + @Override + public void onDestroy() { + if (mRecorder != null) { + stopRecording(); + } + + super.onDestroy(); + } + + public void setFileNameAndPath(boolean isFilePathTemp) { + if (isFilePathTemp) { + mFileName = getString(R.string.default_file_name) + (++tempFileCount) + "_" + ".tmp"; + Paths.createDirectory(getExternalCacheDir(), Paths.SOUND_RECORDER_FOLDER); + mFilePath = Paths.combine( + getExternalCacheDir(), + Paths.SOUND_RECORDER_FOLDER, mFileName); + } else { + int count = 0; + File f; + + do { + ++count; + + mFileName = + getString(R.string.default_file_name) + "_" + (mDatabase.getCount() + count) + ".mp4"; + + mFilePath = Paths.combine( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), + Paths.SOUND_RECORDER_FOLDER, mFileName); + + f = new File(mFilePath); + } while (f.exists() && !f.isDirectory()); + } + } + + /** + * Start or resume sound recording. + */ + public void startRecording() { + if (state == RecorderState.RECORDING || state == RecorderState.PREPARING) + return; + changeStateTo(RecorderState.PREPARING); + + boolean isTemporary = true; + setFileNameAndPath(isTemporary); + + // Configure the MediaRecorder for a new recording + mRecorder = new MediaRecorder(); + mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + mRecorder.setOutputFile(mFilePath); + mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + mRecorder.setAudioChannels(1); + if (MySharedPreferences.getPrefHighQuality(this)) { + mRecorder.setAudioSamplingRate(44100); + mRecorder.setAudioEncodingBitRate(192000); + } + + try { + final long totalDurationMillis = getTotalDurationMillis(); + mRecorder.prepare(); + mRecorder.start(); + if (state != RecorderState.PAUSED) + NotificationCompatPie.createNotification(this); + changeStateTo(RecorderState.RECORDING); + Toast.makeText(this, R.string.toast_recording_start, Toast.LENGTH_SHORT).show(); + mStartingTimeMillis = SystemClock.elapsedRealtime(); + EventBroadcaster.startRecording(this, mStartingTimeMillis - totalDurationMillis); + } catch (IOException e) { + changeStateTo(RecorderState.STOPPED); + EventBroadcaster.stopRecording(this); + if (Fabric.isInitialized()) Crashlytics.logException(e); + Log.e(LOG_TAG, "prepare() failed", e); + EventBroadcaster.send(this, getString(R.string.error_unknown)); + } catch (IllegalStateException e) { + changeStateTo(RecorderState.STOPPED); + EventBroadcaster.stopRecording(this); + if (Fabric.isInitialized()) Crashlytics.logException(e); + Log.e(LOG_TAG, "start() failed", e); + EventBroadcaster.send(this, getString(R.string.error_mic_is_busy)); + } + } + + public void pauseRecording() { + if (state != RecorderState.RECORDING) + return; + changeStateTo(RecorderState.PREPARING); + + try { + mElapsedMillis = (SystemClock.elapsedRealtime() - mStartingTimeMillis); + pauseDurations.add(mElapsedMillis); + mRecorder.stop(); + changeStateTo(RecorderState.PAUSED); + Toast.makeText(this, getString(R.string.toast_recording_paused), Toast.LENGTH_LONG).show(); + + filesPaused.add(mFilePath); + } catch (IllegalStateException exc) { + changeStateTo(RecorderState.RECORDING); + Crashlytics.logException(exc); + Log.e(LOG_TAG, "stop() failed", exc); + } + } + + public void stopRecording() { + if (state == RecorderState.STOPPED) { + Log.wtf(LOG_TAG, "stopRecording: already STOPPED."); + return; + } + if (state == RecorderState.PREPARING) + return; + final RecorderState stateBefore = state; + changeStateTo(RecorderState.PREPARING); + if (stateBefore == RecorderState.RECORDING) + filesPaused.add(mFilePath); + + boolean isTemporary = false; + setFileNameAndPath(isTemporary); + String pathToSend = ""; + try { + if (stateBefore != RecorderState.PAUSED) { + mElapsedMillis = (SystemClock.elapsedRealtime() - mStartingTimeMillis); + mRecorder.stop(); + } + mRecorder.release(); + pathToSend = mFilePath; + Toast.makeText(this, getString(R.string.toast_recording_finish) + " " + mFilePath, Toast.LENGTH_LONG).show(); + } catch (RuntimeException exc) { + // RuntimeException is thrown when stop() is called immediately after start(). + // In this case the output file is not properly constructed ans should be deleted. + Log.e(LOG_TAG, "RuntimeException: stop() is called immediately after start()", exc); + pathToSend = null; + Crashlytics.logException(exc); + // TODO delete temporary output file + } finally { + mRecorder = null; + changeStateTo(RecorderState.STOPPED); + EventBroadcaster.stopRecording(this, pathToSend); + } + + if (filesPaused != null && !filesPaused.isEmpty()) { + if (makeSingleFile(filesPaused)) { + for (long duration : pauseDurations) + mElapsedMillis += duration; + } + } + + try { + mDatabase.addRecording(mFileName, mFilePath, mElapsedMillis); + } catch (Exception e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + Log.e(LOG_TAG, "exception", e); + } + } + + /** + * collect temp generated files because of pause to one target file + * + * @param filesPaused contains all temp files due to pause + */ + private boolean makeSingleFile(ArrayList filesPaused) { + ArrayList tracks = new ArrayList<>(); + Movie finalMovie = new Movie(); + for (String filePath : filesPaused) { + try { + Movie movie = MovieCreator.build(filePath); + List movieTracks = movie.getTracks(); + tracks.addAll(movieTracks); + } catch (IOException e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + e.printStackTrace(); + return false; + } catch (NullPointerException exc) { + Crashlytics.logException(exc); + Log.wtf(LOG_TAG, "Caught NPE from MovieCreator#build()"); + } + } + + if (tracks.size() > 0) { + try { + finalMovie.addTrack(new AppendTrack(tracks.toArray(new Track[0]))); + } catch (IOException e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + e.printStackTrace(); + } + } + + final Container mp4file; + final FileChannel fc; + try { + mp4file = new DefaultMp4Builder().build(finalMovie); + fc = new FileOutputStream(new File(mFilePath)).getChannel(); + } catch (NoSuchElementException exc) { + Crashlytics.logException(exc); + Log.wtf(LOG_TAG, "Caught NoSuchElementException from DefaultMp4Builder#build()", exc); + return false; + } catch (FileNotFoundException e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + e.printStackTrace(); + return false; + } + + boolean ok = true; + try { + mp4file.writeContainer(fc); + } catch (IOException e) { + if (Fabric.isInitialized()) Crashlytics.logException(e); + e.printStackTrace(); + ok = false; + } finally { + try { + fc.close(); + } catch (IOException exc) { + Crashlytics.logException(exc); + exc.printStackTrace(); + ok = false; + } + } + + return ok; + } + + public long getElapsedMillis() { + return mElapsedMillis; + } + + public long getStartingTimeMillis() { + return mStartingTimeMillis; + } + + public long getTotalDurationMillis() { + long total = 0; + if (pauseDurations == null || pauseDurations.isEmpty()) { + total += mElapsedMillis; + } else { + for (long duration : pauseDurations) + total += duration; + } + if (state == RecorderState.RECORDING) { + total += (SystemClock.elapsedRealtime() - mStartingTimeMillis); + } + + return total; + } + + public RecorderState getState() { + return state; + } + + private void changeStateTo(RecorderState newState) { + if (state == RecorderState.PREPARING && newState == RecorderState.PREPARING) + throw new IllegalStateException(); + state = newState; + if (Fabric.isDebuggable()) + Crashlytics.setString("recorder_state", state.toString()); + } + + /** + * Class used for the client Binder. Because we know this service always + * runs in the same process as its clients, we don't need to deal with IPC. + */ + public class LocalBinder extends Binder { + public RecordingService getService() { + // Return this instance of LocalService so clients can call public methods + return RecordingService.this; + } + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/AudioManagerCompat.java b/app/src/main/java/by/naxa/soundrecorder/util/AudioManagerCompat.java new file mode 100644 index 00000000..a3847c58 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/AudioManagerCompat.java @@ -0,0 +1,54 @@ +package by.naxa.soundrecorder.util; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.AudioManager.OnAudioFocusChangeListener; +import android.os.Build; + +import static android.content.Context.AUDIO_SERVICE; + +public class AudioManagerCompat { + + private AudioManager mAudioManager; + + public static AudioManagerCompat getInstance(Context context) { + return new AudioManagerCompat((AudioManager) context.getSystemService(AUDIO_SERVICE)); + } + + private AudioManagerCompat(AudioManager audioManager) { + mAudioManager = audioManager; + } + + public boolean requestAudioFocus(OnAudioFocusChangeListener focusChangeListener, + int streamType, int audioFocusGain) { + int r; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + r = mAudioManager.requestAudioFocus( + new AudioFocusRequest.Builder(audioFocusGain) + .setAudioAttributes( + new AudioAttributes.Builder() + .setLegacyStreamType(streamType) + .build()) + .setOnAudioFocusChangeListener(focusChangeListener) + .build()); + } else { + r = mAudioManager.requestAudioFocus(focusChangeListener, streamType, audioFocusGain); + } + + return r == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + + public int abandonAudioFocus(OnAudioFocusChangeListener focusChangeListener) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return mAudioManager.abandonAudioFocusRequest( + new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setOnAudioFocusChangeListener(focusChangeListener) + .build()); + } else { + return mAudioManager.abandonAudioFocus(focusChangeListener); + } + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/Command.java b/app/src/main/java/by/naxa/soundrecorder/util/Command.java new file mode 100644 index 00000000..b90f483c --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/Command.java @@ -0,0 +1,17 @@ +package by.naxa.soundrecorder.util; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import androidx.annotation.IntDef; + +// Command enumeration +// more info - https://blog.shamanland.com/2016/02/int-string-enum.html +@IntDef({Command.INVALID, Command.START, Command.PAUSE, Command.STOP}) +@Retention(RetentionPolicy.SOURCE) +public @interface Command { + int INVALID = -1; + int START = 0; + int PAUSE = 1; + int STOP = 2; +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/EventBroadcaster.java b/app/src/main/java/by/naxa/soundrecorder/util/EventBroadcaster.java new file mode 100644 index 00000000..504bbc65 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/EventBroadcaster.java @@ -0,0 +1,58 @@ +package by.naxa.soundrecorder.util; + +import android.content.Context; +import android.content.Intent; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import by.naxa.soundrecorder.RecorderState; + +public class EventBroadcaster { + public static final String SHOW_SNACKBAR = "SHOW_SNACKBAR"; + public static final String MESSAGE = "MESSAGE"; + public static final String CHANGE_STATE = "CHANGE_STATE"; + public static final String NEW_STATE = "NEW_STATE"; + public static final String CHRONOMETER_TIME = "CHRONOMETER_TIME"; + public static final String LAST_AUDIO_LOCATION = "LAST_AUDIO_LOCATION"; + + public static void send(@NonNull Context context, String message) { + final Intent it = new Intent(EventBroadcaster.SHOW_SNACKBAR); + if (!TextUtils.isEmpty(message)) + it.putExtra(EventBroadcaster.MESSAGE, message); + LocalBroadcastManager.getInstance(context).sendBroadcast(it); + } + + public static void send(@Nullable Context context, int stringId) { + if (context == null) + return; + send(context, context.getString(stringId)); + } + + public static void stopRecording(@Nullable Context context) { + if (context == null) + return; + final Intent it = new Intent(EventBroadcaster.CHANGE_STATE); + it.putExtra(EventBroadcaster.NEW_STATE, RecorderState.STOPPED); + LocalBroadcastManager.getInstance(context).sendBroadcast(it); + } + + public static void stopRecording(@Nullable Context context,String filePath) { + if (context == null) + return; + final Intent it = new Intent(EventBroadcaster.CHANGE_STATE); + it.putExtra(EventBroadcaster.NEW_STATE, RecorderState.STOPPED); + it.putExtra(EventBroadcaster.LAST_AUDIO_LOCATION, filePath); + LocalBroadcastManager.getInstance(context).sendBroadcast(it); + } + + public static void startRecording(@Nullable Context context, long chronometerTime) { + if (context == null) + return; + final Intent it = new Intent(EventBroadcaster.CHANGE_STATE); + it.putExtra(EventBroadcaster.NEW_STATE, RecorderState.RECORDING); + it.putExtra(EventBroadcaster.CHRONOMETER_TIME, chronometerTime); + LocalBroadcastManager.getInstance(context).sendBroadcast(it); + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/MyIntentBuilder.java b/app/src/main/java/by/naxa/soundrecorder/util/MyIntentBuilder.java new file mode 100644 index 00000000..52dd3956 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/MyIntentBuilder.java @@ -0,0 +1,76 @@ +package by.naxa.soundrecorder.util; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; + +public class MyIntentBuilder { + + private static final String KEY_MESSAGE = "msg"; + private static final String KEY_COMMAND = "cmd"; + private final Context mContext; + private final Class serviceClass; + private String mMessage; + private @Command + int mCommandId = Command.INVALID; + + public static MyIntentBuilder getInstance(@NonNull Context context, + @NonNull Class service) { + return new MyIntentBuilder(context, service); + } + + private MyIntentBuilder(@NonNull Context context, + @NonNull Class serviceClass) { + this.mContext = context; + this.serviceClass = serviceClass; + } + + public MyIntentBuilder setMessage(String message) { + this.mMessage = message; + return this; + } + + /** + * @param command Don't use {@link Command#INVALID} as a param. If you do then this method does + * nothing. + */ + public MyIntentBuilder setCommand(@Command int command) { + this.mCommandId = command; + return this; + } + + public Intent build() { + Intent intent = new Intent(mContext, serviceClass); + if (mCommandId != Command.INVALID) { + intent.putExtra(KEY_COMMAND, mCommandId); + } + if (mMessage != null) { + intent.putExtra(KEY_MESSAGE, mMessage); + } + return intent; + } + + public static boolean containsCommand(Intent intent) { + final Bundle extras = intent.getExtras(); + return extras != null && extras.containsKey(KEY_COMMAND); + } + + public static boolean containsMessage(Intent intent) { + final Bundle extras = intent.getExtras(); + return extras != null && extras.containsKey(KEY_MESSAGE); + } + + @Command + public static int getCommand(Intent intent) { + final @Command int commandId = intent.getExtras().getInt(KEY_COMMAND); + return commandId; + } + + public static String getMessage(Intent intent) { + return intent.getExtras().getString(KEY_MESSAGE); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/danielkim/soundrecorder/MySharedPreferences.java b/app/src/main/java/by/naxa/soundrecorder/util/MySharedPreferences.java similarity index 95% rename from app/src/main/java/com/danielkim/soundrecorder/MySharedPreferences.java rename to app/src/main/java/by/naxa/soundrecorder/util/MySharedPreferences.java index 08e9e537..26f2972b 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/MySharedPreferences.java +++ b/app/src/main/java/by/naxa/soundrecorder/util/MySharedPreferences.java @@ -1,4 +1,4 @@ -package com.danielkim.soundrecorder; +package by.naxa.soundrecorder.util; import android.content.Context; import android.content.SharedPreferences; diff --git a/app/src/main/java/by/naxa/soundrecorder/util/NotificationCompatPie.java b/app/src/main/java/by/naxa/soundrecorder/util/NotificationCompatPie.java new file mode 100644 index 00000000..c23323a4 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/NotificationCompatPie.java @@ -0,0 +1,167 @@ +package by.naxa.soundrecorder.util; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; + +import java.util.Random; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import by.naxa.soundrecorder.R; +import by.naxa.soundrecorder.activities.MainActivity; +import by.naxa.soundrecorder.services.RecordingService; + +public class NotificationCompatPie { + private static final Random r = new Random(); + + public static final String CHANNEL_ID = "13"; + private static final int ONGOING_NOTIFICATION_ID = r.nextInt(1000); + + public static void createNotification(Service context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + final String channelId = createChannel(context); + final Notification notification = buildNotification(context, channelId); + context.startForeground(ONGOING_NOTIFICATION_ID, notification); + } else { + createNotificationPreN(context); + } + } + + private static void createNotificationPreN(Service context) { + // Create Pending Intents. + PendingIntent piLaunchMainActivity = getLaunchActivityPI(context); + PendingIntent piStopService = getStopServicePI(context); + PendingIntent piPauseService = getPauseServicePI(context); + PendingIntent piResumeService = getResumeServicePI(context); + + // Action to pause the service. + NotificationCompat.Action pauseAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_media_pause, + context.getString(R.string.pause_recording_button), + piPauseService) + .build(); + + // Action to resume the service. + NotificationCompat.Action resumeAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_media_play, + context.getString(R.string.resume_recording_button), + piResumeService) + .build(); + + // Action to stop the service. + NotificationCompat.Action stopAction = + new NotificationCompat.Action.Builder( + R.drawable.ic_media_stop, + context.getString(R.string.action_stop), + piStopService) + .build(); + + // Create a notification. + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setContentTitle(context.getString(R.string.notification_title)) + .setSmallIcon(R.drawable.ic_mic_white_36dp) + .setContentIntent(piLaunchMainActivity) + .addAction(stopAction) + .setStyle(new NotificationCompat.BigTextStyle()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + builder.setColor(context.getColor(R.color.primary)); + } + + context.startForeground(ONGOING_NOTIFICATION_ID, builder.build()); + } + + private static PendingIntent getServicePI(Service context, @Command int cmd) { + final Intent iService = MyIntentBuilder.getInstance(context, RecordingService.class) + .setCommand(cmd).build(); + return PendingIntent.getService(context, r.nextInt(100), iService, 0); + } + + private static PendingIntent getStopServicePI(Service context) { + return getServicePI(context, Command.STOP); + } + + private static PendingIntent getPauseServicePI(Service context) { + return getServicePI(context, Command.PAUSE); + } + + private static PendingIntent getResumeServicePI(Service context) { + return getServicePI(context, Command.START); + } + + private static PendingIntent getLaunchActivityPI(Service context) { + final Intent iLaunchActivity = new Intent(context, MainActivity.class); + return PendingIntent.getActivity(context, r.nextInt(100), iLaunchActivity, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static Notification buildNotification(Service context, String channelId) { + // Create Pending Intents. + PendingIntent piLaunchMainActivity = getLaunchActivityPI(context); + PendingIntent piStopService = getStopServicePI(context); + PendingIntent piPauseService = getPauseServicePI(context); + PendingIntent piResumeService = getResumeServicePI(context); + + // Action to pause the service. + Notification.Action pauseAction = + new Notification.Action.Builder( + R.drawable.ic_media_pause, + context.getString(R.string.pause_recording_button), + piPauseService) + .build(); + + // Action to resume the service. + Notification.Action resumeAction = + new Notification.Action.Builder( + R.drawable.ic_media_play, + context.getString(R.string.resume_recording_button), + piResumeService) + .build(); + + // Action to stop the service. + Notification.Action stopAction = + new Notification.Action.Builder( + R.drawable.ic_media_stop, + context.getString(R.string.action_stop), + piStopService) + .build(); + + // Create a notification. + return new Notification.Builder(context, channelId) + .setContentTitle(context.getString(R.string.notification_title)) + .setSmallIcon(R.drawable.ic_mic_white_36dp) + .setColor(context.getColor(R.color.primary)) + .setContentIntent(piLaunchMainActivity) + .setActions(stopAction) + .setStyle(new Notification.BigTextStyle()) + .build(); + } + + /** + * Create a notification channel. + * But only on API 26+ because the {@link NotificationChannel} class is new + * and not in the support library. + */ + @NonNull + @RequiresApi(api = Build.VERSION_CODES.O) + private static String createChannel(Service ctx) { + final String channelName = ctx.getString(R.string.notification_channel_recorder); + final NotificationChannel notificationChannel = + new NotificationChannel(CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW); + notificationChannel.setDescription(ctx.getString(R.string.notification_channel_description)); + + // Register the channel with the system; + // you can't change the importance or other notification behaviours after this + final NotificationManager notificationManager = ctx.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(notificationChannel); + return CHANNEL_ID; + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/Paths.java b/app/src/main/java/by/naxa/soundrecorder/util/Paths.java new file mode 100644 index 00000000..01d47727 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/Paths.java @@ -0,0 +1,52 @@ +package by.naxa.soundrecorder.util; + +import android.os.Environment; + +import java.io.File; + +public class Paths { + + public static final String SOUND_RECORDER_FOLDER = "/SoundRecorder.by"; + + public static String combine(String parent, String... children) { + return combine(new File(parent), children); + } + + public static String combine(File parent, String... children) { + File path = parent; + for (String child : children) { + path = new File(path, child); + } + return path.toString(); + } + + /** + * Checks if external storage is available for read and write. + */ + public static boolean isExternalStorageWritable() { + return (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())); + } + + /** + * Checks if external storage is available to at least read. + */ + public static boolean isExternalStorageReadable() { + String state = Environment.getExternalStorageState(); + return Environment.MEDIA_MOUNTED.equals(state) || + Environment.MEDIA_MOUNTED_READ_ONLY.equals(state); + } + + public static double getFreeStorageSpacePercent() { + long freespace = Environment.getExternalStorageDirectory().getFreeSpace(); + long totalspace = Environment.getExternalStorageDirectory().getTotalSpace(); + return (freespace * 100.0) / (double) totalspace; + } + + public static void createDirectory(File parent, String... children){ + String cacheFileString = combine(parent, children); + File cacheFile = new File(cacheFileString); + if(!cacheFile.exists()){ + cacheFile.mkdirs(); + } + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/PermissionsHelper.java b/app/src/main/java/by/naxa/soundrecorder/util/PermissionsHelper.java new file mode 100644 index 00000000..9afeb124 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/PermissionsHelper.java @@ -0,0 +1,39 @@ +package by.naxa.soundrecorder.util; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; + +import java.util.ArrayList; +import java.util.List; + +import androidx.fragment.app.Fragment; + +import static androidx.core.content.ContextCompat.checkSelfPermission; + +public class PermissionsHelper { + public static boolean checkAndRequestPermissions(Fragment fragment, int permissionsRequestId) { + final Context context = fragment.getContext(); + if (context == null) { + return false; + } + int permissionRecordAudio = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO); + int permissionWriteStorage = checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE); + List listPermissionsNeeded = new ArrayList<>(); + if (permissionRecordAudio != PackageManager.PERMISSION_GRANTED) { + listPermissionsNeeded.add(Manifest.permission.RECORD_AUDIO); + } + if (permissionWriteStorage != PackageManager.PERMISSION_GRANTED) { + listPermissionsNeeded.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); + } + if (!listPermissionsNeeded.isEmpty()) { + // Some permission(s) is/are not granted -> request the permission(s) + fragment.requestPermissions( + listPermissionsNeeded.toArray(new String[listPermissionsNeeded.size()]), + permissionsRequestId); + return false; + } + + return true; + } +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/ScreenLock.java b/app/src/main/java/by/naxa/soundrecorder/util/ScreenLock.java new file mode 100644 index 00000000..3fdc1503 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/ScreenLock.java @@ -0,0 +1,28 @@ +package by.naxa.soundrecorder.util; + +import android.app.Activity; +import android.view.WindowManager; + +public class ScreenLock { + + /** + * Keep the screen on while playing or recording audio + * @param activity + */ + public static void keepScreenOn(Activity activity) { + if (activity != null) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + + /** + * Allow the screen to turn off again once audio is finished playing or recording is stopped + * @param activity + */ + public static void allowScreenTurnOff(Activity activity) { + if (activity != null) { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + +} diff --git a/app/src/main/java/by/naxa/soundrecorder/util/TimeUtils.java b/app/src/main/java/by/naxa/soundrecorder/util/TimeUtils.java new file mode 100644 index 00000000..64415442 --- /dev/null +++ b/app/src/main/java/by/naxa/soundrecorder/util/TimeUtils.java @@ -0,0 +1,28 @@ +package by.naxa.soundrecorder.util; + +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +public class TimeUtils { + + public static String formatDuration(long millis) { + final String sign; + if (millis < 0) { + sign = "-"; + millis = -millis; + } else { + sign = ""; + } + + long hours = TimeUnit.MILLISECONDS.toHours(millis); + long minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours); + long seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.HOURS.toSeconds(hours) + - TimeUnit.MINUTES.toSeconds(minutes); + if (hours > 0) { + return String.format(Locale.ENGLISH, sign + "%d:%02d:%02d", hours, minutes, seconds); + } else { + return String.format(Locale.ENGLISH, sign + "%02d:%02d", minutes, seconds); + } + } + +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java b/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java deleted file mode 100644 index a8b36a18..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.danielkim.soundrecorder; - -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.media.MediaRecorder; -import android.os.Environment; -import android.os.IBinder; -import android.preference.PreferenceManager; -import android.support.v4.app.NotificationCompat; -import android.util.Log; -import android.widget.Toast; - -import com.danielkim.soundrecorder.activities.MainActivity; - -import java.io.File; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Locale; -import java.util.Timer; -import java.util.TimerTask; - -/** - * Created by Daniel on 12/28/2014. - */ -public class RecordingService extends Service { - - private static final String LOG_TAG = "RecordingService"; - - private String mFileName = null; - private String mFilePath = null; - - private MediaRecorder mRecorder = null; - - private DBHelper mDatabase; - - private long mStartingTimeMillis = 0; - private long mElapsedMillis = 0; - private int mElapsedSeconds = 0; - private OnTimerChangedListener onTimerChangedListener = null; - private static final SimpleDateFormat mTimerFormat = new SimpleDateFormat("mm:ss", Locale.getDefault()); - - private Timer mTimer = null; - private TimerTask mIncrementTimerTask = null; - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - public interface OnTimerChangedListener { - void onTimerChanged(int seconds); - } - - @Override - public void onCreate() { - super.onCreate(); - mDatabase = new DBHelper(getApplicationContext()); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - startRecording(); - return START_STICKY; - } - - @Override - public void onDestroy() { - if (mRecorder != null) { - stopRecording(); - } - - super.onDestroy(); - } - - public void startRecording() { - setFileNameAndPath(); - - mRecorder = new MediaRecorder(); - mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); - mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - mRecorder.setOutputFile(mFilePath); - mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - mRecorder.setAudioChannels(1); - if (MySharedPreferences.getPrefHighQuality(this)) { - mRecorder.setAudioSamplingRate(44100); - mRecorder.setAudioEncodingBitRate(192000); - } - - try { - mRecorder.prepare(); - mRecorder.start(); - mStartingTimeMillis = System.currentTimeMillis(); - - //startTimer(); - //startForeground(1, createNotification()); - - } catch (IOException e) { - Log.e(LOG_TAG, "prepare() failed"); - } - } - - public void setFileNameAndPath(){ - int count = 0; - File f; - - do{ - count++; - - mFileName = getString(R.string.default_file_name) - + "_" + (mDatabase.getCount() + count) + ".mp4"; - mFilePath = Environment.getExternalStorageDirectory().getAbsolutePath(); - mFilePath += "/SoundRecorder/" + mFileName; - - f = new File(mFilePath); - }while (f.exists() && !f.isDirectory()); - } - - public void stopRecording() { - mRecorder.stop(); - mElapsedMillis = (System.currentTimeMillis() - mStartingTimeMillis); - mRecorder.release(); - Toast.makeText(this, getString(R.string.toast_recording_finish) + " " + mFilePath, Toast.LENGTH_LONG).show(); - - //remove notification - if (mIncrementTimerTask != null) { - mIncrementTimerTask.cancel(); - mIncrementTimerTask = null; - } - - mRecorder = null; - - try { - mDatabase.addRecording(mFileName, mFilePath, mElapsedMillis); - - } catch (Exception e){ - Log.e(LOG_TAG, "exception", e); - } - } - - private void startTimer() { - mTimer = new Timer(); - mIncrementTimerTask = new TimerTask() { - @Override - public void run() { - mElapsedSeconds++; - if (onTimerChangedListener != null) - onTimerChangedListener.onTimerChanged(mElapsedSeconds); - NotificationManager mgr = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - mgr.notify(1, createNotification()); - } - }; - mTimer.scheduleAtFixedRate(mIncrementTimerTask, 1000, 1000); - } - - //TODO: - private Notification createNotification() { - NotificationCompat.Builder mBuilder = - new NotificationCompat.Builder(getApplicationContext()) - .setSmallIcon(R.drawable.ic_mic_white_36dp) - .setContentTitle(getString(R.string.notification_recording)) - .setContentText(mTimerFormat.format(mElapsedSeconds * 1000)) - .setOngoing(true); - - mBuilder.setContentIntent(PendingIntent.getActivities(getApplicationContext(), 0, - new Intent[]{new Intent(getApplicationContext(), MainActivity.class)}, 0)); - - return mBuilder.build(); - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java b/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java deleted file mode 100644 index a2e6bde7..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.danielkim.soundrecorder.activities; - -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.support.v4.app.Fragment; -import android.support.v4.app.FragmentManager; -import android.support.v4.app.FragmentPagerAdapter; -import android.support.v4.view.ViewPager; -import android.support.v7.app.ActionBarActivity; -import android.support.v7.widget.Toolbar; -import android.view.Menu; -import android.view.MenuItem; - -import com.astuetz.PagerSlidingTabStrip; -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.fragments.FileViewerFragment; -import com.danielkim.soundrecorder.fragments.LicensesFragment; -import com.danielkim.soundrecorder.fragments.RecordFragment; - - -public class MainActivity extends ActionBarActivity{ - - private static final String LOG_TAG = MainActivity.class.getSimpleName(); - - private PagerSlidingTabStrip tabs; - private ViewPager pager; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - pager = (ViewPager) findViewById(R.id.pager); - pager.setAdapter(new MyAdapter(getSupportFragmentManager())); - tabs = (PagerSlidingTabStrip) findViewById(R.id.tabs); - tabs.setViewPager(pager); - - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - toolbar.setPopupTheme(R.style.ThemeOverlay_AppCompat_Light); - if (toolbar != null) { - setSupportActionBar(toolbar); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.menu_main, menu); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Handle action bar item clicks here. The action bar will - // automatically handle clicks on the Home/Up button, so long - // as you specify a parent activity in AndroidManifest.xml. - // Handle presses on the action bar items - switch (item.getItemId()) { - case R.id.action_settings: - Intent i = new Intent(this, SettingsActivity.class); - startActivity(i); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public class MyAdapter extends FragmentPagerAdapter { - private String[] titles = { getString(R.string.tab_title_record), - getString(R.string.tab_title_saved_recordings) }; - - public MyAdapter(FragmentManager fm) { - super(fm); - } - - @Override - public Fragment getItem(int position) { - switch(position){ - case 0:{ - return RecordFragment.newInstance(position); - } - case 1:{ - return FileViewerFragment.newInstance(position); - } - } - return null; - } - - @Override - public int getCount() { - return titles.length; - } - - @Override - public CharSequence getPageTitle(int position) { - return titles[position]; - } - } - - public MainActivity() { - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java b/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java deleted file mode 100644 index fbb3bfef..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.danielkim.soundrecorder.activities; - -import android.app.Activity; -import android.os.Bundle; -import android.os.PersistableBundle; -import android.preference.PreferenceActivity; -import android.support.annotation.Nullable; -import android.support.v4.app.ActivityCompat; -import android.support.v7.app.ActionBar; -import android.support.v7.widget.Toolbar; - -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.fragments.SettingsFragment; - -/** - * Created by Daniel on 5/22/2017. - */ - -public class SettingsActivity extends android.support.v7.app.ActionBarActivity { - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_preferences); - - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - toolbar.setPopupTheme(R.style.ThemeOverlay_AppCompat_Light); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(R.string.action_settings); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowHomeEnabled(true); - } - - getFragmentManager() - .beginTransaction() - .replace(R.id.container, new SettingsFragment()) - .commit(); - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java b/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java deleted file mode 100644 index 1d1418cf..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java +++ /dev/null @@ -1,308 +0,0 @@ -package com.danielkim.soundrecorder.adapters; - -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.os.Environment; -import android.support.v4.app.FragmentActivity; -import android.support.v4.app.FragmentTransaction; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.TextView; -import android.widget.Toast; -import android.text.format.DateUtils; - -import com.danielkim.soundrecorder.DBHelper; -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.RecordingItem; -import com.danielkim.soundrecorder.fragments.PlaybackFragment; -import com.danielkim.soundrecorder.listeners.OnDatabaseChangedListener; - -import java.io.File; -import java.util.Locale; -import java.util.concurrent.TimeUnit; -import java.util.ArrayList; - -/** - * Created by Daniel on 12/29/2014. - */ -public class FileViewerAdapter extends RecyclerView.Adapter - implements OnDatabaseChangedListener{ - - private static final String LOG_TAG = "FileViewerAdapter"; - - private DBHelper mDatabase; - - RecordingItem item; - Context mContext; - LinearLayoutManager llm; - - public FileViewerAdapter(Context context, LinearLayoutManager linearLayoutManager) { - super(); - mContext = context; - mDatabase = new DBHelper(mContext); - mDatabase.setOnDatabaseChangedListener(this); - llm = linearLayoutManager; - } - - @Override - public void onBindViewHolder(final RecordingsViewHolder holder, int position) { - - item = getItem(position); - long itemDuration = item.getLength(); - - long minutes = TimeUnit.MILLISECONDS.toMinutes(itemDuration); - long seconds = TimeUnit.MILLISECONDS.toSeconds(itemDuration) - - TimeUnit.MINUTES.toSeconds(minutes); - - holder.vName.setText(item.getName()); - holder.vLength.setText(String.format("%02d:%02d", minutes, seconds)); - holder.vDateAdded.setText( - DateUtils.formatDateTime( - mContext, - item.getTime(), - DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_YEAR - ) - ); - - // define an on click listener to open PlaybackFragment - holder.cardView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - try { - PlaybackFragment playbackFragment = - new PlaybackFragment().newInstance(getItem(holder.getPosition())); - - FragmentTransaction transaction = ((FragmentActivity) mContext) - .getSupportFragmentManager() - .beginTransaction(); - - playbackFragment.show(transaction, "dialog_playback"); - - } catch (Exception e) { - Log.e(LOG_TAG, "exception", e); - } - } - }); - - holder.cardView.setOnLongClickListener(new View.OnLongClickListener() { - @Override - public boolean onLongClick(View v) { - - ArrayList entrys = new ArrayList(); - entrys.add(mContext.getString(R.string.dialog_file_share)); - entrys.add(mContext.getString(R.string.dialog_file_rename)); - entrys.add(mContext.getString(R.string.dialog_file_delete)); - - final CharSequence[] items = entrys.toArray(new CharSequence[entrys.size()]); - - - // File delete confirm - AlertDialog.Builder builder = new AlertDialog.Builder(mContext); - builder.setTitle(mContext.getString(R.string.dialog_title_options)); - builder.setItems(items, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int item) { - if (item == 0) { - shareFileDialog(holder.getPosition()); - } if (item == 1) { - renameFileDialog(holder.getPosition()); - } else if (item == 2) { - deleteFileDialog(holder.getPosition()); - } - } - }); - builder.setCancelable(true); - builder.setNegativeButton(mContext.getString(R.string.dialog_action_cancel), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - } - }); - - AlertDialog alert = builder.create(); - alert.show(); - - return false; - } - }); - } - - @Override - public RecordingsViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - - View itemView = LayoutInflater. - from(parent.getContext()). - inflate(R.layout.card_view, parent, false); - - mContext = parent.getContext(); - - return new RecordingsViewHolder(itemView); - } - - public static class RecordingsViewHolder extends RecyclerView.ViewHolder { - protected TextView vName; - protected TextView vLength; - protected TextView vDateAdded; - protected View cardView; - - public RecordingsViewHolder(View v) { - super(v); - vName = (TextView) v.findViewById(R.id.file_name_text); - vLength = (TextView) v.findViewById(R.id.file_length_text); - vDateAdded = (TextView) v.findViewById(R.id.file_date_added_text); - cardView = v.findViewById(R.id.card_view); - } - } - - @Override - public int getItemCount() { - return mDatabase.getCount(); - } - - public RecordingItem getItem(int position) { - return mDatabase.getItemAt(position); - } - - @Override - public void onNewDatabaseEntryAdded() { - //item added to top of the list - notifyItemInserted(getItemCount() - 1); - llm.scrollToPosition(getItemCount() - 1); - } - - @Override - //TODO - public void onDatabaseEntryRenamed() { - - } - - public void remove(int position) { - //remove item from database, recyclerview and storage - - //delete file from storage - File file = new File(getItem(position).getFilePath()); - file.delete(); - - Toast.makeText( - mContext, - String.format( - mContext.getString(R.string.toast_file_delete), - getItem(position).getName() - ), - Toast.LENGTH_SHORT - ).show(); - - mDatabase.removeItemWithId(getItem(position).getId()); - notifyItemRemoved(position); - } - - //TODO - public void removeOutOfApp(String filePath) { - //user deletes a saved recording out of the application through another application - } - - public void rename(int position, String name) { - //rename a file - - String mFilePath = Environment.getExternalStorageDirectory().getAbsolutePath(); - mFilePath += "/SoundRecorder/" + name; - File f = new File(mFilePath); - - if (f.exists() && !f.isDirectory()) { - //file name is not unique, cannot rename file. - Toast.makeText(mContext, - String.format(mContext.getString(R.string.toast_file_exists), name), - Toast.LENGTH_SHORT).show(); - - } else { - //file name is unique, rename file - File oldFilePath = new File(getItem(position).getFilePath()); - oldFilePath.renameTo(f); - mDatabase.renameItem(getItem(position), name, mFilePath); - notifyItemChanged(position); - } - } - - public void shareFileDialog(int position) { - Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(new File(getItem(position).getFilePath()))); - shareIntent.setType("audio/mp4"); - mContext.startActivity(Intent.createChooser(shareIntent, mContext.getText(R.string.send_to))); - } - - public void renameFileDialog (final int position) { - // File rename dialog - AlertDialog.Builder renameFileBuilder = new AlertDialog.Builder(mContext); - - LayoutInflater inflater = LayoutInflater.from(mContext); - View view = inflater.inflate(R.layout.dialog_rename_file, null); - - final EditText input = (EditText) view.findViewById(R.id.new_name); - - renameFileBuilder.setTitle(mContext.getString(R.string.dialog_title_rename)); - renameFileBuilder.setCancelable(true); - renameFileBuilder.setPositiveButton(mContext.getString(R.string.dialog_action_ok), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - try { - String value = input.getText().toString().trim() + ".mp4"; - rename(position, value); - - } catch (Exception e) { - Log.e(LOG_TAG, "exception", e); - } - - dialog.cancel(); - } - }); - renameFileBuilder.setNegativeButton(mContext.getString(R.string.dialog_action_cancel), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - } - }); - - renameFileBuilder.setView(view); - AlertDialog alert = renameFileBuilder.create(); - alert.show(); - } - - public void deleteFileDialog (final int position) { - // File delete confirm - AlertDialog.Builder confirmDelete = new AlertDialog.Builder(mContext); - confirmDelete.setTitle(mContext.getString(R.string.dialog_title_delete)); - confirmDelete.setMessage(mContext.getString(R.string.dialog_text_delete)); - confirmDelete.setCancelable(true); - confirmDelete.setPositiveButton(mContext.getString(R.string.dialog_action_yes), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - try { - //remove item from database, recyclerview, and storage - remove(position); - - } catch (Exception e) { - Log.e(LOG_TAG, "exception", e); - } - - dialog.cancel(); - } - }); - confirmDelete.setNegativeButton(mContext.getString(R.string.dialog_action_no), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - } - }); - - AlertDialog alert = confirmDelete.create(); - alert.show(); - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java deleted file mode 100644 index bf29e7f0..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.danielkim.soundrecorder.fragments; - -import android.os.Bundle; -import android.os.FileObserver; -import android.support.v4.app.Fragment; -import android.support.v7.widget.DefaultItemAnimator; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.adapters.FileViewerAdapter; - -/** - * Created by Daniel on 12/23/2014. - */ -public class FileViewerFragment extends Fragment{ - private static final String ARG_POSITION = "position"; - private static final String LOG_TAG = "FileViewerFragment"; - - private int position; - private FileViewerAdapter mFileViewerAdapter; - - public static FileViewerFragment newInstance(int position) { - FileViewerFragment f = new FileViewerFragment(); - Bundle b = new Bundle(); - b.putInt(ARG_POSITION, position); - f.setArguments(b); - - return f; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - position = getArguments().getInt(ARG_POSITION); - observer.startWatching(); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View v = inflater.inflate(R.layout.fragment_file_viewer, container, false); - - RecyclerView mRecyclerView = (RecyclerView) v.findViewById(R.id.recyclerView); - mRecyclerView.setHasFixedSize(true); - LinearLayoutManager llm = new LinearLayoutManager(getActivity()); - llm.setOrientation(LinearLayoutManager.VERTICAL); - - //newest to oldest order (database stores from oldest to newest) - llm.setReverseLayout(true); - llm.setStackFromEnd(true); - - mRecyclerView.setLayoutManager(llm); - mRecyclerView.setItemAnimator(new DefaultItemAnimator()); - - mFileViewerAdapter = new FileViewerAdapter(getActivity(), llm); - mRecyclerView.setAdapter(mFileViewerAdapter); - - return v; - } - - FileObserver observer = - new FileObserver(android.os.Environment.getExternalStorageDirectory().toString() - + "/SoundRecorder") { - // set up a file observer to watch this directory on sd card - @Override - public void onEvent(int event, String file) { - if(event == FileObserver.DELETE){ - // user deletes a recording file out of the app - - String filePath = android.os.Environment.getExternalStorageDirectory().toString() - + "/SoundRecorder" + file + "]"; - - Log.d(LOG_TAG, "File deleted [" - + android.os.Environment.getExternalStorageDirectory().toString() - + "/SoundRecorder" + file + "]"); - - // remove file from database and recyclerview - mFileViewerAdapter.removeOutOfApp(filePath); - } - } - }; -} - - - - diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java deleted file mode 100644 index a93dbded..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.danielkim.soundrecorder.fragments; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.os.Bundle; -import android.support.v4.app.DialogFragment; -import android.view.LayoutInflater; -import android.view.View; - -import com.danielkim.soundrecorder.R; - -/** - * Created by Daniel on 1/3/2015. - */ -public class LicensesFragment extends DialogFragment { - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - LayoutInflater dialogInflater = getActivity().getLayoutInflater(); - View openSourceLicensesView = dialogInflater.inflate(R.layout.fragment_licenses, null); - - AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity()); - dialogBuilder.setView(openSourceLicensesView) - .setTitle((getString(R.string.dialog_title_licenses))) - .setNeutralButton(android.R.string.ok, null); - - return dialogBuilder.create(); - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java deleted file mode 100644 index 09f12135..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java +++ /dev/null @@ -1,320 +0,0 @@ -package com.danielkim.soundrecorder.fragments; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.graphics.ColorFilter; -import android.graphics.LightingColorFilter; -import android.media.MediaPlayer; -import android.os.Bundle; -import android.os.Handler; -import android.support.annotation.NonNull; -import android.support.v4.app.DialogFragment; -import android.util.Log; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.widget.SeekBar; -import android.widget.TextView; - -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.RecordingItem; -import com.melnykov.fab.FloatingActionButton; - -import java.io.IOException; -import java.util.concurrent.TimeUnit; - -/** - * Created by Daniel on 1/1/2015. - */ -public class PlaybackFragment extends DialogFragment{ - - private static final String LOG_TAG = "PlaybackFragment"; - - private static final String ARG_ITEM = "recording_item"; - private RecordingItem item; - - private Handler mHandler = new Handler(); - - private MediaPlayer mMediaPlayer = null; - - private SeekBar mSeekBar = null; - private FloatingActionButton mPlayButton = null; - private TextView mCurrentProgressTextView = null; - private TextView mFileNameTextView = null; - private TextView mFileLengthTextView = null; - - //stores whether or not the mediaplayer is currently playing audio - private boolean isPlaying = false; - - //stores minutes and seconds of the length of the file. - long minutes = 0; - long seconds = 0; - - public PlaybackFragment newInstance(RecordingItem item) { - PlaybackFragment f = new PlaybackFragment(); - Bundle b = new Bundle(); - b.putParcelable(ARG_ITEM, item); - f.setArguments(b); - - return f; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - item = getArguments().getParcelable(ARG_ITEM); - - long itemDuration = item.getLength(); - minutes = TimeUnit.MILLISECONDS.toMinutes(itemDuration); - seconds = TimeUnit.MILLISECONDS.toSeconds(itemDuration) - - TimeUnit.MINUTES.toSeconds(minutes); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - - Dialog dialog = super.onCreateDialog(savedInstanceState); - - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - View view = getActivity().getLayoutInflater().inflate(R.layout.fragment_media_playback, null); - - mFileNameTextView = (TextView) view.findViewById(R.id.file_name_text_view); - mFileLengthTextView = (TextView) view.findViewById(R.id.file_length_text_view); - mCurrentProgressTextView = (TextView) view.findViewById(R.id.current_progress_text_view); - - mSeekBar = (SeekBar) view.findViewById(R.id.seekbar); - ColorFilter filter = new LightingColorFilter - (getResources().getColor(R.color.primary), getResources().getColor(R.color.primary)); - mSeekBar.getProgressDrawable().setColorFilter(filter); - mSeekBar.getThumb().setColorFilter(filter); - - mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if(mMediaPlayer != null && fromUser) { - mMediaPlayer.seekTo(progress); - mHandler.removeCallbacks(mRunnable); - - long minutes = TimeUnit.MILLISECONDS.toMinutes(mMediaPlayer.getCurrentPosition()); - long seconds = TimeUnit.MILLISECONDS.toSeconds(mMediaPlayer.getCurrentPosition()) - - TimeUnit.MINUTES.toSeconds(minutes); - mCurrentProgressTextView.setText(String.format("%02d:%02d", minutes,seconds)); - - updateSeekBar(); - - } else if (mMediaPlayer == null && fromUser) { - prepareMediaPlayerFromPoint(progress); - updateSeekBar(); - } - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - if(mMediaPlayer != null) { - // remove message Handler from updating progress bar - mHandler.removeCallbacks(mRunnable); - } - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - if (mMediaPlayer != null) { - mHandler.removeCallbacks(mRunnable); - mMediaPlayer.seekTo(seekBar.getProgress()); - - long minutes = TimeUnit.MILLISECONDS.toMinutes(mMediaPlayer.getCurrentPosition()); - long seconds = TimeUnit.MILLISECONDS.toSeconds(mMediaPlayer.getCurrentPosition()) - - TimeUnit.MINUTES.toSeconds(minutes); - mCurrentProgressTextView.setText(String.format("%02d:%02d", minutes,seconds)); - updateSeekBar(); - } - } - }); - - mPlayButton = (FloatingActionButton) view.findViewById(R.id.fab_play); - mPlayButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onPlay(isPlaying); - isPlaying = !isPlaying; - } - }); - - mFileNameTextView.setText(item.getName()); - mFileLengthTextView.setText(String.format("%02d:%02d", minutes,seconds)); - - builder.setView(view); - - // request a window without the title - dialog.getWindow().requestFeature(Window.FEATURE_NO_TITLE); - - return builder.create(); -} - - @Override - public void onStart() { - super.onStart(); - - //set transparent background - Window window = getDialog().getWindow(); - window.setBackgroundDrawableResource(android.R.color.transparent); - - //disable buttons from dialog - AlertDialog alertDialog = (AlertDialog) getDialog(); - alertDialog.getButton(Dialog.BUTTON_POSITIVE).setEnabled(false); - alertDialog.getButton(Dialog.BUTTON_NEGATIVE).setEnabled(false); - alertDialog.getButton(Dialog.BUTTON_NEUTRAL).setEnabled(false); - } - - @Override - public void onPause() { - super.onPause(); - - if (mMediaPlayer != null) { - stopPlaying(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (mMediaPlayer != null) { - stopPlaying(); - } - } - - // Play start/stop - private void onPlay(boolean isPlaying){ - if (!isPlaying) { - //currently MediaPlayer is not playing audio - if(mMediaPlayer == null) { - startPlaying(); //start from beginning - } else { - resumePlaying(); //resume the currently paused MediaPlayer - } - - } else { - //pause the MediaPlayer - pausePlaying(); - } - } - - private void startPlaying() { - mPlayButton.setImageResource(R.drawable.ic_media_pause); - mMediaPlayer = new MediaPlayer(); - - try { - mMediaPlayer.setDataSource(item.getFilePath()); - mMediaPlayer.prepare(); - mSeekBar.setMax(mMediaPlayer.getDuration()); - - mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { - @Override - public void onPrepared(MediaPlayer mp) { - mMediaPlayer.start(); - } - }); - } catch (IOException e) { - Log.e(LOG_TAG, "prepare() failed"); - } - - mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - stopPlaying(); - } - }); - - updateSeekBar(); - - //keep screen on while playing audio - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - private void prepareMediaPlayerFromPoint(int progress) { - //set mediaPlayer to start from middle of the audio file - - mMediaPlayer = new MediaPlayer(); - - try { - mMediaPlayer.setDataSource(item.getFilePath()); - mMediaPlayer.prepare(); - mSeekBar.setMax(mMediaPlayer.getDuration()); - mMediaPlayer.seekTo(progress); - - mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - stopPlaying(); - } - }); - - } catch (IOException e) { - Log.e(LOG_TAG, "prepare() failed"); - } - - //keep screen on while playing audio - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - private void pausePlaying() { - mPlayButton.setImageResource(R.drawable.ic_media_play); - mHandler.removeCallbacks(mRunnable); - mMediaPlayer.pause(); - } - - private void resumePlaying() { - mPlayButton.setImageResource(R.drawable.ic_media_pause); - mHandler.removeCallbacks(mRunnable); - mMediaPlayer.start(); - updateSeekBar(); - } - - private void stopPlaying() { - mPlayButton.setImageResource(R.drawable.ic_media_play); - mHandler.removeCallbacks(mRunnable); - mMediaPlayer.stop(); - mMediaPlayer.reset(); - mMediaPlayer.release(); - mMediaPlayer = null; - - mSeekBar.setProgress(mSeekBar.getMax()); - isPlaying = !isPlaying; - - mCurrentProgressTextView.setText(mFileLengthTextView.getText()); - mSeekBar.setProgress(mSeekBar.getMax()); - - //allow the screen to turn off again once audio is finished playing - getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - //updating mSeekBar - private Runnable mRunnable = new Runnable() { - @Override - public void run() { - if(mMediaPlayer != null){ - - int mCurrentPosition = mMediaPlayer.getCurrentPosition(); - mSeekBar.setProgress(mCurrentPosition); - - long minutes = TimeUnit.MILLISECONDS.toMinutes(mCurrentPosition); - long seconds = TimeUnit.MILLISECONDS.toSeconds(mCurrentPosition) - - TimeUnit.MINUTES.toSeconds(minutes); - mCurrentProgressTextView.setText(String.format("%02d:%02d", minutes, seconds)); - - updateSeekBar(); - } - } - }; - - private void updateSeekBar() { - mHandler.postDelayed(mRunnable, 1000); - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java deleted file mode 100644 index 151822c0..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.danielkim.soundrecorder.fragments; - -import android.content.Intent; -import android.os.Bundle; -import android.os.Environment; -import android.os.SystemClock; -import android.support.v4.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.Chronometer; -import android.widget.TextView; -import android.widget.Toast; - -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.RecordingService; -import com.melnykov.fab.FloatingActionButton; - -import java.io.File; - -/** - * A simple {@link Fragment} subclass. - * Activities that contain this fragment must implement the - * to handle interaction events. - * Use the {@link RecordFragment#newInstance} factory method to - * create an instance of this fragment. - */ -public class RecordFragment extends Fragment { - // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER - private static final String ARG_POSITION = "position"; - private static final String LOG_TAG = RecordFragment.class.getSimpleName(); - - private int position; - - //Recording controls - private FloatingActionButton mRecordButton = null; - private Button mPauseButton = null; - - private TextView mRecordingPrompt; - private int mRecordPromptCount = 0; - - private boolean mStartRecording = true; - private boolean mPauseRecording = true; - - private Chronometer mChronometer = null; - long timeWhenPaused = 0; //stores time when user clicks pause button - - /** - * Use this factory method to create a new instance of - * this fragment using the provided parameters. - * - * @return A new instance of fragment Record_Fragment. - */ - public static RecordFragment newInstance(int position) { - RecordFragment f = new RecordFragment(); - Bundle b = new Bundle(); - b.putInt(ARG_POSITION, position); - f.setArguments(b); - - return f; - } - - public RecordFragment() { - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - position = getArguments().getInt(ARG_POSITION); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - View recordView = inflater.inflate(R.layout.fragment_record, container, false); - - mChronometer = (Chronometer) recordView.findViewById(R.id.chronometer); - //update recording prompt text - mRecordingPrompt = (TextView) recordView.findViewById(R.id.recording_status_text); - - mRecordButton = (FloatingActionButton) recordView.findViewById(R.id.btnRecord); - mRecordButton.setColorNormal(getResources().getColor(R.color.primary)); - mRecordButton.setColorPressed(getResources().getColor(R.color.primary_dark)); - mRecordButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onRecord(mStartRecording); - mStartRecording = !mStartRecording; - } - }); - - mPauseButton = (Button) recordView.findViewById(R.id.btnPause); - mPauseButton.setVisibility(View.GONE); //hide pause button before recording starts - mPauseButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - onPauseRecord(mPauseRecording); - mPauseRecording = !mPauseRecording; - } - }); - - return recordView; - } - - // Recording Start/Stop - //TODO: recording pause - private void onRecord(boolean start){ - - Intent intent = new Intent(getActivity(), RecordingService.class); - - if (start) { - // start recording - mRecordButton.setImageResource(R.drawable.ic_media_stop); - //mPauseButton.setVisibility(View.VISIBLE); - Toast.makeText(getActivity(),R.string.toast_recording_start,Toast.LENGTH_SHORT).show(); - File folder = new File(Environment.getExternalStorageDirectory() + "/SoundRecorder"); - if (!folder.exists()) { - //folder /SoundRecorder doesn't exist, create the folder - folder.mkdir(); - } - - //start Chronometer - mChronometer.setBase(SystemClock.elapsedRealtime()); - mChronometer.start(); - mChronometer.setOnChronometerTickListener(new Chronometer.OnChronometerTickListener() { - @Override - public void onChronometerTick(Chronometer chronometer) { - if (mRecordPromptCount == 0) { - mRecordingPrompt.setText(getString(R.string.record_in_progress) + "."); - } else if (mRecordPromptCount == 1) { - mRecordingPrompt.setText(getString(R.string.record_in_progress) + ".."); - } else if (mRecordPromptCount == 2) { - mRecordingPrompt.setText(getString(R.string.record_in_progress) + "..."); - mRecordPromptCount = -1; - } - - mRecordPromptCount++; - } - }); - - //start RecordingService - getActivity().startService(intent); - //keep screen on while recording - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - - mRecordingPrompt.setText(getString(R.string.record_in_progress) + "."); - mRecordPromptCount++; - - } else { - //stop recording - mRecordButton.setImageResource(R.drawable.ic_mic_white_36dp); - //mPauseButton.setVisibility(View.GONE); - mChronometer.stop(); - mChronometer.setBase(SystemClock.elapsedRealtime()); - timeWhenPaused = 0; - mRecordingPrompt.setText(getString(R.string.record_prompt)); - - getActivity().stopService(intent); - //allow the screen to turn off again once recording is finished - getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - } - - //TODO: implement pause recording - private void onPauseRecord(boolean pause) { - if (pause) { - //pause recording - mPauseButton.setCompoundDrawablesWithIntrinsicBounds - (R.drawable.ic_media_play ,0 ,0 ,0); - mRecordingPrompt.setText((String)getString(R.string.resume_recording_button).toUpperCase()); - timeWhenPaused = mChronometer.getBase() - SystemClock.elapsedRealtime(); - mChronometer.stop(); - } else { - //resume recording - mPauseButton.setCompoundDrawablesWithIntrinsicBounds - (R.drawable.ic_media_pause ,0 ,0 ,0); - mRecordingPrompt.setText((String)getString(R.string.pause_recording_button).toUpperCase()); - mChronometer.setBase(SystemClock.elapsedRealtime() + timeWhenPaused); - mChronometer.start(); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java deleted file mode 100644 index fc0d47f0..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.danielkim.soundrecorder.fragments; - -import android.os.Bundle; -import android.preference.CheckBoxPreference; -import android.preference.Preference; -import android.preference.PreferenceFragment; -import android.support.annotation.Nullable; - -import com.danielkim.soundrecorder.BuildConfig; -import com.danielkim.soundrecorder.MySharedPreferences; -import com.danielkim.soundrecorder.R; -import com.danielkim.soundrecorder.activities.SettingsActivity; - -/** - * Created by Daniel on 5/22/2017. - */ - -public class SettingsFragment extends PreferenceFragment { - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.preferences); - - CheckBoxPreference highQualityPref = (CheckBoxPreference) findPreference(getResources().getString(R.string.pref_high_quality_key)); - highQualityPref.setChecked(MySharedPreferences.getPrefHighQuality(getActivity())); - highQualityPref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - MySharedPreferences.setPrefHighQuality(getActivity(), (boolean) newValue); - return true; - } - }); - - Preference aboutPref = findPreference(getString(R.string.pref_about_key)); - aboutPref.setSummary(getString(R.string.pref_about_desc, BuildConfig.VERSION_NAME)); - aboutPref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - LicensesFragment licensesFragment = new LicensesFragment(); - licensesFragment.show(((SettingsActivity)getActivity()).getSupportFragmentManager().beginTransaction(), "dialog_licenses"); - return true; - } - }); - } -} diff --git a/app/src/main/res/drawable-hdpi/ic_launcher.png b/app/src/main/res/drawable-hdpi/ic_launcher.png deleted file mode 100644 index f8cabda3..00000000 Binary files a/app/src/main/res/drawable-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png deleted file mode 100644 index a15bc69e..00000000 Binary files a/app/src/main/res/drawable-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..d1656ce4 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png deleted file mode 100644 index 9566bea4..00000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxhdpi/ic_launcher.png deleted file mode 100644 index 9e9e43e2..00000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher.png deleted file mode 100644 index 69446e3e..00000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/drawable/dots.xml b/app/src/main/res/drawable/dots.xml new file mode 100644 index 00000000..fbc01c82 --- /dev/null +++ b/app/src/main/res/drawable/dots.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/record_progress_bar.xml b/app/src/main/res/drawable/record_progress_bar.xml deleted file mode 100644 index 59892fd7..00000000 --- a/app/src/main/res/drawable/record_progress_bar.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/record_progress_bar_background.xml b/app/src/main/res/drawable/record_progress_bar_background.xml deleted file mode 100644 index fb9906b8..00000000 --- a/app/src/main/res/drawable/record_progress_bar_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-land/fragment_record.xml b/app/src/main/res/layout-land/fragment_record.xml new file mode 100644 index 00000000..01109a5a --- /dev/null +++ b/app/src/main/res/layout-land/fragment_record.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 9fd19e4f..db1de91f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,38 +1,53 @@ - + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".activities.MainActivity"> - - - + android:layout_height="wrap_content" + android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" + app:elevation="0dp"> - + - + - + + + + + + + + diff --git a/app/src/main/res/layout/activity_preferences.xml b/app/src/main/res/layout/activity_preferences.xml index 8082627b..b6e92499 100644 --- a/app/src/main/res/layout/activity_preferences.xml +++ b/app/src/main/res/layout/activity_preferences.xml @@ -1,9 +1,12 @@ + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" > + + layout="@layout/toolbar" /> + \ No newline at end of file diff --git a/app/src/main/res/layout/card_view.xml b/app/src/main/res/layout/card_view.xml index 0ec48c46..03e42ab0 100644 --- a/app/src/main/res/layout/card_view.xml +++ b/app/src/main/res/layout/card_view.xml @@ -1,68 +1,75 @@ - - + + + android:layout_height="match_parent" + android:orientation="horizontal"> + + + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:orientation="vertical"> - + android:fontFamily="sans-serif-condensed" + android:text="file_name" + android:textSize="15sp" + android:textStyle="bold" /> - - - - - - - - + android:layout_marginTop="7dp" + android:fontFamily="sans-serif-condensed" + android:text="00:00" + android:textSize="12sp" /> + - + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/dialog_rename_file.xml b/app/src/main/res/layout/dialog_rename_file.xml index d92ac782..af73b96f 100644 --- a/app/src/main/res/layout/dialog_rename_file.xml +++ b/app/src/main/res/layout/dialog_rename_file.xml @@ -1,18 +1,26 @@ - + android:inputType="text" + android:focusable="true" + android:focusableInTouchMode="true" + android:focusedByDefault="true" + android:maxLines="1" /> diff --git a/app/src/main/res/layout/fragment_file_viewer.xml b/app/src/main/res/layout/fragment_file_viewer.xml index e1b6acf4..08e61d53 100644 --- a/app/src/main/res/layout/fragment_file_viewer.xml +++ b/app/src/main/res/layout/fragment_file_viewer.xml @@ -3,11 +3,22 @@ android:id="@+id/fragment_file_viewer" android:layout_width="fill_parent" android:layout_height="fill_parent" - tools:context="com.danielkim.soundrecorder.fragments.FileViewerFragment"> + tools:context=".fragments.FileViewerFragment"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_licenses.xml b/app/src/main/res/layout/fragment_licenses.xml index 40945993..186e2d42 100644 --- a/app/src/main/res/layout/fragment_licenses.xml +++ b/app/src/main/res/layout/fragment_licenses.xml @@ -1,70 +1,160 @@ - + android:layout_marginTop="10dp" + tools:context=".fragments.LicensesFragment"> - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + android:orientation="vertical" + android:padding="10dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_media_playback.xml b/app/src/main/res/layout/fragment_media_playback.xml index f1a82d7e..3905adec 100644 --- a/app/src/main/res/layout/fragment_media_playback.xml +++ b/app/src/main/res/layout/fragment_media_playback.xml @@ -1,85 +1,79 @@ - + xmlns:tools="http://schemas.android.com/tools" - + + + android:layout_margin="7dp" + android:orientation="vertical"> - + android:layout_marginStart="10dp" + android:layout_marginLeft="10dp" + android:layout_marginTop="7dp" + android:layout_marginBottom="7dp" + android:fontFamily="sans-serif-condensed" + android:text="file_name.mp4" + android:textSize="18sp" /> + + + + - - - - + android:text="00:00" /> - - - - - - + - + - + - + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_record.xml b/app/src/main/res/layout/fragment_record.xml index f5f774bb..0e9419be 100644 --- a/app/src/main/res/layout/fragment_record.xml +++ b/app/src/main/res/layout/fragment_record.xml @@ -1,71 +1,87 @@ + android:layout_height="fill_parent" + android:paddingBottom="?attr/actionBarSize" + tools:context=".fragments.RecordFragment"> - + app:backgroundTint="@color/primary" + app:elevation="6dp" + app:fabSize="normal" + app:srcCompat="@drawable/ic_mic_white_36dp" /> + android:layout_marginBottom="57dp" + android:fontFamily="sans-serif-light" + android:textSize="60sp" /> - + android:layout_centerHorizontal="true" + app:animateProgress="true" + app:backgroundStrokeColor="@color/white" + app:backgroundStrokeWidth="5dp" + app:drawBackgroundStroke="true" + app:foregroundStrokeCap="round" + app:foregroundStrokeColor="@color/primary" + app:foregroundStrokeWidth="2.5dp" + app:indeterminateMinimumAngle="45" + app:indeterminateRotationAnimationDuration="60000" + app:indeterminateSweepAnimationDuration="1000" + app:progressAnimationDuration="100" + app:startAngle="270" /> -