diff --git a/.gitignore b/.gitignore index afbdab33..a63d7458 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,39 @@ -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties +keystore.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +/.idea/ +.idea/workspace.xml + +# Android Studio +*.iml \ No newline at end of file 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/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 2a7f5bfc..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file 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 index b0a270f5..75dac502 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,8 +1,5 @@ - - - - + 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/SoundRecorder.iml b/SoundRecorder.iml index 40b8fc4a..c9d55a16 100644 --- a/SoundRecorder.iml +++ b/SoundRecorder.iml @@ -8,7 +8,7 @@ - + diff --git a/app/app.iml b/app/app.iml index 1cdca55a..90b62e96 100644 --- a/app/app.iml +++ b/app/app.iml @@ -22,25 +22,26 @@ - + + - + - + @@ -55,6 +56,13 @@ + + + + + + + @@ -78,43 +86,120 @@ + - + - - - + - - + + - + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index ae3d3494..6f67dcf4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,31 +1,90 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 21 - buildToolsVersion '25.0.0' + compileSdkVersion 25 + buildToolsVersion '26.0.2' defaultConfig { applicationId "com.danielkim.soundrecorder" - minSdkVersion 16 - targetSdkVersion 21 - versionCode 130 - versionName "1.3.0" + minSdkVersion 18 + targetSdkVersion 25 + versionCode 10 + versionName "1.4" + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } + + android.defaultConfig.vectorDrawables.useSupportLibrary = true + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } - lintOptions{ + lintOptions { disable 'MissingTranslation' } + + // Timeout for installing the app on the device + adbOptions { + timeOutInMs = 30 * 1000 + } + + // Added for Robolectric compatibility. + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +ext { + libraryVersion = '25.3.1' + daggerVersion = '2.11-rc2' } 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.android.support:appcompat-v7:$libraryVersion" + compile "com.android.support:cardview-v7:$libraryVersion" + compile "com.android.support:recyclerview-v7:$libraryVersion" + compile "com.android.support:support-v13:$libraryVersion" + compile "com.android.support:support-v4:$libraryVersion" + + compile 'com.android.support.constraint:constraint-layout:1.0.2' compile 'com.melnykov:floatingactionbutton:1.1.0' compile 'com.jpardogo.materialtabstrip:library:1.0.6' + compile 'com.github.sundeepk:compact-calendar-view:2.0.2.2' + + // Dagger2. + compile "com.google.dagger:dagger:$daggerVersion" + annotationProcessor "com.google.dagger:dagger-compiler:$daggerVersion" + compile "com.google.dagger:dagger-android-support:$daggerVersion" + provided 'javax.annotation:jsr250-api:1.0' + + // RxJava2. + compile group: 'io.reactivex.rxjava2', name: 'rxjava', version: '2.1.1' + compile 'io.reactivex.rxjava2:rxandroid:2.0.1' + + // Testing. + androidTestCompile('com.android.support.test:runner:1.0.1') { + exclude module: 'support-annotations' + } + androidTestCompile('com.android.support.test:rules:1.0.1') { + exclude module: 'support-annotations' + } + androidTestCompile 'com.android.support.test.espresso:espresso-core:3.0.1' + androidTestCompile('com.android.support.test.espresso:espresso-contrib:3.0.1') { + exclude module: 'support-annotations' + exclude module: 'support-v4' + exclude module: 'support-v13' + exclude module: 'recyclerview-v7' + exclude module: 'design' + } + compile "com.android.support.test.uiautomator:uiautomator-v18:2.1.2" + + compile 'com.android.support:support-annotations:24.2.0' + compile 'junit:junit:4.12' + testCompile 'org.robolectric:robolectric:3.3.2' + + } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 14857155..eca1ac79 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -9,6 +9,9 @@ # Add any project specific keep options here: +-keep public interface android.support.test.internal.runner.tracker.UsageTracker {*;} + + # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/DBScheduledRecordingsTest.java b/app/src/androidTest/java/com/danielkim/soundrecorder/DBScheduledRecordingsTest.java new file mode 100644 index 00000000..2049cc6f --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/DBScheduledRecordingsTest.java @@ -0,0 +1,241 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.runner.AndroidJUnit4; + +import com.danielkim.soundrecorder.database.DBHelper; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; + +/** + * Tests for "scheduled_recordings" table in the database. + * Tests methods of DBHelper.java class. + */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class DBScheduledRecordingsTest { + + private DBHelper dbHelper; + private long rec1, rec2, rec3; + + @Before + public void setUp() { + dbHelper = new DBHelper(InstrumentationRegistry.getTargetContext()); + } + + @After + public void finish() { + dbHelper.restoreDatabase(); + dbHelper.close(); + } + + @Test + public void testDBHelperNotNull() throws Exception { + assertNotNull("DBHelper class is null", dbHelper); + } + + @Test + public void testAdd() throws Exception { + dbHelper.restoreDatabase(); + assertEquals("Table is not empty", 0, dbHelper.getScheduledRecordingsCount()); + + dbHelper.addScheduledRecording(0, 100); + assertEquals("Records not incremented to 1 after 1st insertion", 1, dbHelper.getScheduledRecordingsCount()); + dbHelper.addScheduledRecording(100, 500); + assertEquals("Records not incremented to 2 after 2nd insertion", 2, dbHelper.getScheduledRecordingsCount()); + dbHelper.addScheduledRecording(200, 600); + assertEquals("Records not incremented to 3 after 3rd insertion", 3, dbHelper.getScheduledRecordingsCount()); + } + + @Test + public void testGet() throws Exception { + // First add 3 records. + addRecords(); + + // Get existing records. + ScheduledRecordingItem item = dbHelper.getScheduledRecording(rec1); + assertNotNull("1st item is null", item); + assertEquals("Start of 1st item is not 0", 0, item.getStart()); + assertEquals("End of 1st item is not 100", 100, item.getEnd()); + + item = dbHelper.getScheduledRecording(rec2); + assertNotNull("2nd item is null", item); + assertEquals("Start of 2nd item is not 100", 100, item.getStart()); + assertEquals("End of 2nd item is not 500", 500, item.getEnd()); + + item = dbHelper.getScheduledRecording(rec3); + assertNotNull("Item is null", item); + assertEquals("Start of 3rd item is not 200", 200, item.getStart()); + assertEquals("End of 3rd item is not 600", 600, item.getEnd()); + + // Get non-existent record. + item = dbHelper.getScheduledRecording(-7); + assertNull("Non-existent record returned", item); + } + + @Test + public void testUpdate() throws Exception { + // First add 3 records. + addRecords(); + + // Update 1 record. + int updated = 0; + updated = dbHelper.updateScheduledRecording(rec2, 455, 315); + assertEquals("0 records updated", 1, updated); + ScheduledRecordingItem item = dbHelper.getScheduledRecording(rec2); + assertNotNull("Updated item is null", item); + assertEquals("Start of updated item is not 455", 455, item.getStart()); + assertEquals("End of updated item is not 315", 315, item.getEnd()); + + // Update non-existent record. + updated = dbHelper.updateScheduledRecording(-7, 455, 315); + assertEquals("Non-existent record updated", 0, updated); + } + + @Test + public void testDelete() throws Exception { + addRecords(); + + int deleted = 0; + // Delete record 1. + deleted = dbHelper.removeScheduledRecording(rec1); + assertEquals("0 records deleted", 1, deleted); + ScheduledRecordingItem item = dbHelper.getScheduledRecording(rec1); + assertNull("Record 1 not deleted", item); + int count = dbHelper.getScheduledRecordingsCount(); + assertEquals("Records are not 2 after deleting 1 item", 2, count); + + // Delete record 2. + deleted = dbHelper.removeScheduledRecording(rec2); + assertEquals("0 records deleted", 1, deleted); + item = dbHelper.getScheduledRecording(rec2); + assertNull("Record 2 not deleted", item); + count = dbHelper.getScheduledRecordingsCount(); + assertEquals("Records are not 1 after deleting 2 items", 1, count); + + // Delete record 3. + deleted = dbHelper.removeScheduledRecording(rec3); + assertEquals("0 records deleted", 1, deleted); + item = dbHelper.getScheduledRecording(rec3); + assertNull("Record 3 not deleted", item); + count = dbHelper.getScheduledRecordingsCount(); + assertEquals("Records are not 0 after deleting 3 items", 0, count); + + // Delete non-existent record. + deleted = dbHelper.removeScheduledRecording(-7); + assertEquals("Non-existent record deleted", 0, deleted); + } + + @Test + public void testAlreadyScheduled() throws Exception { + addRecords(); + + // Recordings already scheduled. + boolean scheduled = dbHelper.alreadyScheduled(50); + assertEquals("A recording is already scheduled for time 50", true, scheduled); + scheduled = dbHelper.alreadyScheduled(100); + assertEquals("A recording is already scheduled for time 100", true, scheduled); + scheduled = dbHelper.alreadyScheduled(550); + assertEquals("A recording is already scheduled for time 550", true, scheduled); + + // No recordings scheduled. + scheduled = dbHelper.alreadyScheduled(700); + assertEquals("No recording is scheduled for time 700", false, scheduled); + } + + @Test + public void testGetScheduledRecordingsBetween() throws Exception { + addRecords(); + + List list; + // Should return empty list. + list = dbHelper.getScheduledRecordingsBetween(700, 900); + assertEquals("List should be empty", true, list.isEmpty()); + + // Should return 2 items. + list = dbHelper.getScheduledRecordingsBetween(0, 150); + assertEquals("List should contain 2 items", 2, list.size()); + // Get the 2 items and check their properties. + ScheduledRecordingItem item = list.get(0); + assertEquals("1st item's start should be 0", 0, item.getStart()); + assertEquals("1st item's end should be 0", 100, item.getEnd()); + item = list.get(1); + assertEquals("2nd item's start should be 100", 100, item.getStart()); + assertEquals("2nd item's end should be 500", 500, item.getEnd()); + } + + @Test + public void testGetAllScheduledRecordings() throws Exception { + addRecords(); + + List list; + + // Should return 3 items. + list = dbHelper.getAllScheduledRecordings(); + assertEquals("List should contain 3 items", 3, list.size()); + // Get 2 items and check their properties. + ScheduledRecordingItem item = list.get(0); + assertEquals("1st item's start should be 0", 0, item.getStart()); + assertEquals("1st item's end should be 0", 100, item.getEnd()); + item = list.get(1); + assertEquals("2nd item's start should be 100", 100, item.getStart()); + assertEquals("2nd item's end should be 500", 500, item.getEnd()); + } + + @Test + public void testGetNextScheduledRecording() throws Exception { + // Empty database. + dbHelper.restoreDatabase(); + ScheduledRecordingItem item = dbHelper.getNextScheduledRecording(); + assertEquals("Item should be null", null, item); + + // Add 3 records. + item = null; + addRecords(); + item = dbHelper.getNextScheduledRecording(); + assertNotNull("Item should be not null", item); + assertEquals("Start time should be 0", 0, item.getStart()); + + // Delete first scheduled recording. + item = null; + dbHelper.removeScheduledRecording(rec1); + item = dbHelper.getNextScheduledRecording(); + assertNotNull("Item should be not null", item); + assertEquals("Start time should be 100", 100, item.getStart()); + + // Delete second scheduled recording. + item = null; + dbHelper.removeScheduledRecording(rec2); + item = dbHelper.getNextScheduledRecording(); + assertNotNull("Item should be not null", item); + assertEquals("Start time should be 200", 200, item.getStart()); + + // Delete last scheduled recording. + item = null; + dbHelper.removeScheduledRecording(rec3); + item = dbHelper.getNextScheduledRecording(); + assertEquals("Item should be null", null, item); + } + + // Add 3 records to the database. + private void addRecords() { + dbHelper.restoreDatabase(); + rec1 = dbHelper.addScheduledRecording(0, 100); + rec2 = dbHelper.addScheduledRecording(100, 500); + rec3 = dbHelper.addScheduledRecording(200, 600); + } +} diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/EspressoRecordFragment.java b/app/src/androidTest/java/com/danielkim/soundrecorder/EspressoRecordFragment.java new file mode 100644 index 00000000..cfb2b332 --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/EspressoRecordFragment.java @@ -0,0 +1,236 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.Manifest; +import android.content.Intent; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.rule.GrantPermissionRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +import com.danielkim.soundrecorder.activities.MainActivity; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static android.support.test.InstrumentationRegistry.getInstrumentation; +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.Espresso.pressBack; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.longClick; +import static android.support.test.espresso.action.ViewActions.swipeLeft; +import static android.support.test.espresso.assertion.ViewAssertions.doesNotExist; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +/** + * Tests on RecordFragment. + */ + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class EspressoRecordFragment { + + @Rule + public ActivityTestRule mActivityRule = new ActivityTestRule<>(MainActivity.class); + + @Rule + public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO, + Manifest.permission.READ_EXTERNAL_STORAGE); + + /* + Checks that: + - when I click on the start/stop record button the UI changes correctly + - when I stop recording the new recording is added to the file viewer Fragment + */ + @Test + public void startAndStopRecording() { + String recording = mActivityRule.getActivity().getResources().getString(R.string.record_in_progress); + String prompt = mActivityRule.getActivity().getResources().getString(R.string.record_prompt); + + // Start recording. + onView(withId(R.id.btnRecord)).perform(click()); + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + onView(withId(R.id.recording_status_text)).check(matches(withText(containsString(recording)))); + onView(withId(R.id.tvChronometer)).check(matches(withText(not(containsString("00:00"))))); + + // Stop recording. + onView(withId(R.id.btnRecord)).perform(click()); + onView(withId(R.id.recording_status_text)).check(matches(withText(containsString(prompt)))); + onView(withId(R.id.tvChronometer)).check(matches(withText(containsString("00:00")))); + + // Check that the recording is added to FileViewerFragment. + String myRecordings = mActivityRule.getActivity().getResources().getString(R.string.default_file_name); + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withId(R.id.recyclerView)).perform(actionOnItemAtPosition(0, click())); + onView(withId(R.id.file_name_text_view)).check(matches(withText(containsString(myRecordings)))); + pressBack(); + + // Delete the recording. + String deleteFile = mActivityRule.getActivity().getResources().getString(R.string.dialog_file_delete); + String yes = mActivityRule.getActivity().getResources().getString(R.string.dialog_action_yes); + + onView(withText(containsString(myRecordings))).perform(longClick()); + onView(withText(containsString(deleteFile))).perform(click()); + onView(withText(containsString(yes))).perform(click()); + + // Check that the recording is no longer in the list. + onView(withText(containsString(myRecordings))).check(doesNotExist()); + } + + /* + Checks that: + - when I stop the Activity while recording the recording continues + - when I start the Activity again the UI shows the ongoing recording correctly + */ + @Test + public void stopActivityWhileRecording() { + String recording = mActivityRule.getActivity().getResources().getString(R.string.record_in_progress); + String prompt = mActivityRule.getActivity().getResources().getString(R.string.record_prompt); + + onView(withId(R.id.btnRecord)).perform(click()); + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + MainActivity activity = mActivityRule.getActivity(); + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + getInstrumentation().callActivityOnPause(activity); + getInstrumentation().callActivityOnStop(activity); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + getInstrumentation().callActivityOnRestart(activity); + getInstrumentation().callActivityOnStart(activity); + getInstrumentation().callActivityOnResume(activity); + } + }); + + onView(withId(R.id.recording_status_text)).check(matches(withText(containsString(recording)))); + onView(withId(R.id.tvChronometer)).check(matches(withText(not(containsString("00:00"))))); + + onView(withId(R.id.btnRecord)).perform(click()); + onView(withId(R.id.recording_status_text)).check(matches(withText(containsString(prompt)))); + onView(withId(R.id.tvChronometer)).check(matches(withText(containsString("00:00")))); + + // Delete the recording. + String myRecordings = mActivityRule.getActivity().getResources().getString(R.string.default_file_name); + String deleteFile = mActivityRule.getActivity().getResources().getString(R.string.dialog_file_delete); + String yes = mActivityRule.getActivity().getResources().getString(R.string.dialog_action_yes); + + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withText(containsString(myRecordings))).perform(longClick()); + onView(withText(containsString(deleteFile))).perform(click()); + onView(withText(containsString(yes))).perform(click()); + + // Check that the recording is no longer in the list. + onView(withText(containsString(myRecordings))).check(doesNotExist()); + + } + + /* + Checks that: + - when I destroy the Activity while recording the recording continues + - when I start the Activity again the UI shows the ongoing recording correctly + */ + @Test + public void destroyActivityWhileRecording() { + String recording = mActivityRule.getActivity().getResources().getString(R.string.record_in_progress); + String prompt = mActivityRule.getActivity().getResources().getString(R.string.record_prompt); + + onView(withId(R.id.btnRecord)).perform(click()); + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + MainActivity activity = mActivityRule.getActivity(); + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + activity.finish(); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }); + + Intent intent = new Intent(InstrumentationRegistry.getTargetContext(), MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getInstrumentation().startActivitySync(intent); + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + onView(withId(R.id.recording_status_text)).check(matches(withText(containsString(recording)))); + onView(withId(R.id.tvChronometer)).check(matches(withText(not(containsString("00:00"))))); + + onView(withId(R.id.btnRecord)).perform(click()); + onView(withId(R.id.recording_status_text)).check(matches(withText(containsString(prompt)))); + onView(withId(R.id.tvChronometer)).check(matches(withText(containsString("00:00")))); + + // Delete the recording. + String myRecordings = mActivityRule.getActivity().getResources().getString(R.string.default_file_name); + String deleteFile = mActivityRule.getActivity().getResources().getString(R.string.dialog_file_delete); + String yes = mActivityRule.getActivity().getResources().getString(R.string.dialog_action_yes); + + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withText(containsString(myRecordings))).perform(longClick()); + onView(withText(containsString(deleteFile))).perform(click()); + onView(withText(containsString(yes))).perform(click()); + + // Check that the recording is no longer in the list. + onView(withText(containsString(myRecordings))).check(doesNotExist()); + } + + private static Matcher childAtPosition( + final Matcher parentMatcher, final int position) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Child at position " + position + " in parent "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + return parent instanceof ViewGroup && parentMatcher.matches(parent) + && view.equals(((ViewGroup) parent).getChildAt(position)); + } + }; + } + +} diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/EspressoScheduledRecordingsFragment.java b/app/src/androidTest/java/com/danielkim/soundrecorder/EspressoScheduledRecordingsFragment.java new file mode 100644 index 00000000..8b11e69b --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/EspressoScheduledRecordingsFragment.java @@ -0,0 +1,215 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.Manifest; +import android.app.Activity; +import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.ViewInteraction; +import android.support.test.rule.ActivityTestRule; +import android.support.test.rule.GrantPermissionRule; +import android.support.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; + +import com.danielkim.soundrecorder.activities.AddScheduledRecordingActivity; +import com.danielkim.soundrecorder.activities.MainActivity; + +import org.hamcrest.Matchers; +import org.junit.Rule; +import org.junit.Test; + +import java.util.Calendar; +import java.util.Collection; +import java.util.GregorianCalendar; + +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.longClick; +import static android.support.test.espresso.action.ViewActions.swipeLeft; +import static android.support.test.espresso.assertion.ViewAssertions.doesNotExist; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.contrib.RecyclerViewActions.scrollToPosition; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static android.support.test.runner.lifecycle.Stage.RESUMED; +import static junit.framework.Assert.assertTrue; + +/** + * Tests on ScheduledRecordingFragment. + */ + +public class EspressoScheduledRecordingsFragment { + + @Rule + public ActivityTestRule mActivityRule = new ActivityTestRule<>(MainActivity.class); + + @Rule + public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO, + Manifest.permission.READ_EXTERNAL_STORAGE); + + /* + Add a new scheduled recording with correct data. + Check that the recording is added to the list. + Delete the recording. + Check that the recording is no longer in the list. + */ + @Test + public void addScheduledRecordingCorrect() { + String save = mActivityRule.getActivity().getResources().getString(R.string.action_save); + + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withId(R.id.pager)).perform(swipeLeft()); // go to ScheduledRecordingsFragment + onView(withId(R.id.fab_add)).perform(click()); // click on add new scheduled recording button + + // Set date and time for a scheduled recording in AddScheduledRecordingActivity. + GregorianCalendar today = new GregorianCalendar(); + int year = today.get(Calendar.YEAR); + int month = today.get(Calendar.MONTH); + int day = today.get(Calendar.DAY_OF_MONTH); + AddScheduledRecordingActivity activity = (AddScheduledRecordingActivity) getActivityInstance(); + activity.setDatesAndTimesForTesting(year, month, day, 23, 0, year, month, day, 23, 5); + + // Click on action save. + ViewInteraction actionMenuItemView = onView( + Matchers.allOf(withId(R.id.action_save), withText(save), isDisplayed())); + actionMenuItemView.perform(click()); + + // Check that the new scheduled recording is added to the list. + String scheduledRecording = mActivityRule.getActivity().getResources().getString(R.string.frag_sched_scheduled_recording); + onView(withId(R.id.rvRecordings)).perform(scrollToPosition(0)); + onView(withText(scheduledRecording)).check(matches(isDisplayed())); + onView(withText("23:00")).check(matches(isDisplayed())); + onView(withText("23:05")).check(matches(isDisplayed())); + + // Delete the scheduled recording. + onView(withText("23:00")).perform(longClick()); + onView(withText("OK")).perform(click()); + + // Check that the scheduled recording is no longer in the list. + onView(withText("23:00")).check(doesNotExist()); + } + + /* + Add a new scheduled recording with a duration of 3 minutes. + Check that the recording is added to the list with a duration of 5 minutes. + Delete the recording. + Check that the recording is no longer in the list. + */ + @Test + public void addScheduledRecording3Minutes() { + String save = mActivityRule.getActivity().getResources().getString(R.string.action_save); + + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withId(R.id.pager)).perform(swipeLeft()); // go to ScheduledRecordingsFragment + onView(withId(R.id.fab_add)).perform(click()); // click on add new scheduled recording button + + // Set date and time for a scheduled recording in AddScheduledRecordingActivity. + GregorianCalendar today = new GregorianCalendar(); + int year = today.get(Calendar.YEAR); + int month = today.get(Calendar.MONTH); + int day = today.get(Calendar.DAY_OF_MONTH); + AddScheduledRecordingActivity activity = (AddScheduledRecordingActivity) getActivityInstance(); + activity.setDatesAndTimesForTesting(year, month, day, 23, 0, year, month, day, 23, 3); + + // Click on action save. + ViewInteraction actionMenuItemView = onView( + Matchers.allOf(withId(R.id.action_save), withText(save), isDisplayed())); + actionMenuItemView.perform(click()); + + // Check that the new scheduled recording is added to the list. + String scheduledRecording = mActivityRule.getActivity().getResources().getString(R.string.frag_sched_scheduled_recording); + onView(withId(R.id.rvRecordings)).perform(scrollToPosition(0)); + onView(withText(scheduledRecording)).check(matches(isDisplayed())); + onView(withText("23:00")).check(matches(isDisplayed())); + onView(withText("23:05")).check(matches(isDisplayed())); + + // Delete the scheduled recording. + onView(withText("23:00")).perform(longClick()); + onView(withText("OK")).perform(click()); + + // Check that the scheduled recording is no longer in the list. + onView(withText("23:00")).check(doesNotExist()); + } + + /* + Try to add a scheduled recording in the past. + It should not be added (it should stay in the same Activity). + */ + @Test + public void addScheduledRecordingPast() { + String save = mActivityRule.getActivity().getResources().getString(R.string.action_save); + + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withId(R.id.pager)).perform(swipeLeft()); // go to ScheduledRecordingsFragment + onView(withId(R.id.fab_add)).perform(click()); // click on add new scheduled recording button + + // Set date and time for a scheduled recording in AddScheduledRecordingActivity. + GregorianCalendar today = new GregorianCalendar(); + today.add(Calendar.DAY_OF_MONTH, -2); + int year = today.get(Calendar.YEAR); + int month = today.get(Calendar.MONTH); + int day = today.get(Calendar.DAY_OF_MONTH); + AddScheduledRecordingActivity activity = (AddScheduledRecordingActivity) getActivityInstance(); + activity.setDatesAndTimesForTesting(year, month, day, 23, 0, year, month, day, 23, 10); + + // Click on action save. + ViewInteraction actionMenuItemView = onView( + Matchers.allOf(withId(R.id.action_save), withText(save), isDisplayed())); + actionMenuItemView.perform(click()); + + // Check that we are still in the same Activity (scheduled recording not added). + Activity currentActivity = getActivityInstance(); + boolean b = currentActivity instanceof AddScheduledRecordingActivity; + assertTrue("We should be in AddScheduledRecordingActivity", b); + } + + /* + Try to add a scheduled recording with end time before start time. + It should not be added (it should stay in the same Activity). + */ + @Test + public void addScheduledRecordingTimesMismatch() { + String save = mActivityRule.getActivity().getResources().getString(R.string.action_save); + + onView(withId(R.id.pager)).perform(swipeLeft()); + onView(withId(R.id.pager)).perform(swipeLeft()); // go to ScheduledRecordingsFragment + onView(withId(R.id.fab_add)).perform(click()); // click on add new scheduled recording button + + // Set date and time for a scheduled recording in AddScheduledRecordingActivity. + GregorianCalendar today = new GregorianCalendar(); + int year = today.get(Calendar.YEAR); + int month = today.get(Calendar.MONTH); + int day = today.get(Calendar.DAY_OF_MONTH); + AddScheduledRecordingActivity activity = (AddScheduledRecordingActivity) getActivityInstance(); + activity.setDatesAndTimesForTesting(year, month, day, 23, 10, year, month, day, 23, 0); + + // Click on action save. + ViewInteraction actionMenuItemView = onView( + Matchers.allOf(withId(R.id.action_save), withText(save), isDisplayed())); + actionMenuItemView.perform(click()); + + // Check that we are still in the same Activity (scheduled recording not added). + Activity currentActivity = getActivityInstance(); + boolean b = currentActivity instanceof AddScheduledRecordingActivity; + assertTrue("We should be in AddScheduledRecordingActivity", b); + } + + public Activity getActivityInstance() { + final Activity[] activity = new Activity[1]; + InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { + public void run() { + Activity currentActivity = null; + Collection resumedActivities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(RESUMED); + if (resumedActivities.iterator().hasNext()) { + currentActivity = (Activity) resumedActivities.iterator().next(); + activity[0] = currentActivity; + } + } + }); + + return activity[0]; + } +} diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/RecordingServiceTest.java b/app/src/androidTest/java/com/danielkim/soundrecorder/RecordingServiceTest.java new file mode 100644 index 00000000..85d6e847 --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/RecordingServiceTest.java @@ -0,0 +1,223 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.rule.GrantPermissionRule; +import android.support.test.rule.ServiceTestRule; +import android.support.test.runner.AndroidJUnit4; + +import com.danielkim.soundrecorder.database.DBHelper; +import com.danielkim.soundrecorder.utils.Utils; + +import org.junit.AfterClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.concurrent.TimeoutException; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static junit.framework.TestCase.assertNotNull; + +/** + * Tests on RecordingService. + */ + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class RecordingServiceTest { + + @Rule + public final ServiceTestRule mServiceRule = new ServiceTestRule() { + @Override + protected void afterService() { + super.afterService(); + + RecordingService.onCreateCalls = 0; + RecordingService.onStartCommandCalls = 0; + RecordingService.onDestroyCalls = 0; + } + }; + + @Rule + public GrantPermissionRule mRuntimePermissionRule = GrantPermissionRule.grant( + Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO, + Manifest.permission.READ_EXTERNAL_STORAGE); + + /* + Test that the Local Binder Pattern for this Service works correctly. + */ + @Test + public void testLocalBinder() throws TimeoutException { + // Create the service Intent. + Intent serviceIntent = RecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), true); + + // Bind the service and grab a reference to the binder. + IBinder binder = mServiceRule.bindService(serviceIntent); + + // Get the reference to the service, or you can call + // public methods on the binder directly. + RecordingService service = ((RecordingService.LocalBinder) binder).getService(); + + // Verify that the service is working correctly. + assertNotNull("Service reference is null", service); + + mServiceRule.unbindService(); + } + + /* + Test that the Service's lifecycle methods are called the exact number of times in response + to binding, unbinding and calls to startService. + */ + @Test + public void testLifecyleMethodCalls() throws TimeoutException { + // Create the service Intent. + Intent serviceIntent = RecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), true); + + mServiceRule.startService(serviceIntent); + IBinder binder = mServiceRule.bindService(serviceIntent); + RecordingService service = ((RecordingService.LocalBinder) binder).getService(); + mServiceRule.startService(serviceIntent); + mServiceRule.startService(serviceIntent); + + assertNotNull("Service reference is null", service); + assertEquals("onCreate called multiple times", 1, RecordingService.onCreateCalls); + assertEquals("onStartCommand not called 3 times as expected", 3, RecordingService.onStartCommandCalls); + + mServiceRule.unbindService(); + assertEquals("onDestroy not called after unbinding from Service", 1, RecordingService.onCreateCalls); + } + + /* + Test that the Service starts and stops recording when asked to. + */ + @Test + public void testStartAndStopRecording() throws TimeoutException { + // Bind to Service. + Intent serviceIntent = RecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), true); + IBinder binder = mServiceRule.bindService(serviceIntent); + RecordingService service = ((RecordingService.LocalBinder) binder).getService(); + assertNotNull("Service reference is null", service); + + // Start recording. + service.startRecording(0); + assertTrue("Service is not recording, but it should", service.isRecording()); + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + // Stop recording. + service.stopRecording(); + assertFalse("Service is recording, but it should not", service.isRecording()); + + } + + /* + Test the interface used by the Service to communicate information to a connected + Activity. Test that the interface communicates the right information when starting, stopping + the Service and when the recording is ongoing. + */ + @Test + public void testOnRecordingStatusChangedListener() throws TimeoutException { + // Bind to Service. + Intent serviceIntent = RecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), true); + IBinder binder = mServiceRule.bindService(serviceIntent); + RecordingService service = ((RecordingService.LocalBinder) binder).getService(); + assertNotNull("Service reference is null", service); + + // Create listener and bind it to the Service. + MyOnRecordingStatusChangedListener listener = new MyOnRecordingStatusChangedListener(); + service.setOnRecordingStatusChangedListener(listener); + service.startRecording(0); + try { + Thread.sleep(3000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + service.stopRecording(); + + assertTrue("The start of the recording was not communicated to the listener", + listener.isRecordingStarted()); + assertTrue("The stop of the recording was not communicated to the listener", + listener.isRecordingStopped()); + assertTrue("The elapsed seconds of the recording was not communicated to the listener", + listener.getElapsedSeconds() > 0); + assertTrue("The file path of the recording was not communicated to the listener", + listener.getFilePath() != null && listener.getFilePath().length() > 0); + + service.setOnRecordingStatusChangedListener(null); + } + + /* + Delete all the data added by the tests. + */ + @AfterClass + public static void clean() { + Context context = InstrumentationRegistry.getTargetContext(); + // Clear database. + DBHelper dbHelper = new DBHelper(context); + dbHelper.restoreDatabase(); + + // Delete all files created. + String mFilePath = Utils.getDirectoryPath(context); + + File dir = new File(mFilePath); + for (File file : dir.listFiles()) { + file.delete(); + } + dir.delete(); + } + + private class MyOnRecordingStatusChangedListener implements RecordingService.OnRecordingStatusChangedListener { + private boolean recordingStarted; + private boolean recordingStopped; + private int elapsedSeconds; + private String filePath; + + @Override + public void onRecordingStarted() { + recordingStarted = true; + } + + @Override + public void onTimerChanged(int seconds) { + elapsedSeconds = seconds; + } + + @Override + public void onRecordingStopped(String filePath) { + recordingStopped = true; + this.filePath = filePath; + } + + public boolean isRecordingStarted() { + return recordingStarted; + } + + public boolean isRecordingStopped() { + return recordingStopped; + } + + public int getElapsedSeconds() { + return elapsedSeconds; + } + + public String getFilePath() { + return filePath; + } + } + +} diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceLifecycleTest.java b/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceLifecycleTest.java new file mode 100644 index 00000000..02d98a06 --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceLifecycleTest.java @@ -0,0 +1,63 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ServiceTestRule; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.TimeoutException; + +import static junit.framework.Assert.assertEquals; + +/** + * Checks that the lifecycle methods of the ScheduledRecordingService are called the correct + * number of times. + * Created by iClaude on 03/07/2017. + */ + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ScheduledRecordingServiceLifecycleTest implements ServiceConnection { + + private ScheduledRecordingService service; + + @Rule + public final ServiceTestRule mServiceRule = new ServiceTestRule(); + + @Test + public void testLifecycleMethods() throws TimeoutException { + ScheduledRecordingService.onDestroyCalls = 0; + ScheduledRecordingService.onStartCommandCalls = 0; + + Intent intent = ScheduledRecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), false); + // Call startService 3 times. + mServiceRule.startService(intent); + mServiceRule.startService(intent); + mServiceRule.startService(intent); + + assertEquals("onCreate called multiple times", 1, ScheduledRecordingService.onCreateCalls); + assertEquals("onStartCommand not called 3 times as expected", 3, ScheduledRecordingService.onStartCommandCalls); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder iBinder) { + service = ((ScheduledRecordingService.LocalBinder) iBinder).getService(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + } +} diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceNotWakefulTest.java b/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceNotWakefulTest.java new file mode 100644 index 00000000..48c52e2d --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceNotWakefulTest.java @@ -0,0 +1,56 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ServiceTestRule; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.TimeoutException; + +import static junit.framework.Assert.assertEquals; + +/** + * Checks that the ScheduledRecordingService called as not wakeful is treated as such. + * Created by iClaude on 03/07/2017. + */ + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ScheduledRecordingServiceNotWakefulTest implements ServiceConnection { + + private ScheduledRecordingService service; + + @Rule + public final ServiceTestRule mServiceRule = new ServiceTestRule(); + + @Test + public void testNotWakeful() throws TimeoutException, InterruptedException { + // Launch a wakeful Service. + Intent intent = ScheduledRecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), true); + mServiceRule.startService(intent); + assertEquals("Service should be wakeful", true, ScheduledRecordingService.wakeful); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder iBinder) { + service = ((ScheduledRecordingService.LocalBinder) iBinder).getService(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + } +} + diff --git a/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceWakefulTest.java b/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceWakefulTest.java new file mode 100644 index 00000000..3294c67a --- /dev/null +++ b/app/src/androidTest/java/com/danielkim/soundrecorder/ScheduledRecordingServiceWakefulTest.java @@ -0,0 +1,56 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.support.test.InstrumentationRegistry; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ServiceTestRule; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.TimeoutException; + +import static junit.framework.Assert.assertEquals; + +/** + * Checks that the ScheduledRecordingService called as wakeful is treated as such. + * Created by iClaude on 03/07/2017. + */ + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ScheduledRecordingServiceWakefulTest implements ServiceConnection { + + private ScheduledRecordingService service; + + @Rule + public final ServiceTestRule mServiceRule = new ServiceTestRule(); + + @Test + public void testWakeful() throws TimeoutException, InterruptedException { + // Launch a non-wakeful Service. + Intent intent = ScheduledRecordingService.makeIntent(InstrumentationRegistry.getTargetContext(), false); + mServiceRule.startService(intent); + assertEquals("Service should be not wakeful", false, ScheduledRecordingService.wakeful); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder iBinder) { + service = ((ScheduledRecordingService.LocalBinder) iBinder).getService(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + } +} + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ee713ca..0dab22c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,19 +1,25 @@ + package="com.danielkim.soundrecorder"> - + + + + + + android:theme="@style/AppTheme"> + android:launchMode="singleTop"> @@ -29,16 +35,41 @@ android:name="android.support.PARENT_ACTIVITY" android:value="com.danielkim.soundrecorder.activities.MainActivity" /> + + + android:exported="false" + android:grantUriPermissions="true"> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/danielkim/soundrecorder/BootUpReceiver.java b/app/src/main/java/com/danielkim/soundrecorder/BootUpReceiver.java new file mode 100644 index 00000000..6aac0008 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/BootUpReceiver.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder; + +import android.content.Context; +import android.content.Intent; +import android.support.v4.content.WakefulBroadcastReceiver; + +/** + * When the device is rebooted alarms set with the AlarmManager are cancelled. + * So we need to use a BroadcastReceiver that gets triggered at bootup in order to start + * the ScheduledRecordingService and set the next alarm. + */ +public class BootUpReceiver extends WakefulBroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() != null && intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) + startWakefulService(context, ScheduledRecordingService.makeIntent(context, true)); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/DBHelper.java b/app/src/main/java/com/danielkim/soundrecorder/DBHelper.java deleted file mode 100644 index 6f0bb7a3..00000000 --- a/app/src/main/java/com/danielkim/soundrecorder/DBHelper.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.danielkim.soundrecorder; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.provider.BaseColumns; - -import com.danielkim.soundrecorder.listeners.OnDatabaseChangedListener; - -import java.util.Comparator; - -/** - * 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; - - public static final String DATABASE_NAME = "saved_recordings.db"; - private static final int DATABASE_VERSION = 1; - - public static abstract class DBHelperItem implements BaseColumns { - public static final String TABLE_NAME = "saved_recordings"; - - public static final String COLUMN_NAME_RECORDING_NAME = "recording_name"; - public static final String COLUMN_NAME_RECORDING_FILE_PATH = "file_path"; - public static final String COLUMN_NAME_RECORDING_LENGTH = "length"; - public static final String COLUMN_NAME_TIME_ADDED = "time_added"; - } - - private static final String TEXT_TYPE = " TEXT"; - private static final String COMMA_SEP = ","; - private static final String SQL_CREATE_ENTRIES = - "CREATE TABLE " + DBHelperItem.TABLE_NAME + " (" + - DBHelperItem._ID + " INTEGER PRIMARY KEY" + COMMA_SEP + - DBHelperItem.COLUMN_NAME_RECORDING_NAME + TEXT_TYPE + COMMA_SEP + - DBHelperItem.COLUMN_NAME_RECORDING_FILE_PATH + TEXT_TYPE + COMMA_SEP + - DBHelperItem.COLUMN_NAME_RECORDING_LENGTH + " INTEGER " + COMMA_SEP + - DBHelperItem.COLUMN_NAME_TIME_ADDED + " INTEGER " + ")"; - - @SuppressWarnings("unused") - private static final String SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS " + DBHelperItem.TABLE_NAME; - - @Override - public void onCreate(SQLiteDatabase db) { - db.execSQL(SQL_CREATE_ENTRIES); - } - - @Override - 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) { - mOnDatabaseChangedListener = listener; - } - - public RecordingItem getItemAt(int position) { - SQLiteDatabase db = getReadableDatabase(); - String[] projection = { - DBHelperItem._ID, - DBHelperItem.COLUMN_NAME_RECORDING_NAME, - DBHelperItem.COLUMN_NAME_RECORDING_FILE_PATH, - DBHelperItem.COLUMN_NAME_RECORDING_LENGTH, - DBHelperItem.COLUMN_NAME_TIME_ADDED - }; - Cursor c = db.query(DBHelperItem.TABLE_NAME, projection, null, null, null, null, null); - if (c.moveToPosition(position)) { - RecordingItem item = new RecordingItem(); - item.setId(c.getInt(c.getColumnIndex(DBHelperItem._ID))); - item.setName(c.getString(c.getColumnIndex(DBHelperItem.COLUMN_NAME_RECORDING_NAME))); - item.setFilePath(c.getString(c.getColumnIndex(DBHelperItem.COLUMN_NAME_RECORDING_FILE_PATH))); - item.setLength(c.getInt(c.getColumnIndex(DBHelperItem.COLUMN_NAME_RECORDING_LENGTH))); - item.setTime(c.getLong(c.getColumnIndex(DBHelperItem.COLUMN_NAME_TIME_ADDED))); - c.close(); - return item; - } - return null; - } - - public void removeItemWithId(int id) { - SQLiteDatabase db = getWritableDatabase(); - String[] whereArgs = { String.valueOf(id) }; - db.delete(DBHelperItem.TABLE_NAME, "_ID=?", whereArgs); - } - - public int getCount() { - SQLiteDatabase db = getReadableDatabase(); - String[] projection = { DBHelperItem._ID }; - Cursor c = db.query(DBHelperItem.TABLE_NAME, projection, null, null, null, null, null); - int count = c.getCount(); - c.close(); - 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(); - ContentValues cv = new ContentValues(); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_NAME, recordingName); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_FILE_PATH, filePath); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_LENGTH, length); - cv.put(DBHelperItem.COLUMN_NAME_TIME_ADDED, System.currentTimeMillis()); - long rowId = db.insert(DBHelperItem.TABLE_NAME, null, cv); - - if (mOnDatabaseChangedListener != null) { - mOnDatabaseChangedListener.onNewDatabaseEntryAdded(); - } - - return rowId; - } - - public void renameItem(RecordingItem item, String recordingName, String filePath) { - SQLiteDatabase db = getWritableDatabase(); - ContentValues cv = new ContentValues(); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_NAME, recordingName); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_FILE_PATH, filePath); - db.update(DBHelperItem.TABLE_NAME, cv, - DBHelperItem._ID + "=" + item.getId(), null); - - if (mOnDatabaseChangedListener != null) { - mOnDatabaseChangedListener.onDatabaseEntryRenamed(); - } - } - - public long restoreRecording(RecordingItem item) { - SQLiteDatabase db = getWritableDatabase(); - ContentValues cv = new ContentValues(); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_NAME, item.getName()); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_FILE_PATH, item.getFilePath()); - cv.put(DBHelperItem.COLUMN_NAME_RECORDING_LENGTH, item.getLength()); - cv.put(DBHelperItem.COLUMN_NAME_TIME_ADDED, item.getTime()); - cv.put(DBHelperItem._ID, item.getId()); - long rowId = db.insert(DBHelperItem.TABLE_NAME, null, cv); - if (mOnDatabaseChangedListener != null) { - //mOnDatabaseChangedListener.onNewDatabaseEntryAdded(); - } - return rowId; - } -} diff --git a/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java b/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java index a8b36a18..d62d725d 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java +++ b/app/src/main/java/com/danielkim/soundrecorder/RecordingService.java @@ -1,174 +1,302 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + package com.danielkim.soundrecorder; + +import android.Manifest; 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.content.pm.PackageManager; import android.media.MediaRecorder; -import android.os.Environment; +import android.os.Binder; import android.os.IBinder; -import android.preference.PreferenceManager; +import android.support.annotation.VisibleForTesting; import android.support.v4.app.NotificationCompat; +import android.support.v4.content.ContextCompat; import android.util.Log; -import android.widget.Toast; import com.danielkim.soundrecorder.activities.MainActivity; +import com.danielkim.soundrecorder.database.DBHelper; +import com.danielkim.soundrecorder.didagger2.App; +import com.danielkim.soundrecorder.utils.Utils; import java.io.File; import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Locale; import java.util.Timer; import java.util.TimerTask; +import javax.inject.Inject; + +import static android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED; + /** - * Created by Daniel on 12/28/2014. + * Edited by iClaude on 25/09/2017. + * Service used to record audio. This class implements an hybrid Service (bound and started + * Service). + * Compared with the original Service, this class adds 2 new features: + * 1) record scheduled recordings + * 2) bound Service features to connect this Service to an Activity */ + public class RecordingService extends Service { + private static final String TAG = "SCHEDULED_RECORDER_TAG"; + private static final String EXTRA_ACTIVITY_STARTER = "com.danielkim.soundrecorder.EXTRA_ACTIVITY_STARTER"; + private static final int ONGOING_NOTIFICATION = 1; - private static final String LOG_TAG = "RecordingService"; + @Inject + DBHelper dbHelper; 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; + private final IBinder myBinder = new LocalBinder(); + private boolean isRecording = false; + + // Just for testing. + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static int onCreateCalls = 0; + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static int onDestroyCalls = 0; + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static int onStartCommandCalls = 0; + + + /* + Static factory method used to create an Intent to start this Service. The boolean value + activityStarter is true if this method is called by an Activity, false otherwise (i.e. + Service started by an AlarmManager for a scheduled recording. + */ + public static Intent makeIntent(Context context, boolean activityStarter) { + Intent intent = new Intent(context, RecordingService.class); + intent.putExtra(EXTRA_ACTIVITY_STARTER, activityStarter); + return intent; + } + + /* + Other convenient method used to retrieve an empty Intent (i.e to stop this Service). + */ + public static Intent makeIntent(Context context) { + return new Intent(context, RecordingService.class); + } + + /* + The following code implements a bound Service used to connect this Service to an Activity. + */ + + public class LocalBinder extends Binder { + public RecordingService getService() { + return RecordingService.this; + } + } + @Override public IBinder onBind(Intent intent) { - return null; + return myBinder; } - public interface OnTimerChangedListener { + /* + Interface used to communicate to a connected Activity changes in the status of a + recording: + - recording started + - recording stopped (with file path) + - seconds elapsed + */ + public interface OnRecordingStatusChangedListener { + void onRecordingStarted(); void onTimerChanged(int seconds); + void onRecordingStopped(String filePath); } - @Override - public void onCreate() { - super.onCreate(); - mDatabase = new DBHelper(getApplicationContext()); + private OnRecordingStatusChangedListener onRecordingStatusChangedListener = null; + + public void setOnRecordingStatusChangedListener(OnRecordingStatusChangedListener onRecordingStatusChangedListener) { + this.onRecordingStatusChangedListener = onRecordingStatusChangedListener; } + /* + The following code implements a started Service. + */ @Override public int onStartCommand(Intent intent, int flags, int startId) { - startRecording(); - return START_STICKY; + onStartCommandCalls++; + boolean activityStarter = intent.getBooleanExtra(EXTRA_ACTIVITY_STARTER, false); + int duration; + if (!activityStarter) { // automatic scheduled recording + // Get next recording data. + ScheduledRecordingItem item = dbHelper.getNextScheduledRecording(); + duration = (int) (item.getEnd() - item.getStart()); + // Remove scheduled recording from database and schedule next recording. + dbHelper.removeScheduledRecording(item.getId()); + startService(ScheduledRecordingService.makeIntent(this, false)); + + if (!isRecording && hasPermissions()) { + startRecording(duration); + } + } + + return START_NOT_STICKY; + } + + /* + The following code is shared by both started and bound Service. + */ + + @Override + public void onCreate() { + onCreateCalls++; + super.onCreate(); + App.getComponent().inject(this); } @Override public void onDestroy() { + onDestroyCalls++; + super.onDestroy(); if (mRecorder != null) { stopRecording(); } - super.onDestroy(); + if (onRecordingStatusChangedListener != null) onRecordingStatusChangedListener = null; } - public void startRecording() { - setFileNameAndPath(); + public void startRecording(int duration) { + startForeground(ONGOING_NOTIFICATION, createNotification()); + setFileNameAndPath(); mRecorder = new MediaRecorder(); mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); mRecorder.setOutputFile(mFilePath); + mRecorder.setMaxDuration(duration); // if this is a scheduled recording, set the max duration, after which the Service is stopped mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); mRecorder.setAudioChannels(1); if (MySharedPreferences.getPrefHighQuality(this)) { mRecorder.setAudioSamplingRate(44100); mRecorder.setAudioEncodingBitRate(192000); } + // Called only if a max duration has been set (scheduled recordings). + mRecorder.setOnInfoListener((mediaRecorder, what, extra) -> { + if (what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) { + stopRecording(); + } + }); try { mRecorder.prepare(); mRecorder.start(); mStartingTimeMillis = System.currentTimeMillis(); + isRecording = true; - //startTimer(); - //startForeground(1, createNotification()); - + startTimer(); } catch (IOException e) { - Log.e(LOG_TAG, "prepare() failed"); + Log.e(TAG, "prepare() failed" + e.toString()); + } + + if (onRecordingStatusChangedListener != null) { + onRecordingStatusChangedListener.onRecordingStarted(); } } - public void setFileNameAndPath(){ + private void setFileNameAndPath() { int count = 0; File f; - do{ + do { count++; mFileName = getString(R.string.default_file_name) - + "_" + (mDatabase.getCount() + count) + ".mp4"; - mFilePath = Environment.getExternalStorageDirectory().getAbsolutePath(); - mFilePath += "/SoundRecorder/" + mFileName; + + " #" + (dbHelper.getSavedRecordingsCount() + count) + ".mp4"; + mFilePath = Utils.getDirectoryPath(this) + "/" + mFileName; f = new File(mFilePath); - }while (f.exists() && !f.isDirectory()); + } while (f.exists() && !f.isDirectory()); + } + + private void startTimer() { + Timer mTimer = new Timer(); + mElapsedSeconds = 0; + mIncrementTimerTask = new TimerTask() { + @Override + public void run() { + mElapsedSeconds++; + if (onRecordingStatusChangedListener != null) { + onRecordingStatusChangedListener.onTimerChanged(mElapsedSeconds); + } + } + }; + mTimer.scheduleAtFixedRate(mIncrementTimerTask, 1000, 1000); } public void stopRecording() { mRecorder.stop(); - mElapsedMillis = (System.currentTimeMillis() - mStartingTimeMillis); + long mElapsedMillis = (System.currentTimeMillis() - mStartingTimeMillis); mRecorder.release(); - Toast.makeText(this, getString(R.string.toast_recording_finish) + " " + mFilePath, Toast.LENGTH_LONG).show(); + isRecording = false; + mRecorder = null; - //remove notification - if (mIncrementTimerTask != null) { - mIncrementTimerTask.cancel(); - mIncrementTimerTask = null; + // Communicate the file path to the connected Activity. + if (onRecordingStatusChangedListener != null) { + onRecordingStatusChangedListener.onRecordingStopped(mFilePath); } - mRecorder = null; + // Save the recording data in the database. try { - mDatabase.addRecording(mFileName, mFilePath, mElapsedMillis); + dbHelper.addRecording(mFileName, mFilePath, mElapsedMillis); + } catch (Exception e) { + Log.e(TAG, "exception", e); + } - } catch (Exception e){ - Log.e(LOG_TAG, "exception", e); + // Stop timer. + if (mIncrementTimerTask != null) { + mIncrementTimerTask.cancel(); + mIncrementTimerTask = null; } - } - 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); + // No Activity connected -> stop the Service (scheduled recording). + if (onRecordingStatusChangedListener == null) + stopSelf(); + + stopForeground(true); } - //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)) + .setContentText(getString(R.string.notification_recording_text)) .setOngoing(true); mBuilder.setContentIntent(PendingIntent.getActivities(getApplicationContext(), 0, - new Intent[]{new Intent(getApplicationContext(), MainActivity.class)}, 0)); + new Intent[]{new Intent(getApplicationContext(), MainActivity.class)}, PendingIntent.FLAG_UPDATE_CURRENT)); return mBuilder.build(); } + + public boolean isRecording() { + return isRecording; + } + + /* + For Marshmallow+ check if we have the necessary permissions. This method is called for + scheduled recordings because the use might deny the permissions after a scheduled + recording has already been set. + */ + private boolean hasPermissions() { + boolean writePerm = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + boolean audioPerm = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + return writePerm && audioPerm; + } } diff --git a/app/src/main/java/com/danielkim/soundrecorder/ScheduledRecordingItem.java b/app/src/main/java/com/danielkim/soundrecorder/ScheduledRecordingItem.java new file mode 100644 index 00000000..629ae8ae --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/ScheduledRecordingItem.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder; + +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +/** + * POJO representing a scheduled recording, mapping scheduled recordings in the table + * "scheduled_recordings" of the database. + */ + +public class ScheduledRecordingItem implements Parcelable, Comparable { + + private long id; + private long start; + private long end; + + public ScheduledRecordingItem() { + } + + public ScheduledRecordingItem(long id, long start, long end) { + this.id = id; + this.start = start; + this.end = end; + } + + public long getId() { + return id; + } + + public long getStart() { + return start; + } + + public long getEnd() { + return end; + } + + public void setId(long id) { + this.id = id; + } + + public void setStart(long start) { + this.start = start; + } + + public void setEnd(long end) { + this.end = end; + } + + // Implementation of Parcelable interface. + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public ScheduledRecordingItem createFromParcel(Parcel in) { + return new ScheduledRecordingItem(in); + } + + public ScheduledRecordingItem[] newArray(int size) { + return new ScheduledRecordingItem[size]; + } + }; + + public ScheduledRecordingItem(Parcel in) { + id = in.readLong(); + start = in.readLong(); + end = in.readLong(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeLong(id); + parcel.writeLong(start); + parcel.writeLong(end); + } + + // Implementation of Comparable interface. + + @Override + public int compareTo(@NonNull ScheduledRecordingItem scheduledRecordingItem) { + return (int) (getStart() - scheduledRecordingItem.getStart()); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/ScheduledRecordingService.java b/app/src/main/java/com/danielkim/soundrecorder/ScheduledRecordingService.java new file mode 100644 index 00000000..9076bb1b --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/ScheduledRecordingService.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Message; +import android.support.annotation.VisibleForTesting; + +import com.danielkim.soundrecorder.database.DBHelper; +import com.danielkim.soundrecorder.didagger2.App; + +import javax.inject.Inject; + +/** + * This Service gets triggered at boot time and sets the next scheduled recording using an + * AlarmManager. Scheduled recordings are retrieved from the database and loaded in a separate + * thread. + * This class (started Service) also implements the Local Binder pattern just for testing purposes. + */ +public class ScheduledRecordingService extends Service implements Handler.Callback { + + private final int SCHEDULE_RECORDINGS = 1; + private static final String TAG = "SCHEDULED_RECORDER_TAG"; + protected static final String EXTRA_WAKEFUL = "com.danielkim.soundrecorder.WAKEFUL"; + + @Inject + DBHelper dbHelper; + + protected AlarmManager alarmManager; + protected Context context; + private Handler mHandler; + protected Intent startIntent; + + // Just for testing. + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static int onCreateCalls, onDestroyCalls, onStartCommandCalls; + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static boolean wakeful; + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + private final LocalBinder localBinder = new LocalBinder(); + + /* + Static factory method used to create an Intent to start this Service. + */ + public static Intent makeIntent(Context context, boolean wakeful) { + Intent intent = new Intent(context, ScheduledRecordingService.class); + intent.putExtra(EXTRA_WAKEFUL, wakeful); + return intent; + } + + public ScheduledRecordingService() { + } + + @Override + public void onCreate() { + super.onCreate(); + App.getComponent().inject(this); + + onCreateCalls++; // just for testing + + if (alarmManager == null) + alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + + if (context == null) context = this; + + // Start background thread with a Looper. + HandlerThread handlerThread = new HandlerThread("BackgroundThread"); + handlerThread.start(); + mHandler = new Handler(handlerThread.getLooper(), this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + onDestroyCalls++; // just for testing + + // Stop background thread. + mHandler.getLooper().quit(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + onStartCommandCalls++; // just for testing + + // Is this a wakeful Service? In this case we have to release the wake-lock at the end. + wakeful = intent.getBooleanExtra(EXTRA_WAKEFUL, false); + startIntent = intent; + + Message message = mHandler.obtainMessage(SCHEDULE_RECORDINGS); + mHandler.sendMessage(message); + + return START_REDELIVER_INTENT; + } + + @Override + public boolean handleMessage(Message message) { + if (message.what == SCHEDULE_RECORDINGS) { + resetAlarmManager(); // cancel all pending alarms + scheduleNextRecording(); + if (wakeful) { + BootUpReceiver.completeWakefulIntent(startIntent); + } + } + + return true; + } + + // Cancels all pending alarms already set in the AlarmManager. + protected void resetAlarmManager() { + Intent intent = RecordingService.makeIntent(context); + PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, 0); + alarmManager.cancel(pendingIntent); + } + + // Get scheduled recordings from database and set the AlarmManager. + protected void scheduleNextRecording() { + ScheduledRecordingItem item = dbHelper.getNextScheduledRecording(); + if (item != null) { + Intent intent = RecordingService.makeIntent(context, false); + PendingIntent pendingIntent = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) { // up to API 18 + alarmManager.set(AlarmManager.RTC_WAKEUP, item.getStart(), pendingIntent); + } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2 && Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { // API 19-22 + alarmManager.setExact(AlarmManager.RTC_WAKEUP, item.getStart(), pendingIntent); + } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) { // API 23+ + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, item.getStart(), pendingIntent); + } + } + } + + /* + Implementation of local binder pattern for testing purposes. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public class LocalBinder extends Binder { + public ScheduledRecordingService getService() { + return ScheduledRecordingService.this; + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @Override + public IBinder onBind(Intent intent) { + return localBinder; + } + +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/activities/AddScheduledRecordingActivity.java b/app/src/main/java/com/danielkim/soundrecorder/activities/AddScheduledRecordingActivity.java new file mode 100644 index 00000000..2d918e87 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/activities/AddScheduledRecordingActivity.java @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.activities; + +import android.app.DialogFragment; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.content.ContextCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import com.danielkim.soundrecorder.R; +import com.danielkim.soundrecorder.ScheduledRecordingItem; +import com.danielkim.soundrecorder.ScheduledRecordingService; +import com.danielkim.soundrecorder.database.DBHelper; +import com.danielkim.soundrecorder.didagger2.App; +import com.danielkim.soundrecorder.fragments.DatePickerFragment; +import com.danielkim.soundrecorder.fragments.DatePickerFragment.MyOnDateSetListener; +import com.danielkim.soundrecorder.fragments.TimePickerFragment; +import com.danielkim.soundrecorder.fragments.TimePickerFragment.MyOnTimeSetListener; + +import java.text.DateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; + +import javax.inject.Inject; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.observers.DisposableSingleObserver; +import io.reactivex.schedulers.Schedulers; + +import static com.danielkim.soundrecorder.database.RecordingsContract.MAX_DURATION; +import static com.danielkim.soundrecorder.database.RecordingsContract.MIN_DURATION; + +/** + * Activity used to add a new scheduled recording. + */ + +public class AddScheduledRecordingActivity extends AppCompatActivity implements MyOnDateSetListener, MyOnTimeSetListener { + private enum Operation {ADD, EDIT} + + private interface StatusCodes { + int NO_ERROR = 0; + int ERROR_START_AFTER_END = 1; + int ERROR_TIME_PAST = 2; + int ERROR_ALREADY_SCHEDULED = 3; + int ERROR_SAVING = 4; + } + + private static final String EXTRA_DATE_LONG = "com.danielkim.soundrecorder.activities.EXTRA_DATE_LONG"; + private static final String EXTRA_ITEM = "com.danielkim.soundrecorder.activities.EXTRA_ITEM"; + + private TextView tvDateStart; + private TextView tvDateEnd; + private TextView tvTimeStart; + private TextView tvTimeEnd; + + private Operation operation; + private ScheduledRecordingItem item = null; + private final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM); + private int yearStart, monthStart, dayStart, hourStart, minuteStart; + private int yearEnd, monthEnd, dayEnd, hourEnd, minuteEnd; + private int statusCode = StatusCodes.NO_ERROR; + private static final int[] toastMsgs = {R.string.toast_scheduledrecording_saved, + R.string.toast_scheduledrecording_timeerror_start_after_end, R.string.toast_scheduledrecording_timeerror_past, + R.string.toast_scheduledrecording_timeerror_already_scheduled, R.string.toast_scheduledrecording_saved_error}; + + @Inject + DBHelper dbHelper; + private Disposable disposable; + + public static Intent makeIntent(Context context, long selectedDate) { + Intent intent = new Intent(context, AddScheduledRecordingActivity.class); + intent.putExtra(EXTRA_DATE_LONG, selectedDate); + return intent; + } + + public static Intent makeIntent(Context context, ScheduledRecordingItem item) { + Intent intent = new Intent(context, AddScheduledRecordingActivity.class); + intent.putExtra(EXTRA_ITEM, item); + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_add_scheduled_recording); + + App.getComponent().inject(this); + // Action bar (Toolbar). + Toolbar myToolbar = (Toolbar) findViewById(R.id.my_toolbar); + setSupportActionBar(myToolbar); + ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayShowTitleEnabled(false); // hide the title + ab.setDisplayHomeAsUpEnabled(true); + } + + + tvDateStart = (TextView) findViewById(R.id.tvDateStart); + tvDateEnd = (TextView) findViewById(R.id.tvDateEnd); + tvTimeStart = (TextView) findViewById(R.id.tvTimeStart); + tvTimeEnd = (TextView) findViewById(R.id.tvTimeEnd); + + long selectedDateLong = getIntent().getLongExtra(EXTRA_DATE_LONG, System.currentTimeMillis()); + item = getIntent().getParcelableExtra(EXTRA_ITEM); + if (item == null) { + initVariables(selectedDateLong); + } else { + initVariables(item); + } + checkDatesAndTimes(); + displayDatesAndTimes(); + + } + + // Initialize starting and ending days and times (operation = add). + private void initVariables(long selectedDateLong) { + operation = Operation.ADD; + + Calendar cal = new GregorianCalendar(); + cal.setTimeInMillis(selectedDateLong); + yearStart = yearEnd = cal.get(Calendar.YEAR); + monthStart = monthEnd = cal.get(Calendar.MONTH); + dayStart = dayEnd = cal.get(Calendar.DAY_OF_MONTH); + hourStart = 0; + minuteStart = 0; + hourEnd = 1; + minuteEnd = 0; + } + + // Initialize starting and ending days and times (operation = edit). + private void initVariables(@NonNull ScheduledRecordingItem item) { + operation = Operation.EDIT; + + Calendar cal = new GregorianCalendar(); + cal.setTimeInMillis(item.getStart()); + yearStart = cal.get(Calendar.YEAR); + monthStart = cal.get(Calendar.MONTH); + dayStart = cal.get(Calendar.DAY_OF_MONTH); + hourStart = cal.get(Calendar.HOUR_OF_DAY); + minuteStart = cal.get(Calendar.MINUTE); + + cal.setTimeInMillis(item.getEnd()); + yearEnd = cal.get(Calendar.YEAR); + monthEnd = cal.get(Calendar.MONTH); + dayEnd = cal.get(Calendar.DAY_OF_MONTH); + hourEnd = cal.get(Calendar.HOUR_OF_DAY); + minuteEnd = cal.get(Calendar.MINUTE); + } + + // When dates and times change, display them again. + private void displayDatesAndTimes() { + tvDateStart.setText(dateFormat.format(new Date(new GregorianCalendar(yearStart, monthStart, dayStart).getTimeInMillis()))); + tvDateEnd.setText(dateFormat.format(new Date(new GregorianCalendar(yearEnd, monthEnd, dayEnd).getTimeInMillis()))); + tvTimeStart.setText(String.format(Locale.getDefault(), "%1$02d:%2$02d", hourStart, minuteStart)); + tvTimeEnd.setText(String.format(Locale.getDefault(), "%1$02d:%2$02d", hourEnd, minuteEnd)); + } + + public void showDatePickerDialog(View view) { + DialogFragment datePicker = DatePickerFragment.newInstance(view.getId()); + datePicker.show(getFragmentManager(), "datePicker"); + } + + public void showTimePickerDialog(View view) { + int hour = 0; + int minute = 0; + if (view.getId() == R.id.tvTimeStart) { + hour = hourStart; + minute = minuteStart; + } else if (view.getId() == R.id.tvTimeEnd) { + hour = hourEnd; + minute = minuteEnd; + } + + DialogFragment timePicker = TimePickerFragment.newInstance(view.getId(), hour, minute); + timePicker.show(getFragmentManager(), "timePicker"); + } + + // Callback methods for DatePickerFragment and TimePickerFragment. + @Override + public void onDateSet(long viewId, int year, int month, int day) { + if (viewId == R.id.tvDateStart) { + yearStart = year; + monthStart = month; + dayStart = day; + } else if (viewId == R.id.tvDateEnd) { + yearEnd = year; + monthEnd = month; + dayEnd = day; + } + + checkDatesAndTimes(); + displayDatesAndTimes(); + } + + @Override + public void onTimeSet(long viewId, int hour, int minute) { + if (viewId == R.id.tvTimeStart) { + hourStart = hour; + minuteStart = minute; + } else if (viewId == R.id.tvTimeEnd) { + hourEnd = hour; + minuteEnd = minute; + } + + checkDatesAndTimes(); + displayDatesAndTimes(); + } + + private void checkDatesAndTimes() { + statusCode = getTimeErrorCode(); + if (statusCode != StatusCodes.NO_ERROR) { + tvDateStart.setTextColor(ContextCompat.getColor(this, R.color.primary_dark)); + tvTimeStart.setTextColor(ContextCompat.getColor(this, R.color.primary_dark)); + } else { + tvDateStart.setTextColor(ContextCompat.getColor(this, R.color.primary_text)); + tvTimeStart.setTextColor(ContextCompat.getColor(this, R.color.primary_text)); + } + } + + // Dates and times are correct? What kind of error there is? + private int getTimeErrorCode() { + Calendar start = new GregorianCalendar(yearStart, monthStart, dayStart, hourStart, minuteStart); + Calendar end = new GregorianCalendar(yearEnd, monthEnd, dayEnd, hourEnd, minuteEnd); + + if (System.currentTimeMillis() > start.getTimeInMillis()) { + return StatusCodes.ERROR_TIME_PAST; + } else if (end.before(start)) { + return StatusCodes.ERROR_START_AFTER_END; + } else { + return StatusCodes.NO_ERROR; + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_addscheduledrecording, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_save: + saveScheduledRecording(); + return true; + case android.R.id.home: + super.onBackPressed(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private void saveScheduledRecording() { + if (statusCode == StatusCodes.NO_ERROR) { + disposable = observable.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(observer); + } else { + Toast.makeText(this, getString(toastMsgs[statusCode]), Toast.LENGTH_SHORT).show(); + } + } + + // Save the scheduled recording. + private final Single observable = Single.create(emitter -> { + int result = saveRecording(); + emitter.onSuccess(result); + }); + + private final DisposableSingleObserver observer = new DisposableSingleObserver() { + @Override + public void onSuccess(Integer integer) { + saveRecordingCompleted(integer); + } + + @Override + public void onError(Throwable e) { + statusCode = StatusCodes.ERROR_SAVING; + saveRecordingCompleted(statusCode); + } + }; + + private int saveRecording() { + long startLong = new GregorianCalendar(yearStart, monthStart, dayStart, hourStart, minuteStart).getTimeInMillis(); + long endLong = new GregorianCalendar(yearEnd, monthEnd, dayEnd, hourEnd, minuteEnd).getTimeInMillis(); + if (endLong - startLong < MIN_DURATION) { + endLong = startLong + MIN_DURATION; // a scheduled recording must be at least 5 minutes + } else if (endLong - startLong > MAX_DURATION) { + endLong = startLong + MAX_DURATION; // a scheduled recording must be at most 3 hours + } + + if (operation == Operation.ADD) { + if (dbHelper.alreadyScheduled(startLong)) { + statusCode = StatusCodes.ERROR_ALREADY_SCHEDULED; + return statusCode; + } + + long id = dbHelper.addScheduledRecording(startLong, endLong); + if (id == -1) { + statusCode = StatusCodes.ERROR_SAVING; + } + } else { + int updated = dbHelper.updateScheduledRecording(item.getId(), startLong, endLong); + if (updated == 0) { + statusCode = StatusCodes.ERROR_SAVING; + } + } + + return statusCode; + } + + private void saveRecordingCompleted(int result) { + Toast.makeText(this, toastMsgs[result], Toast.LENGTH_SHORT).show(); + if (result == StatusCodes.NO_ERROR) { + + setResult(RESULT_OK); + startService(ScheduledRecordingService.makeIntent(this, false)); + finish(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (disposable != null && !disposable.isDisposed()) + disposable.dispose(); + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public void setDatesAndTimesForTesting(int yearStart, int monthStart, int dayStart, int hourStart, int minuteStart, int yearEnd, int monthEnd, int dayEnd, int hourEnd, int minuteEnd) { + this.yearStart = yearStart; + this.yearEnd = yearEnd; + this.monthStart = monthStart; + this.monthEnd = monthEnd; + this.dayStart = dayStart; + this.dayEnd = dayEnd; + this.hourStart = hourStart; + this.hourEnd = hourEnd; + this.minuteStart = minuteStart; + this.minuteEnd = minuteEnd; + statusCode = getTimeErrorCode(); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java b/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java index a2e6bde7..69cb2a84 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java +++ b/app/src/main/java/com/danielkim/soundrecorder/activities/MainActivity.java @@ -1,47 +1,55 @@ +/* + * Year: 2017. This class was edited by iClaude. + */ + package com.danielkim.soundrecorder.activities; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.ComponentName; import android.content.Intent; -import android.content.SharedPreferences; +import android.content.ServiceConnection; 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.os.IBinder; +import android.support.v13.app.FragmentPagerAdapter; import android.support.v4.view.ViewPager; -import android.support.v7.app.ActionBarActivity; +import android.support.v7.app.AppCompatActivity; 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.RecordingService; import com.danielkim.soundrecorder.fragments.FileViewerFragment; import com.danielkim.soundrecorder.fragments.LicensesFragment; import com.danielkim.soundrecorder.fragments.RecordFragment; +import com.danielkim.soundrecorder.fragments.ScheduledRecordingsFragment; + + +public class MainActivity extends AppCompatActivity implements RecordFragment.ServiceOperations { + private static final String TAG = "SCHEDULED_RECORDER_TAG"; -public class MainActivity extends ActionBarActivity{ + private RecordFragment recordFragment = null; - private static final String LOG_TAG = MainActivity.class.getSimpleName(); + private RecordingService recordingService; + private boolean serviceConnected = false; - 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); + ViewPager pager = (ViewPager) findViewById(R.id.pager); + pager.setAdapter(new MyAdapter(getFragmentManager())); + PagerSlidingTabStrip 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); - } + setSupportActionBar(toolbar); } @Override @@ -67,9 +75,15 @@ public boolean onOptionsItemSelected(MenuItem item) { } } + private void openLicenses() { + LicensesFragment licensesFragment = new LicensesFragment(); + licensesFragment.show(getFragmentManager().beginTransaction(), "dialog_licenses"); + } + public class MyAdapter extends FragmentPagerAdapter { - private String[] titles = { getString(R.string.tab_title_record), - getString(R.string.tab_title_saved_recordings) }; + private final String[] titles = {getString(R.string.tab_title_record), + getString(R.string.tab_title_saved_recordings), + getString(R.string.tab_title_scheduled_recordings)}; public MyAdapter(FragmentManager fm) { super(fm); @@ -79,11 +93,15 @@ public MyAdapter(FragmentManager fm) { public Fragment getItem(int position) { switch(position){ case 0:{ - return RecordFragment.newInstance(position); + recordFragment = RecordFragment.newInstance(position); + return recordFragment; } case 1:{ return FileViewerFragment.newInstance(position); } + case 2: { + return ScheduledRecordingsFragment.newInstance(position); + } } return null; } @@ -101,4 +119,119 @@ public CharSequence getPageTitle(int position) { public MainActivity() { } + + // Connection with local Service. + @Override + protected void onStart() { + super.onStart(); + + startService(RecordingService.makeIntent(this, true)); + bindService(RecordingService.makeIntent(this, true), serviceConnection, BIND_AUTO_CREATE); + } + + // Disconnection from local Service. + @Override + protected void onStop() { + super.onStop(); + + if (serviceConnected) { + unbindService(serviceConnection); + if (!isServiceRecording()) stopService(RecordingService.makeIntent(this)); + recordingService.setOnRecordingStatusChangedListener(null); + recordingService = null; + serviceConnected = false; + if (recordFragment != null) { + recordFragment.serviceConnection(false); + } + } + } + + /* + Implementation of RecordFragment.ServiceOperations. + RecordFragment uses this interface to communicate with this Activity in order to interact + with RecordingService (the connection with the Service is managed by this Activity). + */ + @Override + public void requestStartRecording() { + if (recordingService != null && !isServiceRecording()) { + recordingService.startRecording(0); + } + } + + @Override + public void requestStopRecording() { + if (recordingService != null) { + recordingService.stopRecording(); + } + } + + @Override + public boolean isServiceConnected() { + return serviceConnected; + } + + @Override + public boolean isServiceRecording() { + return recordingService != null && recordingService.isRecording(); + } + + /* + Implementation of ServiceConnection interface. + The interaction with the Service is managed by this Activity. + */ + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + recordingService = ((RecordingService.LocalBinder) iBinder).getService(); + serviceConnected = true; + if (recordFragment != null) { + recordFragment.serviceConnection(true); + } + recordingService.setOnRecordingStatusChangedListener(onScheduledRecordingListener); + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + recordingService.setOnRecordingStatusChangedListener(null); + recordingService = null; + serviceConnected = false; + if (recordFragment != null) { + recordFragment.serviceConnection(false); + } + } + }; + + /* + Implementation of RecordingService.OnRecordingStatusChangedListener interface. + The Service uses this interface to communicate to the connected Activity that a + recording has started/stopped, and the seconds elapsed, so that the UI can be updated + accordingly. + */ + private final RecordingService.OnRecordingStatusChangedListener onScheduledRecordingListener = new RecordingService.OnRecordingStatusChangedListener() { + @Override + public void onRecordingStarted() { + if (recordFragment != null) + recordFragment.recordingStarted(); + } + + @Override + public void onRecordingStopped(String filePath) { + if (recordFragment != null) + recordFragment.recordingStopped(filePath); + } + + // This method is called from a separate thread. + @Override + public void onTimerChanged(int seconds) { + if (recordFragment != null) { + runOnUiThread(new Runnable() { + @Override + public void run() { + recordFragment.timerChanged(seconds); + } + }); + } + } + }; + } diff --git a/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java b/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java index fbb3bfef..a34b7363 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java +++ b/app/src/main/java/com/danielkim/soundrecorder/activities/SettingsActivity.java @@ -1,11 +1,7 @@ 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; diff --git a/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java b/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java index 1d1418cf..ce1d6048 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java +++ b/app/src/main/java/com/danielkim/soundrecorder/adapters/FileViewerAdapter.java @@ -1,15 +1,16 @@ package com.danielkim.soundrecorder.adapters; import android.app.AlertDialog; +import android.app.FragmentTransaction; 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.text.format.DateUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -17,18 +18,19 @@ 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.database.DBHelper; +import com.danielkim.soundrecorder.didagger2.App; 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; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; /** * Created by Daniel on 12/29/2014. @@ -38,7 +40,8 @@ public class FileViewerAdapter extends RecyclerView.Adapter getAllScheduledRecordings() { + SQLiteDatabase db = getReadableDatabase(); + String[] projection = { + TableScheduledRecording._ID, + TableScheduledRecording.COLUMN_NAME_START, + TableScheduledRecording.COLUMN_NAME_END + }; + + Cursor c = db.query(TableScheduledRecording.TABLE_NAME, projection, null, null, null, null, null); + List list = new ArrayList<>(); + while (c.moveToNext()) { + ScheduledRecordingItem item = new ScheduledRecordingItem(); + item.setId(c.getLong(c.getColumnIndex(TableScheduledRecording._ID))); + item.setStart(c.getLong(c.getColumnIndex(TableScheduledRecording.COLUMN_NAME_START))); + item.setEnd(c.getLong(c.getColumnIndex(TableScheduledRecording.COLUMN_NAME_END))); + list.add(item); + } + c.close(); + + return list; + } + + // Returns all scheduled recordings whose field start is between start and end. + public List getScheduledRecordingsBetween(long start, long end) { + SQLiteDatabase db = getReadableDatabase(); + String[] projection = { + TableScheduledRecording._ID, + TableScheduledRecording.COLUMN_NAME_START, + TableScheduledRecording.COLUMN_NAME_END + }; + String where = TableScheduledRecording.COLUMN_NAME_START + " >= ? AND " + TableScheduledRecording.COLUMN_NAME_START + " <= ?"; + String[] whereArgs = {String.valueOf(start), String.valueOf(end)}; + + Cursor c = db.query(TableScheduledRecording.TABLE_NAME, projection, where, whereArgs, null, null, null); + List list = new ArrayList<>(); + while (c.moveToNext()) { + ScheduledRecordingItem item = new ScheduledRecordingItem(); + item.setId(c.getLong(c.getColumnIndex(TableScheduledRecording._ID))); + item.setStart(c.getLong(c.getColumnIndex(TableScheduledRecording.COLUMN_NAME_START))); + item.setEnd(c.getLong(c.getColumnIndex(TableScheduledRecording.COLUMN_NAME_END))); + list.add(item); + } + c.close(); + + return list; + } + + // Returns the first scheduled recording according to the starting time. + public ScheduledRecordingItem getNextScheduledRecording() { + SQLiteDatabase db = getReadableDatabase(); + String[] projection = { + TableScheduledRecording._ID, + TableScheduledRecording.COLUMN_NAME_START, + TableScheduledRecording.COLUMN_NAME_END + }; + + Cursor c = db.query(TableScheduledRecording.TABLE_NAME, projection, null, null, null, null, TableScheduledRecording.COLUMN_NAME_START, "1"); + ScheduledRecordingItem item = null; + if (c.moveToFirst()) { + item = new ScheduledRecordingItem(); + item.setId(c.getLong(c.getColumnIndex(TableScheduledRecording._ID))); + item.setStart(c.getLong(c.getColumnIndex(TableScheduledRecording.COLUMN_NAME_START))); + item.setEnd(c.getLong(c.getColumnIndex(TableScheduledRecording.COLUMN_NAME_END))); + } + c.close(); + + return item; + } + + public int getScheduledRecordingsCount() { + SQLiteDatabase db = getReadableDatabase(); + String[] projection = {TableScheduledRecording._ID}; + Cursor c = db.query(TableScheduledRecording.TABLE_NAME, projection, null, null, null, null, null); + int count = c.getCount(); + c.close(); + return count; + } + + // Given a time, returns true if other recordings have already been scheduled for that time. + public boolean alreadyScheduled(long time) { + SQLiteDatabase db = getReadableDatabase(); + String[] projection = {TableScheduledRecording._ID}; + String where = "? >= " + TableScheduledRecording.COLUMN_NAME_START + " AND ? <= " + TableScheduledRecording.COLUMN_NAME_END; + String[] whereArgs = {String.valueOf(time), String.valueOf(time)}; + Cursor c = db.query(TableScheduledRecording.TABLE_NAME, projection, where, whereArgs, null, null, null); + int count = c.getCount(); + c.close(); + + return count > 0; + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/database/RecordingsContract.java b/app/src/main/java/com/danielkim/soundrecorder/database/RecordingsContract.java new file mode 100644 index 00000000..6917f170 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/database/RecordingsContract.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.database; + +import android.provider.BaseColumns; + +/** + * Contract class for all operations related to database. + */ + +public class RecordingsContract { + + public static class TableSavedRecording implements BaseColumns { + public static final String TABLE_NAME = "saved_recordings"; + + public static final String COLUMN_NAME_RECORDING_NAME = "recording_name"; + public static final String COLUMN_NAME_RECORDING_FILE_PATH = "file_path"; + public static final String COLUMN_NAME_RECORDING_LENGTH = "length"; + public static final String COLUMN_NAME_TIME_ADDED = "time_added"; + } + + // Table "scheduled_recordings". + public static class TableScheduledRecording implements BaseColumns { + public static final String TABLE_NAME = "scheduled_recordings"; + + public static final String COLUMN_NAME_START = "start"; // start of the recording in ms from epoch + public static final String COLUMN_NAME_END = "end"; // length of the recording in ms + } + + // Requirements. + public static final int MIN_DURATION = 1000 * 60 * 5; // 5 minutes + public static final int MAX_DURATION = 1000 * 60 * 60 * 3; // 3 hours + + private RecordingsContract() { + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/database/SQLStrings.java b/app/src/main/java/com/danielkim/soundrecorder/database/SQLStrings.java new file mode 100644 index 00000000..b14f75c0 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/database/SQLStrings.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.database; + +/** + * Class containing strings with SQL commands for the database. + */ + +public interface SQLStrings { + // Utility. + String TEXT_TYPE = " TEXT"; + String INTEGER_TYPE = " INTEGER"; + String COMMA_SEP = ", "; + + // Table "saved_recordings": create and delete. + String CREATE_TABLE_SAVED_RECORDINGS = + "CREATE TABLE " + RecordingsContract.TableSavedRecording.TABLE_NAME + " (" + + RecordingsContract.TableSavedRecording._ID + " INTEGER PRIMARY KEY" + COMMA_SEP + + RecordingsContract.TableSavedRecording.COLUMN_NAME_RECORDING_NAME + TEXT_TYPE + COMMA_SEP + + RecordingsContract.TableSavedRecording.COLUMN_NAME_RECORDING_FILE_PATH + TEXT_TYPE + COMMA_SEP + + RecordingsContract.TableSavedRecording.COLUMN_NAME_RECORDING_LENGTH + INTEGER_TYPE + COMMA_SEP + + RecordingsContract.TableSavedRecording.COLUMN_NAME_TIME_ADDED + INTEGER_TYPE + ")"; + + @SuppressWarnings("unused") + String DELETE_TABLE_SAVED_RECORDINGS = "DROP TABLE IF EXISTS " + RecordingsContract.TableSavedRecording.TABLE_NAME; + + // Table "scheduled_recordings": create and delete. + String CREATE_TABLE_SCHEDULED_RECORDINGS = + "CREATE TABLE " + RecordingsContract.TableScheduledRecording.TABLE_NAME + " (" + + RecordingsContract.TableScheduledRecording._ID + " INTEGER PRIMARY KEY AUTOINCREMENT" + COMMA_SEP + + RecordingsContract.TableScheduledRecording.COLUMN_NAME_START + INTEGER_TYPE + COMMA_SEP + + RecordingsContract.TableScheduledRecording.COLUMN_NAME_END + INTEGER_TYPE + ")"; + + @SuppressWarnings("unused") + String DELETE_TABLE_SCHEDULED_RECORDINGS = "DROP TABLE IF EXISTS " + RecordingsContract.TableScheduledRecording.TABLE_NAME; + + +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/didagger2/App.java b/app/src/main/java/com/danielkim/soundrecorder/didagger2/App.java new file mode 100644 index 00000000..1f1aec66 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/didagger2/App.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.didagger2; + +import android.app.Application; + +/** + * Custom Application class. + * It initializes AppComponent for Dagger2. + */ + +public class App extends Application { + private static AppComponent component; + + public static AppComponent getComponent() { + return component; + } + + @Override + public void onCreate() { + super.onCreate(); + component = buildComponent(); + } + + protected AppComponent buildComponent() { + return DaggerAppComponent.builder() + .appModule(new AppModule(this)) + .dBHelperModule(new DBHelperModule()) + .build(); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/didagger2/AppComponent.java b/app/src/main/java/com/danielkim/soundrecorder/didagger2/AppComponent.java new file mode 100644 index 00000000..ec935508 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/didagger2/AppComponent.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.didagger2; + +import com.danielkim.soundrecorder.RecordingService; +import com.danielkim.soundrecorder.ScheduledRecordingService; +import com.danielkim.soundrecorder.activities.AddScheduledRecordingActivity; +import com.danielkim.soundrecorder.adapters.FileViewerAdapter; +import com.danielkim.soundrecorder.fragments.ScheduledRecordingsFragment; + +import javax.inject.Singleton; + +import dagger.Component; + +/** + * Dagger @Component class. + */ +@Component(modules = {AppModule.class, DBHelperModule.class}) +@Singleton +public interface AppComponent { + void inject(AddScheduledRecordingActivity addScheduledRecordingActivity); + + void inject(FileViewerAdapter fileViewerAdapter); + + void inject(RecordingService recordingService); + + void inject(ScheduledRecordingService scheduledRecordingService); + + void inject(ScheduledRecordingsFragment scheduledRecordingsFragment); + +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/didagger2/AppModule.java b/app/src/main/java/com/danielkim/soundrecorder/didagger2/AppModule.java new file mode 100644 index 00000000..9078661e --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/didagger2/AppModule.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.didagger2; + +import android.content.Context; +import android.support.annotation.NonNull; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +/** + * Dagger @Module class providing an application Context. + */ +@Module +public class AppModule { + private final Context appContext; + + public AppModule(@NonNull Context appContext) { + this.appContext = appContext; + } + + @Provides + @Singleton + Context provideContext() { + return appContext; + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/didagger2/DBHelperModule.java b/app/src/main/java/com/danielkim/soundrecorder/didagger2/DBHelperModule.java new file mode 100644 index 00000000..a20b527f --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/didagger2/DBHelperModule.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.didagger2; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.danielkim.soundrecorder.database.DBHelper; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +/** + * Dagger @Module class providing a DBHelper object (Singleton for the app's lifecycle scope) to + * interact with local database. + */ +@Module +public class DBHelperModule { + @Provides + @Singleton + @NonNull + public DBHelper provideDBHelper(Context context) { + return new DBHelper(context); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/DatePickerFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/DatePickerFragment.java new file mode 100644 index 00000000..1645cc6a --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/DatePickerFragment.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.fragments; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.DatePickerDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.os.Bundle; +import android.widget.DatePicker; + +import java.util.Calendar; + +/** + * Shows a dialog to pick a date. + * Communicates the date selected through an interface. + * This class stores and communicates the id of the view that needs the date, so it can be used + * for different views within the same Activity/Fragment. + */ + +public class DatePickerFragment extends DialogFragment implements DatePickerDialog.OnDateSetListener { + private static final String VIEW_ID = "view_id"; + + private MyOnDateSetListener listener; + + public static DatePickerFragment newInstance(long viewId) { + DatePickerFragment f = new DatePickerFragment(); + Bundle bundle = new Bundle(); + bundle.putLong(VIEW_ID, viewId); + f.setArguments(bundle); + + return f; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Calendar c = Calendar.getInstance(); + int year = c.get(Calendar.YEAR); + int month = c.get(Calendar.MONTH); + int day = c.get(Calendar.DAY_OF_MONTH); + + return new DatePickerDialog(getActivity(), this, year, month, day); + } + + @TargetApi(23) + @Override + public void onAttach(Context context) { + super.onAttach(context); + + try { + listener = (MyOnDateSetListener) context; + } catch (ClassCastException e) { + throw new ClassCastException(context.toString() + "must implement DatePickerFragment.MyOnDateSetListener"); + } + } + + @SuppressWarnings("deprecation") + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + listener = (MyOnDateSetListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + "must implement DatePickerFragment.MyOnDateSetListener"); + } + } + + @Override + public void onDateSet(DatePicker datePicker, int year, int month, int day) { + if (listener != null) { + listener.onDateSet(getArguments().getLong(VIEW_ID, 0), year, month, day); + } + } + + // Interface form communication with the Activity. + public interface MyOnDateSetListener { + void onDateSet(long viewId, int year, int month, int day); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java index bf29e7f0..f41710c9 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/FileViewerFragment.java @@ -1,8 +1,8 @@ package com.danielkim.soundrecorder.fragments; +import android.app.Fragment; 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; diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java index a93dbded..c58866c6 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/LicensesFragment.java @@ -2,8 +2,8 @@ import android.app.AlertDialog; import android.app.Dialog; +import android.app.DialogFragment; import android.os.Bundle; -import android.support.v4.app.DialogFragment; import android.view.LayoutInflater; import android.view.View; diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java index 09f12135..5617d0de 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/PlaybackFragment.java @@ -2,13 +2,13 @@ import android.app.AlertDialog; import android.app.Dialog; +import android.app.DialogFragment; 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; diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java index 151822c0..f7a194a4 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/RecordFragment.java @@ -1,24 +1,36 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + package com.danielkim.soundrecorder.fragments; -import android.content.Intent; +import android.Manifest; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; -import android.os.Environment; -import android.os.SystemClock; -import android.support.v4.app.Fragment; +import android.support.annotation.NonNull; +import android.support.v13.app.FragmentCompat; +import android.support.v4.content.ContextCompat; 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; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; /** * A simple {@link Fragment} subclass. @@ -28,24 +40,64 @@ * create an instance of this fragment. */ public class RecordFragment extends Fragment { + // Constants. + private static final String TAG = "SCHEDULED_RECORDER_TAG"; + private static final int REQUEST_DANGEROUS_PERMISSIONS = 0; + + private final boolean marshmallow = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; + // 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 boolean isRecording = false; + private boolean mPauseRecording; + + private static final SimpleDateFormat mTimerFormat = new SimpleDateFormat("mm:ss", Locale.getDefault()); + private TextView tvChronometer; + + private ServiceOperations serviceOperations; + + + /* + Interface used to communicate with the Activity with regard to the connected Service. + */ + public interface ServiceOperations { + void requestStartRecording(); + + void requestStopRecording(); + + boolean isServiceConnected(); + + boolean isServiceRecording(); + } + + @TargetApi(23) + @Override + public void onAttach(Context context) { + super.onAttach(context); + + try { + serviceOperations = (ServiceOperations) context; + } catch (ClassCastException e) { + throw new ClassCastException(context.toString() + " must implement ServiceOperations"); + } + } - private Chronometer mChronometer = null; - long timeWhenPaused = 0; //stores time when user clicks pause button + @SuppressWarnings("deprecation") + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + serviceOperations = (ServiceOperations) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + " must implement ServiceOperations"); + } + } /** * Use this factory method to create a new instance of @@ -65,120 +117,168 @@ public static RecordFragment newInstance(int position) { public RecordFragment() { } - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - position = getArguments().getInt(ARG_POSITION); - } - + @SuppressLint("SetTextI18n") @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); + tvChronometer = (TextView) recordView.findViewById(R.id.tvChronometer); + tvChronometer.setText("00:00"); //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; + mRecordButton.setColorNormal(ContextCompat.getColor(getActivity(), R.color.primary)); + mRecordButton.setColorPressed(ContextCompat.getColor(getActivity(), R.color.primary_dark)); + if (serviceOperations != null) { + mRecordButton.setEnabled(serviceOperations.isServiceConnected()); + } + mRecordButton.setOnClickListener(v -> { + if (!marshmallow) { + startStopRecording(); + } else { + checkPermissions(); } }); - mPauseButton = (Button) recordView.findViewById(R.id.btnPause); + Button 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; - } + mPauseButton.setOnClickListener(v -> { + //onPauseRecord(mPauseRecording); + mPauseRecording = !mPauseRecording; }); + /* Are we already recording? Check necessary if Service is connected to the Activity + before the Fragment is created: in this case the method serviceConnection(boolean + isConnected of this Fragment is not called). + */ + checkRecording(); + return recordView; } + // Check dangerous permissions for Android Marshmallow+. + @SuppressWarnings("ConstantConditions") + private void checkPermissions() { + // Check permissions. + boolean writePerm = ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + boolean audioPerm = ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + String[] arrPermissions; + if (!writePerm && !audioPerm) { + arrPermissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO}; + } else if (!writePerm && audioPerm) { + arrPermissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + } else if (writePerm && !audioPerm) { + arrPermissions = new String[]{Manifest.permission.RECORD_AUDIO}; + } else { + startStopRecording(); + return; + } + + // Request permissions. + FragmentCompat.requestPermissions(this, arrPermissions, REQUEST_DANGEROUS_PERMISSIONS); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + boolean granted = true; + for (int grantResult : grantResults) { // we nee all permissions granted + if (grantResult != PackageManager.PERMISSION_GRANTED) + granted = false; + } + + if (granted) + startStopRecording(); + else + Toast.makeText(getActivity(), getString(R.string.toast_permissions_denied), Toast.LENGTH_LONG).show(); + } + // Recording Start/Stop //TODO: recording pause - private void onRecord(boolean start){ - - Intent intent = new Intent(getActivity(), RecordingService.class); + private void startStopRecording() { + if (!isRecording) { // start recording + // Start RecordingService: send request to main Activity. + if (serviceOperations != null) { + serviceOperations.requestStartRecording(); + } - 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(); + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); //keep screen on while recording + isRecording = true; + } else { //stop recording + // Stop RecordingService: send request to main Activity. + if (serviceOperations != null) { + serviceOperations.requestStopRecording(); } - //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++; + getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); //allow the screen to turn off again once recording is finished + isRecording = false; + } + } + private void updateUI(boolean recording, String filePath) { + if (recording) { + mRecordButton.setImageResource(R.drawable.ic_media_stop); + mRecordingPrompt.setText(getString(R.string.record_in_progress) + "..."); + Toast.makeText(getActivity(), R.string.toast_recording_start, Toast.LENGTH_SHORT).show(); } else { - //stop recording mRecordButton.setImageResource(R.drawable.ic_mic_white_36dp); - //mPauseButton.setVisibility(View.GONE); - mChronometer.stop(); - mChronometer.setBase(SystemClock.elapsedRealtime()); - timeWhenPaused = 0; + tvChronometer.setText("00:00"); 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); + if (filePath != null) + Toast.makeText(getActivity(), getString(R.string.toast_recording_finish) + " " + filePath, Toast.LENGTH_LONG).show(); } } //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(); +/* private void onPauseRecord(boolean pause) { + + }*/ + + /* + When the Activity establishes the connection with the Service, it informs this Fragment + so that the record button can be enabled. + */ + public void serviceConnection(boolean isConnected) { + mRecordButton.setEnabled(isConnected); + + // Are we already recording? + checkRecording(); + } + + /* + If the Service is currently recording update the UI accordingly and update the value + of isRecording. + */ + private void checkRecording() { + if (serviceOperations == null) return; + + if (serviceOperations.isServiceConnected() && serviceOperations.isServiceRecording()) { + mRecordButton.setImageResource(R.drawable.ic_media_stop); + mRecordingPrompt.setText(getString(R.string.record_in_progress) + "..."); + isRecording = true; } } + + public void timerChanged(int seconds) { + if (isRecording) + tvChronometer.setText(mTimerFormat.format(new Date(seconds * 1000L))); + } + + public void recordingStarted() { + updateUI(true, null); + isRecording = true; + } + + public void recordingStopped(String filePath) { + updateUI(false, filePath); + isRecording = false; + } + + @Override + public void onDetach() { + super.onDetach(); + + if (serviceOperations != null) serviceOperations = null; + } } \ No newline at end of file diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/ScheduledRecordingsFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/ScheduledRecordingsFragment.java new file mode 100644 index 00000000..b11dfc40 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/ScheduledRecordingsFragment.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.fragments; + +import android.Manifest; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v13.app.FragmentCompat; +import android.support.v4.content.ContextCompat; +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.TextView; +import android.widget.Toast; + +import com.danielkim.soundrecorder.R; +import com.danielkim.soundrecorder.ScheduledRecordingItem; +import com.danielkim.soundrecorder.ScheduledRecordingService; +import com.danielkim.soundrecorder.activities.AddScheduledRecordingActivity; +import com.danielkim.soundrecorder.database.DBHelper; +import com.danielkim.soundrecorder.didagger2.App; +import com.github.sundeepk.compactcalendarview.CompactCalendarView; +import com.github.sundeepk.compactcalendarview.domain.Event; +import com.melnykov.fab.FloatingActionButton; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import javax.inject.Inject; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +/** + * This Fragment shows all scheduled recordings using a CalendarView. + *

+ * Created by iClaude on 16/08/2017. + */ + +public class ScheduledRecordingsFragment extends Fragment implements ScheduledRecordingsFragmentItemAdapter.MyOnItemClickListener { + + private final String TAG = this.getClass().getSimpleName(); + private static final String ARG_POSITION = "position"; + private static final int REQUEST_DANGEROUS_PERMISSIONS = 0; + private static final int ADD_SCHEDULED_RECORDING = 0; + private static final int EDIT_SCHEDULED_RECORDING = 1; + + private CompactCalendarView calendarView; + private TextView tvMonth; + private TextView tvDate; + + private RecyclerView.Adapter adapter; + private List scheduledRecordings; + private Date selectedDate = new Date(System.currentTimeMillis()); + + @Inject + DBHelper dbHelper; + private final CompositeDisposable compositeDisposable = new CompositeDisposable(); + + public static ScheduledRecordingsFragment newInstance(int position) { + ScheduledRecordingsFragment f = new ScheduledRecordingsFragment(); + Bundle b = new Bundle(); + b.putInt(ARG_POSITION, position); + f.setArguments(b); + + return f; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + App.getComponent().inject(this); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_scheduled_recordings, container, false); + + // Title. + tvMonth = (TextView) v.findViewById(R.id.tvMonth); + String month = new SimpleDateFormat("MMMM", Locale.getDefault()).format(Calendar.getInstance().getTime()); + tvMonth.setText(month); + // Calendar view. + calendarView = (CompactCalendarView) v.findViewById(R.id.compactcalendar_view); + calendarView.setListener(myCalendarViewListener); + // List of events for the selected day. + RecyclerView recyclerView = (RecyclerView) v.findViewById(R.id.rvRecordings); + RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getActivity()); + recyclerView.setLayoutManager(layoutManager); + scheduledRecordings = new ArrayList<>(); + adapter = new ScheduledRecordingsFragmentItemAdapter(scheduledRecordings, this, recyclerView); + recyclerView.setAdapter(adapter); + // Selected day. + tvDate = (TextView) v.findViewById(R.id.tvDate); + // Add new scheduled recording button. + FloatingActionButton mRecordButton = (FloatingActionButton) v.findViewById(R.id.fab_add); + mRecordButton.setColorNormal(ContextCompat.getColor(getActivity(), R.color.primary)); + mRecordButton.setColorPressed(ContextCompat.getColor(getActivity(), R.color.primary_dark)); + mRecordButton.setOnClickListener(addScheduledRecordingListener); + + subscribeToGetRecordingsObservable(); + + return v; + } + + // Listener for the CompactCalendarView. + private final CompactCalendarView.CompactCalendarViewListener myCalendarViewListener = new CompactCalendarView.CompactCalendarViewListener() { + @Override + public void onDayClick(Date date) { + selectedDate = date; + displayScheduledRecordings(date); + } + + @Override + public void onMonthScroll(Date date) { + DateFormat dateFormat = new SimpleDateFormat("MMMM", Locale.getDefault()); + String month = dateFormat.format(date); + tvMonth.setText(month); + } + }; + + // Display the list of scheduled recordings for the selected day. + private void displayScheduledRecordings(Date date) { + List events = calendarView.getEvents(date.getTime()); + // Put the events in a list of ScheduledRecordingItem suitable for the adapter. + scheduledRecordings.clear(); + for (Event event : events) { + scheduledRecordings.add((ScheduledRecordingItem) event.getData()); + } + Collections.sort(scheduledRecordings); + ((ScheduledRecordingsFragmentItemAdapter) adapter).setItems(scheduledRecordings); + adapter.notifyDataSetChanged(); + + tvDate.setText(new SimpleDateFormat("EEEE d", Locale.getDefault()).format(date)); + } + + // Get all scheduled recordings and provide them. + private final Single> getRecordingsSingle = Single.create(emitter -> { + try { + List recordings = dbHelper.getAllScheduledRecordings(); + emitter.onSuccess(recordings); + } catch (Exception e) { + emitter.onError(e); + } + }); + + private void subscribeToGetRecordingsObservable() { + Disposable subscribe = getRecordingsSingle + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::updateUI, + throwable -> Log.e(TAG, throwable.getMessage()) + ); + compositeDisposable.add(subscribe); + } + + // Update the UI with the list of scheduled recordings. + private void updateUI(List scheduledRecordings) { + calendarView.removeAllEvents(); + for (ScheduledRecordingItem item : scheduledRecordings) { + Event event = new Event(ContextCompat.getColor(getActivity(), R.color.accent), item.getStart(), item); + calendarView.addEvent(event, false); + } + calendarView.invalidate(); // refresh the calendar view + myCalendarViewListener.onDayClick(selectedDate); // click to show current day + } + + // Click listener for the elements of the RecyclerView (for editing scheduled recordings). + @Override + public void onItemClick(ScheduledRecordingItem item) { + Intent intent = AddScheduledRecordingActivity.makeIntent(getActivity(), item); + startActivityForResult(intent, EDIT_SCHEDULED_RECORDING); + } + + // Long click listener for the elements of the RecyclerView (for deleting or renaming scheduled recordings). + @Override + public void onItemLongClick(ScheduledRecordingItem item) { + // Item delete confirm + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getString(R.string.dialog_title_delete)); + builder.setMessage(R.string.dialog_text_delete_generic); + builder.setPositiveButton(R.string.dialog_action_ok, + (dialogInterface, i) -> { + final Single deleteItemSingle = Single.create(emitter -> { + int numDeleted = dbHelper.removeScheduledRecording(item.getId()); + emitter.onSuccess(numDeleted); + }); + Disposable subscribe = deleteItemSingle + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::deleteItemCompleted); + compositeDisposable.add(subscribe); + }); + builder.setCancelable(true); + builder.setNegativeButton(getString(R.string.dialog_action_cancel), + (dialog, id) -> dialog.cancel()); + + AlertDialog alert = builder.create(); + alert.show(); + } + + // The item has just been deleted. + private void deleteItemCompleted(int numDeleted) { + Activity activity = getActivity(); + if (numDeleted > 0) { + Toast.makeText(activity, activity.getString(R.string.toast_scheduledrecording_deleted), Toast.LENGTH_SHORT).show(); + subscribeToGetRecordingsObservable(); + activity.startService(ScheduledRecordingService.makeIntent(activity, false)); + } else { + Toast.makeText(activity, activity.getString(R.string.toast_scheduledrecording_deleted_error), Toast.LENGTH_SHORT).show(); + } + } + + // Click listener of the button to add a new scheduled recording. + private final View.OnClickListener addScheduledRecordingListener = view -> checkPermissions(); + + // Check dangerous permissions for Android Marshmallow+. + @SuppressWarnings("ConstantConditions") + private void checkPermissions() { + // Check permissions. + boolean writePerm = ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + boolean audioPerm = ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + String[] arrPermissions; + if (!writePerm && !audioPerm) { + arrPermissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO}; + } else if (!writePerm && audioPerm) { + arrPermissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + } else if (writePerm && !audioPerm) { + arrPermissions = new String[]{Manifest.permission.RECORD_AUDIO}; + } else { + startAddScheduledRecordingActivity(); + return; + } + + // Request permissions. + FragmentCompat.requestPermissions(this, arrPermissions, REQUEST_DANGEROUS_PERMISSIONS); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + boolean granted = true; + for (int grantResult : grantResults) { // we nee all permissions granted + if (grantResult != PackageManager.PERMISSION_GRANTED) + granted = false; + } + + if (granted) + startAddScheduledRecordingActivity(); + else + Toast.makeText(getActivity(), getString(R.string.toast_permissions_denied), Toast.LENGTH_LONG).show(); + } + + private void startAddScheduledRecordingActivity() { + Intent intent = AddScheduledRecordingActivity.makeIntent(getActivity(), selectedDate.getTime()); + startActivityForResult(intent, ADD_SCHEDULED_RECORDING); + } + + // After a new scheduled recording has been added, get all the recordings and refresh the layout. + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if ((requestCode == ADD_SCHEDULED_RECORDING || requestCode == EDIT_SCHEDULED_RECORDING) && resultCode == Activity.RESULT_OK) { + subscribeToGetRecordingsObservable(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (!compositeDisposable.isDisposed()) + compositeDisposable.dispose(); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/ScheduledRecordingsFragmentItemAdapter.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/ScheduledRecordingsFragmentItemAdapter.java new file mode 100644 index 00000000..78433c30 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/ScheduledRecordingsFragmentItemAdapter.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.fragments; + +import android.graphics.Color; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.danielkim.soundrecorder.R; +import com.danielkim.soundrecorder.ScheduledRecordingItem; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * Adapter of the RecyclerView within ScheduledRecordingsFragment. + */ + +public class ScheduledRecordingsFragmentItemAdapter extends RecyclerView.Adapter { + + public interface MyOnItemClickListener { + void onItemClick(ScheduledRecordingItem item); + + void onItemLongClick(ScheduledRecordingItem item); + } + + + // ViewHolder. + public class ItemViewHolder extends RecyclerView.ViewHolder { + private final TextView tvStart; + private final TextView tvEnd; + private final TextView tvColor; + + public ItemViewHolder(View v) { + super(v); + tvStart = (TextView) v.findViewById(R.id.tvStart); + tvEnd = (TextView) v.findViewById(R.id.tvEnd); + tvColor = (TextView) v.findViewById(R.id.tvColor); + + v.setOnClickListener(view -> { + int pos = recyclerView.getChildAdapterPosition(view); + if (pos >= 0 && pos < getItemCount()) { + listener.onItemClick(items.get(pos)); + } + }); + + v.setOnLongClickListener(view -> { + int pos = recyclerView.getChildAdapterPosition(view); + if (pos >= 0 && pos < getItemCount()) { + listener.onItemLongClick(items.get(pos)); + } + return true; + }); + } + } + + // Adapter. + private List items; + private final MyOnItemClickListener listener; + private final RecyclerView recyclerView; + private final int[] colors = {Color.argb(255, 255, 193, 7), + Color.argb(255, 244, 67, 54), Color.argb(255, 99, 233, 112), + Color.argb(255, 7, 168, 255), Color.argb(255, 255, 7, 251), + Color.argb(255, 255, 61, 7), Color.argb(255, 205, 7, 255)}; + + public ScheduledRecordingsFragmentItemAdapter(List items, MyOnItemClickListener listener, RecyclerView recyclerView) { + this.items = items; + this.recyclerView = recyclerView; + this.listener = listener; + } + + public void setItems(List items) { + this.items = items; + } + + @Override + public int getItemCount() { + return items.size(); + } + + @Override + public ItemViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) { + View view = LayoutInflater + .from(viewGroup.getContext()) + .inflate(R.layout.fragment_scheduled_recordings_item, viewGroup, false); + + return new ItemViewHolder(view); + } + + @Override + public void onBindViewHolder(ItemViewHolder holder, int position) { + ScheduledRecordingItem item = items.get(position); + if (item == null) return; + DateFormat dateFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); + holder.tvStart.setText(dateFormat.format(new Date(item.getStart()))); + holder.tvEnd.setText(dateFormat.format(new Date(item.getEnd()))); + + int posCol = position % (colors.length); + holder.tvColor.setBackgroundColor(colors[posCol]); + } +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java index fc0d47f0..624e128e 100644 --- a/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/SettingsFragment.java @@ -37,7 +37,7 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { @Override public boolean onPreferenceClick(Preference preference) { LicensesFragment licensesFragment = new LicensesFragment(); - licensesFragment.show(((SettingsActivity)getActivity()).getSupportFragmentManager().beginTransaction(), "dialog_licenses"); + licensesFragment.show(((SettingsActivity) getActivity()).getFragmentManager().beginTransaction(), "dialog_licenses"); return true; } }); diff --git a/app/src/main/java/com/danielkim/soundrecorder/fragments/TimePickerFragment.java b/app/src/main/java/com/danielkim/soundrecorder/fragments/TimePickerFragment.java new file mode 100644 index 00000000..c579b4dd --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/fragments/TimePickerFragment.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.fragments; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.TimePickerDialog; +import android.content.Context; +import android.os.Bundle; +import android.text.format.DateFormat; +import android.widget.TimePicker; + +/** + * Shows a dialog to pick a time (hour and minute). + * Communicates the time selected through an interface. + * This class stores and communicates the id of the view that needs the time, so it can be used + * for different views within the same Activity/Fragment. + */ + +public class TimePickerFragment extends DialogFragment implements TimePickerDialog.OnTimeSetListener { + private static final String ARG_VIEW_ID = "ARG_VIEW_ID"; + private static final String ARG_HOUR = "ARG_HOUR"; + private static final String ARG_MINUTE = "ARG_MINUTE"; + + + private MyOnTimeSetListener listener; + + public static TimePickerFragment newInstance(long viewId, int hour, int minute) { + TimePickerFragment f = new TimePickerFragment(); + Bundle bundle = new Bundle(); + bundle.putLong(ARG_VIEW_ID, viewId); + bundle.putInt(ARG_HOUR, hour); + bundle.putInt(ARG_MINUTE, minute); + f.setArguments(bundle); + + return f; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + int hour = getArguments().getInt(ARG_HOUR); + int minute = getArguments().getInt(ARG_MINUTE); + + return new TimePickerDialog(getActivity(), this, hour, minute, DateFormat.is24HourFormat(getActivity())); + } + + @TargetApi(23) + @Override + public void onAttach(Context context) { + super.onAttach(context); + + try { + listener = (TimePickerFragment.MyOnTimeSetListener) context; + } catch (ClassCastException e) { + throw new ClassCastException(context.toString() + "must implement TimePickerFragment.MyOnTimeSetListener"); + } + } + + @SuppressWarnings("deprecation") + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + listener = (TimePickerFragment.MyOnTimeSetListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity.toString() + "must implement TimePickerFragment.MyOnTimeSetListener"); + } + } + + @Override + public void onTimeSet(TimePicker timePicker, int hourOfDay, int minute) { + if (listener != null) { + listener.onTimeSet(getArguments().getLong(ARG_VIEW_ID, 0), hourOfDay, minute); + } + } + + // Interface form communication with the Activity. + public interface MyOnTimeSetListener { + void onTimeSet(long viewId, int hour, int minute); + } + +} diff --git a/app/src/main/java/com/danielkim/soundrecorder/utils/Utils.java b/app/src/main/java/com/danielkim/soundrecorder/utils/Utils.java new file mode 100644 index 00000000..a0c1f797 --- /dev/null +++ b/app/src/main/java/com/danielkim/soundrecorder/utils/Utils.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2017 Claudio "iClaude" Agostini + * Licensed under the Apache License, Version 2.0 + */ + +package com.danielkim.soundrecorder.utils; + +import android.content.Context; +import android.os.Environment; + +import java.io.File; + +/** + * Common utility methods. + */ + +public class Utils { + + /* + Get, or create if necessary, the path of the directory where to save recordings. + */ + public static String getDirectoryPath(Context context) { + String directoryPath; + + if (isExternalStorageWritable()) { + directoryPath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/SoundRecorder"; + File f = new File(directoryPath); + boolean available = f.mkdirs() || f.isDirectory(); + if (available) + return directoryPath; + } + + return context.getFilesDir().getAbsolutePath(); + } + + private static boolean isExternalStorageWritable() { + String state = Environment.getExternalStorageState(); + return Environment.MEDIA_MOUNTED.equals(state) || Environment.MEDIA_MOUNTED_READ_ONLY.equals(state); + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png new file mode 100644 index 00000000..694179bd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png new file mode 100644 index 00000000..3856041d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-nodpi/example_picture.png b/app/src/main/res/drawable-nodpi/example_picture.png new file mode 100644 index 00000000..e0627f53 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/example_picture.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png new file mode 100644 index 00000000..67bb598e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png new file mode 100644 index 00000000..0fdced8f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_white_24dp.png differ diff --git a/app/src/main/res/drawable/ic_access_time_black_24dp.xml b/app/src/main/res/drawable/ic_access_time_black_24dp.xml new file mode 100644 index 00000000..60b9f126 --- /dev/null +++ b/app/src/main/res/drawable/ic_access_time_black_24dp.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/record_progress_bar.xml b/app/src/main/res/drawable/record_progress_bar.xml index 59892fd7..818195bd 100644 --- a/app/src/main/res/drawable/record_progress_bar.xml +++ b/app/src/main/res/drawable/record_progress_bar.xml @@ -4,4 +4,5 @@ android:shape="oval"> + diff --git a/app/src/main/res/drawable/record_progress_bar_background.xml b/app/src/main/res/drawable/record_progress_bar_background.xml index fb9906b8..0a893be9 100644 --- a/app/src/main/res/drawable/record_progress_bar_background.xml +++ b/app/src/main/res/drawable/record_progress_bar_background.xml @@ -4,4 +4,5 @@ android:shape="oval"> + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_add_scheduled_recording.xml b/app/src/main/res/layout/activity_add_scheduled_recording.xml new file mode 100644 index 00000000..35de5db2 --- /dev/null +++ b/app/src/main/res/layout/activity_add_scheduled_recording.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_preferences.xml b/app/src/main/res/layout/activity_preferences.xml index 8082627b..d0e6e34b 100644 --- a/app/src/main/res/layout/activity_preferences.xml +++ b/app/src/main/res/layout/activity_preferences.xml @@ -1,9 +1,11 @@ + + 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..e4cbd8b4 100644 --- a/app/src/main/res/layout/card_view.xml +++ b/app/src/main/res/layout/card_view.xml @@ -1,7 +1,8 @@ + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + android:layout_marginRight="7dp" + android:src="@drawable/ic_fileviewer" /> + android:layout_gravity="center_vertical" + android:orientation="vertical"> + android:textStyle="bold" /> + android:text="00:00" + android:textSize="12sp" /> + android:textSize="12sp" /> diff --git a/app/src/main/res/layout/fragment_licenses.xml b/app/src/main/res/layout/fragment_licenses.xml index 40945993..6ec7b0a3 100644 --- a/app/src/main/res/layout/fragment_licenses.xml +++ b/app/src/main/res/layout/fragment_licenses.xml @@ -20,7 +20,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="15sp" - android:text="@string/link"/> + android:text="@string/link" /> - + android:layout_marginBottom="64dp" + android:fontFamily="sans-serif-light" + android:text="00:00" + android:textColor="@color/primary_text" + android:textSize="60sp" /> diff --git a/app/src/main/res/layout/fragment_scheduled_recordings.xml b/app/src/main/res/layout/fragment_scheduled_recordings.xml new file mode 100644 index 00000000..e2ad0582 --- /dev/null +++ b/app/src/main/res/layout/fragment_scheduled_recordings.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_scheduled_recordings_item.xml b/app/src/main/res/layout/fragment_scheduled_recordings_item.xml new file mode 100644 index 00000000..cb42741a --- /dev/null +++ b/app/src/main/res/layout/fragment_scheduled_recordings_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_addscheduledrecording.xml b/app/src/main/res/menu/menu_addscheduledrecording.xml new file mode 100644 index 00000000..d806d7fd --- /dev/null +++ b/app/src/main/res/menu/menu_addscheduledrecording.xml @@ -0,0 +1,13 @@ + + +

+ + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index bbf51a05..d1c27f5c 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -1,6 +1,4 @@ - Registratore suoni Licenze Impostazioni + Salva Registra Registrazioni salvate + Registrazioni pianificate + Inizio registrazione Registazione salvata in %1$s eliminato con successo Il file %1$s già esiste. Scegliere un nome del file differente. + L\'ora iniziale della registrazione deve essere precedente all\'ora finale! + Non è possibile pianificare una registrazione nel passato! + La registrazione pianificata è stata salvata + Errore nel salvataggio della registrazione pianificata! + Un\'altra registrazione è già pianificata per l\'orario selezionato! + Errore nell\'eliminazione della registrazione pianificata! + Elemento cancellato con successo + L\'app non funzionerà senza i permessi richiesti! + Registrazione... + SoundRecorder sta registrando... Conferma cancellazione Sei sicuro di voler cancellare questo file? + Sei sicuro di voler cancellare questo elemento? Licenze open source + Condividi File Rinomina file Opzioni + Condividi File Rinomina file Elimina file - Elimina + Modifica + Elimina elemento + Annulla OK No @@ -38,4 +56,27 @@ Premi il bottone per iniziare a registrare Registrazione in corso + Invia a + + + Registrazioni pianificate + registrazione pianificata + nessuna registrazione + Pianifica registrazione + + Nuovo messaggio: %1$s + + + You said %1$s and lorem ipsum + dolor sit amet, consectetur adipiscing elit. Etiam non enim magna. Morbi dictum, velit vel + semper venenatis, magna odio volutpat velit, at ullamcorper nulla lacus sed turpis. + Pellentesque vitae metus elit, nec tincidunt tellus. Integer sed nisl sem, ullamcorper + ornare lacus. Duis ac mauris sed massa congue volutpat. Donec sed erat sit amet turpis + viverra rhoncus sit amet nec magna. Donec lacinia ligula at libero volutpat volutpat nec nec + tortor. + + + Condividi + Rispondi + diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml index 07b371ca..c67cc36a 100644 --- a/app/src/main/res/values-v21/styles.xml +++ b/app/src/main/res/values-v21/styles.xml @@ -1,10 +1,10 @@ - + + + + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 6b806acf..cbd0f63d 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -3,9 +3,9 @@ + android:summary="@string/pref_high_quality_desc" /> + android:summary="@string/pref_about_desc" /> \ No newline at end of file diff --git a/app/src/test/java/com/danielkim/soundrecorder/AlarmManagerTest.java b/app/src/test/java/com/danielkim/soundrecorder/AlarmManagerTest.java new file mode 100644 index 00000000..ee5f8232 --- /dev/null +++ b/app/src/test/java/com/danielkim/soundrecorder/AlarmManagerTest.java @@ -0,0 +1,104 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.app.AlarmManager; +import android.content.Context; +import android.content.Intent; + +import com.danielkim.soundrecorder.database.DBHelper; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlarmManager; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNull; +import static org.robolectric.Shadows.shadowOf; + +/** + * Tests the AlarmManager used the schedule recordings. + */ + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class) +public class AlarmManagerTest { + + private Context context; + private AlarmManager alarmManager; + private ShadowAlarmManager shadowAlarmManager; + + @Before + public void setUp() throws Exception { + context = RuntimeEnvironment.application.getApplicationContext(); + alarmManager = (AlarmManager) RuntimeEnvironment.application.getSystemService(Context.ALARM_SERVICE); + shadowAlarmManager = shadowOf(alarmManager); + } + + // Test with empty database: the AlarmManager should be empty. + @Test + public void testEmptyDatabase() throws Exception { + // Clear the database. + DBHelper dbHelper = new DBHelper(context); + dbHelper.restoreDatabase(); + + // Start the service. + MockScheduledRecordingService mockService = new MockScheduledRecordingService(context, alarmManager); + mockService.onCreate(); + Intent intent = ScheduledRecordingService.makeIntent(context, false); + mockService.onStartCommand(intent, 0, 0); + mockService.onDestroy(); + + assertNull(shadowAlarmManager.getNextScheduledAlarm()); + } + + @Test + public void test3Alarms() throws Exception { + // Insert 3 records in the database. + DBHelper dbHelper = new DBHelper(context); + dbHelper.restoreDatabase(); + dbHelper.addScheduledRecording(0, 100); + dbHelper.addScheduledRecording(200, 300); + dbHelper.addScheduledRecording(500, 600); + + // Test the service to schedule the alarms. + MockScheduledRecordingService mockService = new MockScheduledRecordingService(context, alarmManager); + mockService.onCreate(); + Intent intent = ScheduledRecordingService.makeIntent(context, false); + mockService.onStartCommand(intent, 0, 0); + mockService.onStartCommand(intent, 0, 0); + mockService.onStartCommand(intent, 0, 0); + mockService.onDestroy(); + + // Checks. + assertEquals(1, shadowAlarmManager.getScheduledAlarms().size()); + + // Test the type and times of the alarms. + ShadowAlarmManager.ScheduledAlarm scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm(); + assertEquals(AlarmManager.RTC_WAKEUP, scheduledAlarm.type); + assertEquals(0, scheduledAlarm.triggerAtTime); + + // Test with other settings. + dbHelper.restoreDatabase(); + dbHelper.addScheduledRecording(500, 600); + intent = ScheduledRecordingService.makeIntent(context, false); + mockService.onStartCommand(intent, 0, 0); + mockService.onStartCommand(intent, 0, 0); + mockService.onStartCommand(intent, 0, 0); + mockService.onDestroy(); + + // Checks. + assertEquals(1, shadowAlarmManager.getScheduledAlarms().size()); + + // Test the type and times of the alarms. + scheduledAlarm = shadowAlarmManager.getNextScheduledAlarm(); + assertEquals(AlarmManager.RTC_WAKEUP, scheduledAlarm.type); + assertEquals(500, scheduledAlarm.triggerAtTime); + } +} diff --git a/app/src/test/java/com/danielkim/soundrecorder/BootUpReceiverTest.java b/app/src/test/java/com/danielkim/soundrecorder/BootUpReceiverTest.java new file mode 100644 index 00000000..5435d75c --- /dev/null +++ b/app/src/test/java/com/danielkim/soundrecorder/BootUpReceiverTest.java @@ -0,0 +1,86 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.content.BroadcastReceiver; +import android.content.Intent; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowApplication; + +import java.util.List; + + +/** + * Tests the BootupReceiver (used to start a Service to schedule the next recording with an + * AlarmManager). + */ + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class) +public class BootUpReceiverTest { + + /** + * Let's first test if the BroadcastReceiver, which was defined in the manifest, is correctly + * load in our tests + */ + @Test + public void testBroadcastReceiverRegistered() { + List registeredReceivers = ShadowApplication.getInstance().getRegisteredReceivers(); + Assert.assertFalse(registeredReceivers.isEmpty()); + + boolean receiverFound = false; + for (ShadowApplication.Wrapper wrapper : registeredReceivers) { + if (!receiverFound) + receiverFound = BootUpReceiver.class.getSimpleName().equals( + wrapper.broadcastReceiver.getClass().getSimpleName()); + } + Assert.assertTrue(receiverFound); //will be false if not found + } + + /** + * We defined the Broadcast receiver with a certain action, so we should check if we have + * receivers listening to the defined action + */ + @Test + public void testBroadcastReceiverAction() { + Intent intent = new Intent("android.intent.action.BOOT_COMPLETED"); + ShadowApplication shadowApplication = ShadowApplication.getInstance(); + Assert.assertTrue(shadowApplication.hasReceiverForIntent(intent)); + } + + /** + * Test onReceive method of the BroadcastReceiver: test that our receiver starts the + * ScheduledRecordingService service. + */ + @Test + public void testBroadcastReceiverStartService() { + ShadowApplication shadowApplication = ShadowApplication.getInstance(); + + // First find the BootUpReceiver. + Intent intent = new Intent("android.intent.action.BOOT_COMPLETED"); + List registeredReceivers = shadowApplication.getReceiversForIntent(intent); + BootUpReceiver bootUpReceiver = null; + for (BroadcastReceiver receiver : registeredReceivers) { + if (BootUpReceiver.class.getSimpleName().equals( + receiver.getClass().getSimpleName())) { + bootUpReceiver = (BootUpReceiver) receiver; + } + } + Assert.assertFalse(bootUpReceiver == null); + + // Test onReceive method of BootUpReceiver. + bootUpReceiver.onReceive(shadowApplication.getApplicationContext(), intent); + Intent serviceIntent = shadowApplication.peekNextStartedService(); + Assert.assertEquals("Expected the ScheduledRecordingService service to be invoked", + ScheduledRecordingService.class.getCanonicalName(), + serviceIntent.getComponent().getClassName()); + } + +} diff --git a/app/src/test/java/com/danielkim/soundrecorder/MockScheduledRecordingService.java b/app/src/test/java/com/danielkim/soundrecorder/MockScheduledRecordingService.java new file mode 100644 index 00000000..68783f69 --- /dev/null +++ b/app/src/test/java/com/danielkim/soundrecorder/MockScheduledRecordingService.java @@ -0,0 +1,40 @@ +/* + * Year: 2017. This class was added by iClaude. + */ + +package com.danielkim.soundrecorder; + +import android.app.AlarmManager; +import android.content.Context; +import android.content.Intent; + +/** + * Created by iClaude on 26/07/2017. + * This is a mock class of ScheduledRecordingService created to test the service with + * Robolectric. + * In this mock class you provide a Context and an AlarmManager through the constructor. The + * alarms are set in the main thread, while in the original service they are set in a + * background thread. + */ + +public class MockScheduledRecordingService extends ScheduledRecordingService { + + public MockScheduledRecordingService(Context context, AlarmManager alarmManager) { + this.context = context; + this.alarmManager = alarmManager; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + onStartCommandCalls++; // just for testing + + // Is this a wakeful Service? In this case we have to release the wake-lock at the end. + wakeful = intent.getBooleanExtra(EXTRA_WAKEFUL, false); + startIntent = intent; + + resetAlarmManager(); // cancel all pending alarms + scheduleNextRecording(); + + return START_REDELIVER_INTENT; + } +} diff --git a/build.gradle b/build.gradle index ea98e44f..c9644bc1 100644 --- a/build.gradle +++ b/build.gradle @@ -3,9 +3,13 @@ buildscript { repositories { jcenter() + + maven { + url 'https://maven.google.com' + } } dependencies { - classpath 'com.android.tools.build:gradle:2.3.2' + classpath 'com.android.tools.build:gradle:3.0.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -15,5 +19,13 @@ buildscript { allprojects { repositories { jcenter() + maven { + url "https://maven.google.com" + } + } + + configurations.all { + resolutionStrategy.force 'com.android.support:support-annotations:25.3.1' + resolutionStrategy.force 'com.google.code.findbugs:jsr305:3.0.1' } } diff --git a/gradle.properties b/gradle.properties index 1d3591c8..99b794b8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,11 @@ # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx10248m -XX:MaxPermSize=256m # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx1536M # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true \ No newline at end of file +# org.gradle.parallel=true +# TODO: delete this and check that java tests work correctly +android.enableAapt2=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b111db67..abc05dd1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon May 22 14:33:42 EDT 2017 +#Sun Oct 29 18:58:48 CET 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-rc-1-all.zip diff --git a/others/notes b/others/notes new file mode 100644 index 00000000..e987993b --- /dev/null +++ b/others/notes @@ -0,0 +1,2 @@ +things to fix: +- ... \ No newline at end of file