diff --git a/.gitignore b/.gitignore index 9068cd01..951dfad3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,36 @@ -# 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 - -# Proguard folder generated by Eclipse -proguard/ - -# Log Files -*.log +# 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 + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# IDEs +.idea/ +.vscode/ + +# Ignore Markdown files +*.md + +# Ignore Claude agent files +.claude/ diff --git a/README.md b/README.md index 54841d18..4e41987b 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,44 @@ -Echo -==== +# Echo - Never Miss a Moment -Time travelling recorder for Android. -It is free/libre and gratis software. +Echo is a modern Android application that continuously records audio in the background, allowing you to go back in time and save moments that have already happened. Whether it's a brilliant idea, a funny quote, or an important note, Echo ensures you never miss it. -Download ---- +## Features -* [F-Droid](https://f-droid.org/repository/browse/?fdid=eu.mrogalski.saidit) +* **Continuous Background Recording:** Echo runs silently in the background, keeping a rolling buffer of the last few hours of audio. +* **Save Clips from the Past:** Instantly save audio clips of various lengths from the buffered memory. +* **Auto-Save:** Automatically save recordings when the memory buffer is full, ensuring you never lose important audio. +* **Modern, Intuitive Interface:** A clean, professional design built with Material You principles. +* **Customizable Settings:** Adjust the audio quality and memory usage to fit your needs. -Building ---- +## Getting Started -1. Install gradle-1.10 (version is important) -1. Install SDK platform API 21 and 21.0.2 build-tools, with either [sdkmanager](https://developer.android.com/studio/command-line/sdkmanager) or [Android Studio](https://developer.android.com/studio) -1. Create a Key Store - [Instructions](http://stackoverflow.com/questions/3997748/how-can-i-create-a-keystore) -1. Fill Key Store details in `SaidIt/build.gradle` -1. From this directory run `gradle installDebug` - to install it on a phone or `gradle assembleRelease` - to generate signed APK +### Prerequisites -If you had any issues and fixed them, please correct these instructions in your fork! +* Android Studio +* Java Development Kit (JDK) + +### Building the Project + +1. **Clone the repository:** + ```bash + git clone https://github.com/mafik/echo.git + ``` +2. **Open the project in Android Studio.** +3. **Create a `local.properties` file** in the root of the project and add the following line, pointing to your Android SDK location: + ``` + sdk.dir=/path/to/your/android/sdk + ``` +4. **Build the project:** + * From the command line, run: + ```bash + ./gradlew assembleDebug + ``` + * Or, use the "Build" menu in Android Studio. + +## Contributing + +We welcome contributions! Please feel free to open an issue or submit a pull request. + +## Future Development + +For a detailed roadmap of planned features and improvements, please see the [`spec.md`](spec.md) file. diff --git a/SaidIt/build.gradle b/SaidIt/build.gradle index 4debc777..b53ee364 100644 --- a/SaidIt/build.gradle +++ b/SaidIt/build.gradle @@ -1,21 +1,31 @@ buildscript { repositories { + google() + mavenCentral() maven { url "https://repo.maven.apache.org/maven2" } } dependencies { - classpath 'com.android.tools.build:gradle:0.11.+' + classpath 'com.android.tools.build:gradle:8.12.1' } } -apply plugin: 'android' +apply plugin: 'com.android.application' repositories { - jcenter() + mavenCentral() maven { url "https://maven.google.com" } } android { - compileSdkVersion 21 - buildToolsVersion "21.0.2" + namespace 'eu.mrogalski.saidit' + compileSdk 34 + + defaultConfig{ + applicationId "eu.mrogalski.saidit" + minSdk 30 + targetSdk 34 + versionCode 15 + versionName "2.0.0" + } signingConfigs { release { @@ -28,27 +38,35 @@ android { buildTypes { release { - runProguard true + minifyEnabled true proguardFile file('proguard.cfg') proguardFile getDefaultProguardFile('proguard-android-optimize.txt') signingConfig signingConfigs.release } debug { - signingConfig signingConfigs.release + //signingConfig signingConfigs.release } } - lintOptions { - // Or, if you prefer, you can continue to check for errors in release builds, - // but continue the build even when errors are found: + lint { abortOnError false } + buildFeatures { + buildConfig true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } dependencies { - compile fileTree(dir: 'libs', include: '*.jar') - compile 'com.android.support:appcompat-v7:21.0.3' - compile 'com.nineoldandroids:library:2.4.0' - compile 'com.android.support:support-v4:21.0.3' + implementation fileTree(dir: 'libs', include: '*.jar') + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.3' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.11.0' } diff --git a/SaidIt/src/androidTest/java/eu/mrogalski/saidit/ExampleInstrumentedTest.java b/SaidIt/src/androidTest/java/eu/mrogalski/saidit/ExampleInstrumentedTest.java new file mode 100644 index 00000000..8d920fb3 --- /dev/null +++ b/SaidIt/src/androidTest/java/eu/mrogalski/saidit/ExampleInstrumentedTest.java @@ -0,0 +1,25 @@ +package eu.mrogalski.saidit; + +import android.content.Context; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("eu.mrogalski.saidit", appContext.getPackageName()); + } +} diff --git a/SaidIt/src/androidTest/java/eu/mrogalski/saidit/SaidItFragmentTest.java b/SaidIt/src/androidTest/java/eu/mrogalski/saidit/SaidItFragmentTest.java new file mode 100644 index 00000000..b98b7151 --- /dev/null +++ b/SaidIt/src/androidTest/java/eu/mrogalski/saidit/SaidItFragmentTest.java @@ -0,0 +1,38 @@ +package eu.mrogalski.saidit; + +import androidx.test.espresso.action.ViewActions; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +@RunWith(AndroidJUnit4.class) +public class SaidItFragmentTest { + + @Rule + public ActivityScenarioRule activityRule = + new ActivityScenarioRule<>(SaidItActivity.class); + + @Test + public void testSaveClipFlow_showsProgressDialog() { + // 1. Click the "Save Clip" button to show the bottom sheet + onView(withId(R.id.save_clip_button)).perform(ViewActions.click()); + + // 2. In the bottom sheet, click a duration button. + // We'll assume the layout for the bottom sheet has buttons with text like "15 seconds" + // Let's click a common one, like "30 seconds" + onView(withText("30 seconds")).perform(ViewActions.click()); + + // 3. Verify that the "Saving Recording" progress dialog appears. + // The dialog is a system window, so we check for the title text. + onView(withText("Saving Recording")).check(matches(isDisplayed())); + } +} \ No newline at end of file diff --git a/SaidIt/src/main/AndroidManifest.xml b/SaidIt/src/main/AndroidManifest.xml index 41d47927..f572fd5b 100644 --- a/SaidIt/src/main/AndroidManifest.xml +++ b/SaidIt/src/main/AndroidManifest.xml @@ -1,18 +1,15 @@ - - - + + + + - + + + + android:label="@string/app_name" + android:exported="true"> @@ -49,16 +47,13 @@ - - + android:exported="false"> - + @@ -71,6 +66,34 @@ android:name="android.support.PARENT_ACTIVITY" android:value="SaidItActivity" /> + + + + + + + + + + + + + + diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/AacMp4Writer.java b/SaidIt/src/main/java/eu/mrogalski/saidit/AacMp4Writer.java new file mode 100644 index 00000000..6c316318 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/AacMp4Writer.java @@ -0,0 +1,143 @@ +package eu.mrogalski.saidit; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaMuxer; +import android.util.Log; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * Encodes PCM 16-bit mono to AAC-LC and writes into an MP4 (.m4a) container. + * Thread-safe for single-producer usage on an audio thread. + */ +public class AacMp4Writer implements Closeable { + private static final String TAG = "AacMp4Writer"; + private static final String MIME_TYPE = MediaFormat.MIMETYPE_AUDIO_AAC; // "audio/mp4a-latm" + + private final int sampleRate; + private final int channelCount; + private final int pcmBytesPerSample = 2; // 16-bit PCM + + private final MediaCodec encoder; + private final MediaMuxer muxer; + private final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + private boolean muxerStarted = false; + private int trackIndex = -1; + private long totalPcmBytesWritten = 0; + private long ptsUs = 0; // Monotonic presentation time in microseconds for input samples + + public AacMp4Writer(int sampleRate, int channelCount, int bitRate, File outFile) throws IOException { + this.sampleRate = sampleRate; + this.channelCount = channelCount; + + MediaFormat format = MediaFormat.createAudioFormat(MIME_TYPE, sampleRate, channelCount); + format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384); + + encoder = MediaCodec.createEncoderByType(MIME_TYPE); + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + encoder.start(); + + muxer = new MediaMuxer(outFile.getAbsolutePath(), MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + } + + public void write(byte[] data, int offset, int length) throws IOException { + int remaining = length; + int off = offset; + while (remaining > 0) { + int inIndex = encoder.dequeueInputBuffer(10000); + if (inIndex >= 0) { + ByteBuffer inBuf = encoder.getInputBuffer(inIndex); + if (inBuf == null) continue; + inBuf.clear(); + int toCopy = Math.min(remaining, inBuf.remaining()); + inBuf.put(data, off, toCopy); + long inputPts = ptsUs; + int sampleCount = toCopy / pcmBytesPerSample / channelCount; + ptsUs += (sampleCount * 1_000_000L) / sampleRate; + encoder.queueInputBuffer(inIndex, 0, toCopy, inputPts, 0); + off += toCopy; + remaining -= toCopy; + totalPcmBytesWritten += toCopy; + } else { + // No input buffer available, try draining and retry + drainEncoder(false); + } + } + drainEncoder(false); + } + + private void drainEncoder(boolean endOfStream) throws IOException { + if (endOfStream) { + int inIndex = encoder.dequeueInputBuffer(10000); + if (inIndex >= 0) { + encoder.queueInputBuffer(inIndex, 0, 0, ptsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } else { + // If we couldn't queue EOS now, we'll retry on next drain. + } + } + while (true) { + int outIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000); + if (outIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { + if (!endOfStream) break; + } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + if (muxerStarted) throw new IllegalStateException("Format changed twice"); + MediaFormat newFormat = encoder.getOutputFormat(); + trackIndex = muxer.addTrack(newFormat); + muxer.start(); + muxerStarted = true; + } else if (outIndex >= 0) { + ByteBuffer outBuf = encoder.getOutputBuffer(outIndex); + if (outBuf != null && bufferInfo.size > 0) { + outBuf.position(bufferInfo.offset); + outBuf.limit(bufferInfo.offset + bufferInfo.size); + if (!muxerStarted) { + // This should not happen, but guard anyway + Log.w(TAG, "Muxer not started when output available, dropping frame"); + } else { + muxer.writeSampleData(trackIndex, outBuf, bufferInfo); + } + } + encoder.releaseOutputBuffer(outIndex, false); + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + break; + } + } + } + } + + @Override + public void close() throws IOException { + try { + drainEncoder(true); + } catch (Exception e) { + Log.e(TAG, "Error finishing encoder", e); + } + try { + encoder.stop(); + } catch (Exception ignored) {} + try { + encoder.release(); + } catch (Exception ignored) {} + try { + if (muxerStarted) { + muxer.stop(); + } + } catch (Exception ignored) {} + try { + muxer.release(); + } catch (Exception ignored) {} + } + + public int getTotalSampleBytesWritten() { + // Safe cast, typical sizes will fit; use long if you prefer. + return (int)Math.min(Integer.MAX_VALUE, totalPcmBytesWritten); + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.java b/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.java index bf36438f..1118888b 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.java +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/AudioMemory.java @@ -1,139 +1,157 @@ -package eu.mrogalski.saidit; - -import android.os.SystemClock; - -import java.io.IOException; -import java.util.LinkedList; - -public class AudioMemory { - - private final LinkedList filled = new LinkedList(); - private final LinkedList free = new LinkedList(); - - private long fillingStartUptimeMillis; - private boolean filling = false; - private boolean currentWasFilled = false; - private byte[] current = null; - private int offset = 0; - private static final int CHUNK_SIZE = 1920000; // 20 seconds of 48kHz wav (single channel, 16-bit samples) (1875 kB) - - synchronized public void allocate(long sizeToEnsure) { - long currentSize = getAllocatedMemorySize(); - while(currentSize < sizeToEnsure) { - currentSize += CHUNK_SIZE; - free.addLast(new byte[CHUNK_SIZE]); - } - while(!free.isEmpty() && (currentSize - CHUNK_SIZE >= sizeToEnsure)) { - currentSize -= CHUNK_SIZE; - free.removeLast(); - } - while(!filled.isEmpty() && (currentSize - CHUNK_SIZE >= sizeToEnsure)) { - currentSize -= CHUNK_SIZE; - filled.removeFirst(); - } - if((current != null) && (currentSize - CHUNK_SIZE >= sizeToEnsure)) { - //currentSize -= CHUNK_SIZE; - current = null; - offset = 0; - currentWasFilled = false; - } - System.gc(); - } - - synchronized public long getAllocatedMemorySize() { - return (free.size() + filled.size() + (current == null ? 0 : 1)) * CHUNK_SIZE; - } - - public interface Consumer { - public int consume(byte[] array, int offset, int count) throws IOException; - } - - private int skipAndFeed(int bytesToSkip, byte[] arr, int offset, int length, Consumer consumer) throws IOException { - if(bytesToSkip >= length) { - return length; - } else if(bytesToSkip > 0) { - consumer.consume(arr, offset + bytesToSkip, length - bytesToSkip); - return bytesToSkip; - } - consumer.consume(arr, offset, length); - return 0; - } - - public void read(int skipBytes, Consumer reader) throws IOException { - synchronized (this) { - if(!filling && current != null && currentWasFilled) { - skipBytes -= skipAndFeed(skipBytes, current, offset, current.length - offset, reader); - } - for(byte[] arr : filled) { - skipBytes -= skipAndFeed(skipBytes, arr, 0, arr.length, reader); - } - if(current != null && offset > 0) { - skipAndFeed(skipBytes, current, 0, offset, reader); - } - } - } - - public int countFilled() { - int sum = 0; - synchronized (this) { - if(!filling && current != null && currentWasFilled) { - sum += current.length - offset; - } - for(byte[] arr : filled) { - sum += arr.length; - } - if(current != null && offset > 0) { - sum += offset; - } - } - return sum; - } - - public void fill(Consumer filler) throws IOException { - synchronized (this) { - if(current == null) { - if(free.isEmpty()) { - if(filled.isEmpty()) return; - currentWasFilled = true; - current = filled.removeFirst(); - } else { - currentWasFilled = false; - current = free.removeFirst(); - } - offset = 0; - } - filling = true; - fillingStartUptimeMillis = SystemClock.uptimeMillis(); - } - - final int read = filler.consume(current, offset, current.length - offset); - - synchronized (this) { - if(offset + read >= current.length) { - filled.addLast(current); - current = null; - offset = 0; - } else { - offset += read; - } - filling = false; - } - } - - public interface Observer { - public void observe(int filled, int total, int estimation, boolean overwriting); - } - - public void observe(Observer observer, int fillRate) { - final int estimation; - final int taken; - final int total; - synchronized (this) { - taken = filled.size() * CHUNK_SIZE + (current == null ? 0 : currentWasFilled ? CHUNK_SIZE : offset); - total = (filled.size() + free.size() + (current == null ? 0 : 1)) * CHUNK_SIZE; - estimation = (int) (filling ? (SystemClock.uptimeMillis() - fillingStartUptimeMillis) * fillRate / 1000 : 0); - } - observer.observe(taken, total, estimation, currentWasFilled); - } - -} +package eu.mrogalski.saidit; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public class AudioMemory { + + // Keep chunk size as allocation granularity (20s @ 48kHz mono 16-bit) + static final int CHUNK_SIZE = 1920000; // bytes + + private final Clock clock; + + // Ring buffer + private ByteBuffer ring; // direct buffer + private int capacity = 0; // bytes + private int writePos = 0; // next write index [0..capacity) + private int size = 0; // number of valid bytes stored (<= capacity) + + // Fill estimation + private long fillingStartUptimeMillis; + private boolean filling = false; + private boolean overwriting = false; + + // Reusable IO buffer to reduce allocations when interacting with AudioRecord/consumers + private byte[] ioBuffer = new byte[32 * 1024]; + + public AudioMemory(Clock clock) { + this.clock = clock; + } + + public interface Consumer { + int consume(byte[] array, int offset, int count) throws IOException; + } + + synchronized public void allocate(long sizeToEnsure) { + int required = 0; + while (required < sizeToEnsure) required += CHUNK_SIZE; + if (required == capacity) return; // no change + + // Allocate new ring; drop previous content to free memory pressure. + ring = (required > 0) ? ByteBuffer.allocateDirect(required) : null; + capacity = required; + writePos = 0; + size = 0; + overwriting = false; + } + + synchronized public long getAllocatedMemorySize() { + return capacity; + } + + public int countFilled() { + synchronized (this) { + return size; + } + } + + // Ensure ioBuffer is at least min bytes + private void ensureIoBuffer(int min) { + if (ioBuffer.length < min) { + int newLen = ioBuffer.length; + while (newLen < min) newLen = Math.min(Math.max(newLen * 2, 4096), 256 * 1024); + ioBuffer = new byte[newLen]; + } + } + + // Fill ring buffer with newly recorded data. Returns number of bytes read from the consumer. + public int fill(Consumer filler) throws IOException { + int totalRead = 0; + int read; + synchronized (this) { + if (capacity == 0 || ring == null) return 0; + filling = true; + fillingStartUptimeMillis = clock.uptimeMillis(); + } + + ensureIoBuffer(32 * 1024); + + // The filler might provide data in multiple chunks. + while ((read = filler.consume(ioBuffer, 0, ioBuffer.length)) > 0) { + synchronized (this) { + if (read > 0 && capacity > 0) { // check capacity again inside sync block + // Write into ring with wrap-around + int first = Math.min(read, capacity - writePos); + if (first > 0) { + ByteBuffer dup = ring.duplicate(); + dup.position(writePos); + dup.put(ioBuffer, 0, first); + } + int remaining = read - first; + if (remaining > 0) { + ByteBuffer dup = ring.duplicate(); + dup.position(0); + dup.put(ioBuffer, first, remaining); + } + writePos = (writePos + read) % capacity; + int newSize = size + read; + if (newSize > capacity) { + overwriting = true; + size = capacity; + } else { + size = newSize; + } + totalRead += read; + } else { + // capacity became 0, stop filling + break; + } + } + } + + synchronized (this) { + filling = false; + } + return totalRead; + } + + + public synchronized void dump(Consumer consumer, int bytesToDump) throws IOException { + if (capacity == 0 || ring == null || size == 0 || bytesToDump <= 0) return; + + int toCopy = Math.min(bytesToDump, size); + int skip = size - toCopy; // skip older bytes beyond window + + int start = (writePos - size + capacity) % capacity; // oldest + int readPos = (start + skip) % capacity; // first byte to output + + int remaining = toCopy; + while (remaining > 0) { + int chunk = Math.min(remaining, capacity - readPos); + // Copy out chunk into consumer via temporary array + ensureIoBuffer(chunk); + ByteBuffer dup = ring.duplicate(); + dup.position(readPos); + dup.get(ioBuffer, 0, chunk); + consumer.consume(ioBuffer, 0, chunk); + remaining -= chunk; + readPos = (readPos + chunk) % capacity; + } + } + + public static class Stats { + public int filled; // bytes stored + public int total; // capacity + public int estimation; // bytes assumed in flight since last fill started + public boolean overwriting; // whether we've wrapped at least once + } + + public synchronized Stats getStats(int fillRate) { + final Stats stats = new Stats(); + stats.filled = size; + stats.total = capacity; + stats.estimation = (int) (filling ? (clock.uptimeMillis() - fillingStartUptimeMillis) * fillRate / 1000 : 0); + stats.overwriting = overwriting; + return stats; + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/Clock.java b/SaidIt/src/main/java/eu/mrogalski/saidit/Clock.java new file mode 100644 index 00000000..3e147e09 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/Clock.java @@ -0,0 +1,5 @@ +package eu.mrogalski.saidit; + +public interface Clock { + long uptimeMillis(); +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/ErrorResponseDialog.java b/SaidIt/src/main/java/eu/mrogalski/saidit/ErrorResponseDialog.java deleted file mode 100644 index adb52dc6..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/ErrorResponseDialog.java +++ /dev/null @@ -1,8 +0,0 @@ -package eu.mrogalski.saidit; - -public class ErrorResponseDialog extends ThemedDialog { - @Override - int getShadowColorId() { - return R.color.dark_red; - } -} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/FakeService.java b/SaidIt/src/main/java/eu/mrogalski/saidit/FakeService.java deleted file mode 100644 index 92c42ae2..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/FakeService.java +++ /dev/null @@ -1,30 +0,0 @@ -package eu.mrogalski.saidit; - -import android.app.Notification; -import android.app.Service; -import android.content.Intent; -import android.os.IBinder; - -/** - * Created by marek on 15.12.13. - */ -public class FakeService extends Service { - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - - - Notification note = new Notification( 0, null, System.currentTimeMillis() ); - note.flags |= Notification.FLAG_NO_CLEAR; - startForeground(42, note); - stopForeground(true); - - stopSelf(); - - return super.onStartCommand(intent, flags, startId); - } -} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java new file mode 100644 index 00000000..843711e6 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java @@ -0,0 +1,29 @@ +package eu.mrogalski.saidit; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.viewpager2.widget.ViewPager2; +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +public class HowToActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_how_to); + + MaterialToolbar toolbar = findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(v -> finish()); + + ViewPager2 viewPager = findViewById(R.id.view_pager); + TabLayout tabLayout = findViewById(R.id.tab_layout); + + viewPager.setAdapter(new HowToPagerAdapter(this)); + + new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> tab.setText("Step " + (position + 1)) + ).attach(); + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java new file mode 100644 index 00000000..78ae06d0 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java @@ -0,0 +1,48 @@ +package eu.mrogalski.saidit; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +public class HowToPageFragment extends Fragment { + + private static final String ARG_POSITION = "position"; + + public static HowToPageFragment newInstance(int position) { + HowToPageFragment fragment = new HowToPageFragment(); + Bundle args = new Bundle(); + args.putInt(ARG_POSITION, position); + fragment.setArguments(args); + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_how_to_page, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + TextView textView = view.findViewById(R.id.how_to_text); + int position = getArguments().getInt(ARG_POSITION); + // Set text based on position + switch (position) { + case 0: + textView.setText("Step 1: Press the record button to start saving audio."); + break; + case 1: + textView.setText("Step 2: Press the save button to save the last few minutes of audio."); + break; + case 2: + textView.setText("Step 3: Access your saved recordings from the recordings manager."); + break; + } + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java new file mode 100644 index 00000000..69858054 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java @@ -0,0 +1,26 @@ +package eu.mrogalski.saidit; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +public class HowToPagerAdapter extends FragmentStateAdapter { + + private static final int NUM_PAGES = 3; // Example number of pages + + public HowToPagerAdapter(@NonNull FragmentActivity fragmentActivity) { + super(fragmentActivity); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return HowToPageFragment.newInstance(position); + } + + @Override + public int getItemCount() { + return NUM_PAGES; + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingDoneDialog.java b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingDoneDialog.java deleted file mode 100644 index 4a33bca9..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingDoneDialog.java +++ /dev/null @@ -1,107 +0,0 @@ -package eu.mrogalski.saidit; - -import android.app.Activity; -import android.content.Intent; -import android.content.res.Resources; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import java.io.File; -import java.net.URLConnection; - -import eu.mrogalski.StringFormat; -import eu.mrogalski.android.TimeFormat; - -public class RecordingDoneDialog extends ThemedDialog { - - private static final String KEY_RUNTIME = "runtime"; - private static final String KEY_FILE = "file"; - - private File file; - private float runtime; - private final TimeFormat.Result timeFormatResult = new TimeFormat.Result(); - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putFloat(KEY_RUNTIME, runtime); - outState.putString(KEY_FILE, file.getPath()); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if(savedInstanceState != null) { - if(savedInstanceState.containsKey(KEY_FILE)) - file = new File(savedInstanceState.getString(KEY_FILE)); - if(savedInstanceState.containsKey(KEY_RUNTIME)) - runtime = savedInstanceState.getFloat(KEY_RUNTIME); - } - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - - final View root = inflater.inflate(R.layout.recording_done_dialog, container); - assert root != null; - - fixFonts(root); - - final Activity activity = getActivity(); - assert activity != null; - final Resources resources = activity.getResources(); - TimeFormat.naturalLanguage(resources, runtime, timeFormatResult); - - ((TextView) root.findViewById(R.id.recording_done_filename)).setText(file.getName()); - ((TextView) root.findViewById(R.id.recording_done_dirname)).setText(file.getParent()); - ((TextView) root.findViewById(R.id.recording_done_runtime)).setText(timeFormatResult.text); - ((TextView) root.findViewById(R.id.recording_done_size)).setText(StringFormat.shortFileSize(file.length())); - - root.findViewById(R.id.recording_done_open_dir).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent intent = new Intent(Intent.ACTION_VIEW); - Uri uri = Uri.fromFile(file.getParentFile()); - intent.setData(uri); - startActivity(Intent.createChooser(intent, "Open folder")); - } - }); - - root.findViewById(R.id.recording_done_send).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file)); - shareIntent.setType(URLConnection.guessContentTypeFromName(file.getAbsolutePath())); - startActivity(Intent.createChooser(shareIntent, "Send to")); - } - }); - - root.findViewById(R.id.recording_done_play).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent intent = new Intent(); - intent.setAction(android.content.Intent.ACTION_VIEW); - intent.setDataAndType(Uri.fromFile(file), "audio/*"); - startActivity(intent); - } - }); - - return root; - } - - public RecordingDoneDialog setFile(File file) { - this.file = file; - return this; - } - - public RecordingDoneDialog setRuntime(float runtime) { - this.runtime = runtime; - return this; - } -} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingItem.java b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingItem.java new file mode 100644 index 00000000..ac31dd92 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingItem.java @@ -0,0 +1,33 @@ +package eu.mrogalski.saidit; + +import android.net.Uri; + +public class RecordingItem { + private final Uri uri; + private final String name; + private final long date; + private final long duration; + + public RecordingItem(Uri uri, String name, long date, long duration) { + this.uri = uri; + this.name = name; + this.date = date; + this.duration = duration; + } + + public Uri getUri() { + return uri; + } + + public String getName() { + return name; + } + + public long getDate() { + return date; + } + + public long getDuration() { + return duration; + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsActivity.java b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsActivity.java new file mode 100644 index 00000000..f1079975 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsActivity.java @@ -0,0 +1,93 @@ +package eu.mrogalski.saidit; + +import android.content.ContentUris; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import android.widget.TextView; +import com.google.android.material.appbar.MaterialToolbar; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RecordingsActivity extends AppCompatActivity { + + private RecordingsAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_recordings); + + MaterialToolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + toolbar.setNavigationOnClickListener(v -> finish()); + + RecyclerView recyclerView = findViewById(R.id.recordings_recycler_view); + TextView emptyView = findViewById(R.id.empty_view); + + // Load recordings and set up adapter + List recordings = getRecordings(); + adapter = new RecordingsAdapter(this, recordings); + recyclerView.setAdapter(adapter); + + if (recordings.isEmpty()) { + recyclerView.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + } else { + recyclerView.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + } + } + + private List getRecordings() { + List recordingItems = new ArrayList<>(); + String[] projection = new String[]{ + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.DURATION + }; + String selection = MediaStore.Audio.Media.MIME_TYPE + " IN (?, ?, ?)"; + String[] selectionArgs = new String[]{"audio/mp4", "audio/m4a", "audio/aac"}; + String sortOrder = MediaStore.Audio.Media.DATE_ADDED + " DESC"; + + try (Cursor cursor = getApplicationContext().getContentResolver().query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + )) { + if (cursor != null) { + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID); + int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME); + int dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED); + int durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION); + + while (cursor.moveToNext()) { + long id = cursor.getLong(idColumn); + String name = cursor.getString(nameColumn); + long date = cursor.getLong(dateColumn); + long duration = cursor.getLong(durationColumn); + Uri contentUri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id); + recordingItems.add(new RecordingItem(contentUri, name, date, duration)); + } + } + } + return recordingItems; + } + + @Override + protected void onStop() { + super.onStop(); + if (adapter != null) { + adapter.releasePlayer(); + } + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java new file mode 100644 index 00000000..bfd1141f --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java @@ -0,0 +1,254 @@ +package eu.mrogalski.saidit; + +import android.content.ContentResolver; +import android.content.Context; +import android.media.MediaPlayer; +import android.net.Uri; +import android.provider.MediaStore; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +public class RecordingsAdapter extends RecyclerView.Adapter { + + private static final int TYPE_HEADER = 0; + private static final int TYPE_ITEM = 1; + + private final List items; + private final Context context; + private MediaPlayer mediaPlayer; + private int playingPosition = -1; + + public RecordingsAdapter(Context context, List recordings) { + this.context = context; + this.items = groupRecordingsByDate(recordings); + } + + private List groupRecordingsByDate(List recordings) { + List groupedList = new ArrayList<>(); + if (recordings.isEmpty()) { + return groupedList; + } + + String lastHeader = ""; + for (RecordingItem recording : recordings) { + String header = getDayHeader(recording.getDate()); + if (!header.equals(lastHeader)) { + groupedList.add(header); + lastHeader = header; + } + groupedList.add(recording); + } + return groupedList; + } + + private String getDayHeader(long timestamp) { + Calendar now = Calendar.getInstance(); + Calendar timeToCheck = Calendar.getInstance(); + timeToCheck.setTimeInMillis(timestamp * 1000); + + if (now.get(Calendar.YEAR) == timeToCheck.get(Calendar.YEAR) && now.get(Calendar.DAY_OF_YEAR) == timeToCheck.get(Calendar.DAY_OF_YEAR)) { + return "Today"; + } else { + now.add(Calendar.DAY_OF_YEAR, -1); + if (now.get(Calendar.YEAR) == timeToCheck.get(Calendar.YEAR) && now.get(Calendar.DAY_OF_YEAR) == timeToCheck.get(Calendar.DAY_OF_YEAR)) { + return "Yesterday"; + } else { + return new SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(timeToCheck.getTime()); + } + } + } + + public void releasePlayer() { + if (mediaPlayer != null) { + mediaPlayer.release(); + mediaPlayer = null; + playingPosition = -1; + notifyDataSetChanged(); + } + } + + @Override + public int getItemViewType(int position) { + if (items.get(position) instanceof String) { + return TYPE_HEADER; + } + return TYPE_ITEM; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == TYPE_HEADER) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_header, parent, false); + return new HeaderViewHolder(view); + } + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_recording, parent, false); + return new RecordingViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (holder.getItemViewType() == TYPE_HEADER) { + HeaderViewHolder headerHolder = (HeaderViewHolder) holder; + headerHolder.bind((String) items.get(position)); + } else { + RecordingViewHolder itemHolder = (RecordingViewHolder) holder; + RecordingItem recording = (RecordingItem) items.get(position); + itemHolder.bind(recording); + + if (position == playingPosition) { + itemHolder.playButton.setIconResource(R.drawable.ic_pause); + } else { + itemHolder.playButton.setIconResource(R.drawable.ic_play_arrow); + } + } + } + + @Override + public int getItemCount() { + return items.size(); + } + + class RecordingViewHolder extends RecyclerView.ViewHolder { + private final TextView nameTextView; + private final TextView infoTextView; + private final MaterialButton playButton; + private final MaterialButton deleteButton; + + public RecordingViewHolder(@NonNull View itemView) { + super(itemView); + nameTextView = itemView.findViewById(R.id.recording_name_text); + infoTextView = itemView.findViewById(R.id.recording_info_text); + playButton = itemView.findViewById(R.id.play_button); + deleteButton = itemView.findViewById(R.id.delete_button); + } + + public void bind(RecordingItem recording) { + nameTextView.setText(recording.getName()); + + Date date = new Date(recording.getDate() * 1000); // MediaStore date is in seconds + SimpleDateFormat formatter = new SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()); + String dateString = formatter.format(date); + + long durationMillis = recording.getDuration(); + String durationString = String.format(Locale.getDefault(), "%02d:%02d", + TimeUnit.MILLISECONDS.toMinutes(durationMillis), + TimeUnit.MILLISECONDS.toSeconds(durationMillis) - + TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(durationMillis)) + ); + + infoTextView.setText(String.format("%s | %s", durationString, dateString)); + + playButton.setOnClickListener(v -> handlePlayback(recording, getAdapterPosition())); + + deleteButton.setOnClickListener(v -> { + new MaterialAlertDialogBuilder(context) + .setTitle("Delete Recording") + .setMessage("Are you sure you want to permanently delete this file?") + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton("Delete", (dialog, which) -> { + int currentPosition = getAdapterPosition(); + if (currentPosition != RecyclerView.NO_POSITION) { + // Stop playback if the deleted item is the one playing + if (playingPosition == currentPosition) { + releasePlayer(); + } + + RecordingItem itemToDelete = (RecordingItem) items.get(currentPosition); + ContentResolver contentResolver = context.getContentResolver(); + int deletedRows = contentResolver.delete(itemToDelete.getUri(), null, null); + + if (deletedRows > 0) { + items.remove(currentPosition); + notifyItemRemoved(currentPosition); + notifyItemRangeChanged(currentPosition, items.size()); + // Adjust playing position if an item before it was removed + if (playingPosition > currentPosition) { + playingPosition--; + } + + // Check if the header is now orphaned + if (currentPosition > 0 && items.get(currentPosition - 1) instanceof String) { + if (currentPosition == items.size() || items.get(currentPosition) instanceof String) { + items.remove(currentPosition - 1); + notifyItemRemoved(currentPosition - 1); + notifyItemRangeChanged(currentPosition - 1, items.size()); + if (playingPosition >= currentPosition) { + playingPosition--; + } + } + } + } + } + }) + .show(); + }); + } + + private void handlePlayback(RecordingItem recording, int position) { + if (playingPosition == position) { + if (mediaPlayer.isPlaying()) { + mediaPlayer.pause(); + playButton.setIconResource(R.drawable.ic_play_arrow); + } else { + mediaPlayer.start(); + playButton.setIconResource(R.drawable.ic_pause); + } + } else { + if (mediaPlayer != null) { + mediaPlayer.release(); + notifyItemChanged(playingPosition); + } + + int previousPlayingPosition = playingPosition; + playingPosition = position; + + if (previousPlayingPosition != -1) { + notifyItemChanged(previousPlayingPosition); + } + + mediaPlayer = new MediaPlayer(); + try { + mediaPlayer.setDataSource(context, recording.getUri()); + mediaPlayer.prepare(); + mediaPlayer.setOnCompletionListener(mp -> { + playingPosition = -1; + notifyItemChanged(position); + }); + mediaPlayer.start(); + playButton.setIconResource(R.drawable.ic_pause); + } catch (IOException e) { + e.printStackTrace(); + playingPosition = -1; + } + } + } + } + + class HeaderViewHolder extends RecyclerView.ViewHolder { + private final TextView headerTextView; + + public HeaderViewHolder(@NonNull View itemView) { + super(itemView); + headerTextView = itemView.findViewById(R.id.header_text_view); + } + + public void bind(String text) { + headerTextView.setText(text); + } + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItActivity.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItActivity.java index 949cc111..ed09b713 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItActivity.java +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItActivity.java @@ -1,28 +1,192 @@ package eu.mrogalski.saidit; -import android.app.Activity; +import android.Manifest; +import androidx.appcompat.app.AppCompatActivity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.Environment; +import android.provider.Settings; -public class SaidItActivity extends Activity { +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; - static final String TAG = SaidItActivity.class.getSimpleName(); +import android.content.ComponentName; +import android.content.Context; +import android.content.ServiceConnection; +import android.os.IBinder; +public class SaidItActivity extends AppCompatActivity { + + private static final int PERMISSION_REQUEST_CODE = 5465; + private boolean isFragmentSet = false; + private AlertDialog permissionDeniedDialog; + private SaidItService echoService; + private boolean isBound = false; + + private final ServiceConnection echoConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder binder) { + SaidItService.BackgroundRecorderBinder typedBinder = (SaidItService.BackgroundRecorderBinder) binder; + echoService = typedBinder.getService(); + isBound = true; + if (mainFragment != null) { + mainFragment.setService(echoService); + } + } + + @Override + public void onServiceDisconnected(ComponentName arg0) { + echoService = null; + isBound = false; + } + }; + private static final int HOW_TO_REQUEST_CODE = 123; + private SaidItFragment mainFragment; + + private final ActivityResultLauncher howToLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (mainFragment != null) { + mainFragment.startTour(); + } + }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_background_recorder); + } - if (savedInstanceState == null) { - getFragmentManager().beginTransaction() - .add(R.id.container, new SaidItFragment(), "main-fragment") + @Override + protected void onStart() { + super.onStart(); + if (permissionDeniedDialog != null) { + permissionDeniedDialog.dismiss(); + } + requestPermissions(); + } + + @Override + protected void onRestart() { + super.onRestart(); + if (permissionDeniedDialog != null) { + permissionDeniedDialog.dismiss(); + } + requestPermissions(); + } + + @Override + protected void onStop() { + super.onStop(); + // Unbinding is now handled in onDestroy to keep service alive during navigation + } + + private void requestPermissions() { + // Ask for storage permission + + String[] permissions = {Manifest.permission.RECORD_AUDIO, Manifest.permission.FOREGROUND_SERVICE}; + if(Build.VERSION.SDK_INT >= 33) { + permissions = new String[]{Manifest.permission.RECORD_AUDIO, Manifest.permission.FOREGROUND_SERVICE, Manifest.permission.POST_NOTIFICATIONS}; + } + ActivityCompat.requestPermissions(this, permissions, PERMISSION_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSION_REQUEST_CODE) { + boolean allPermissionsGranted = true; + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allPermissionsGranted = false; + break; + } + } + + if (allPermissionsGranted) { + if (!isBound) { + // Start the service to ensure it's running + Intent serviceIntent = new Intent(this, SaidItService.class); + startService(serviceIntent); + bindService(serviceIntent, echoConnection, Context.BIND_AUTO_CREATE); + } + showFragment(); + } else { + if (permissionDeniedDialog == null || !permissionDeniedDialog.isShowing()) { + showPermissionDeniedDialog(); + } + } + } + } + + private void showFragment() { + if (!isFragmentSet) { + isFragmentSet = true; + + // Check for first run + SharedPreferences prefs = getSharedPreferences("eu.mrogalski.saidit", MODE_PRIVATE); + boolean isFirstRun = prefs.getBoolean("is_first_run", true); + + mainFragment = new SaidItFragment(); + getSupportFragmentManager().beginTransaction() + .replace(R.id.container, mainFragment, "main-fragment") .commit(); + + if (isFirstRun) { + howToLauncher.launch(new Intent(this, HowToActivity.class)); + prefs.edit().putBoolean("is_first_run", false).apply(); + } else { + boolean showTour = prefs.getBoolean("show_tour_on_next_launch", false); + if (showTour) { + if (mainFragment != null) { + mainFragment.startTour(); + } + prefs.edit().putBoolean("show_tour_on_next_launch", false).apply(); + } + } } + } + private void showPermissionDeniedDialog() { + permissionDeniedDialog = new AlertDialog.Builder(this) + .setTitle(R.string.permission_required) + .setMessage(R.string.permission_required_message) + .setPositiveButton(R.string.allow, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // Open app settings + Intent intent = new Intent(); + intent.setAction(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", getPackageName(), null)); + startActivity(intent); + } + }) + .setNegativeButton(R.string.exit, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + finish(); + } + }) + .setCancelable(false) + .show(); + } + public SaidItService getEchoService() { + return echoService; } @Override - protected void onStart() { - super.onStart(); + protected void onDestroy() { + super.onDestroy(); + if (isBound) { + unbindService(echoConnection); + isBound = false; + } } -} +} \ No newline at end of file diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItFragment.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItFragment.java index f772a563..2a183845 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItFragment.java +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItFragment.java @@ -1,460 +1,312 @@ package eu.mrogalski.saidit; import android.app.Activity; -import android.app.Fragment; import android.app.Notification; -import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; -import android.content.res.AssetManager; -import android.content.res.Resources; -import android.graphics.Typeface; +import android.content.pm.PackageManager; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.os.Handler; import android.os.IBinder; -import android.support.v4.app.NotificationCompat; -import android.support.v4.view.ViewCompat; -import android.view.Gravity; import android.view.LayoutInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.Button; import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.FileProvider; +import androidx.fragment.app.Fragment; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.button.MaterialButtonToggleGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.getkeepsafe.taptargetview.TapTarget; +import com.getkeepsafe.taptargetview.TapTargetSequence; +import com.google.android.material.textview.MaterialTextView; import java.io.File; import eu.mrogalski.android.TimeFormat; -import eu.mrogalski.android.Views; - -public class SaidItFragment extends Fragment { - private static final String TAG = SaidItFragment.class.getSimpleName(); +public class SaidItFragment extends Fragment implements SaveClipBottomSheet.SaveClipListener { - private Button record_pause_button; - private Button listenButton; + private static final String YOUR_NOTIFICATION_CHANNEL_ID = "SaidItServiceChannel"; + private SaidItService echo; - ListenButtonClickListener listenButtonClickListener = new ListenButtonClickListener(); - RecordButtonClickListener recordButtonClickListener = new RecordButtonClickListener(); + // UI Elements + private View recordingGroup; + private View listeningGroup; + private MaterialTextView recordingTime; + private MaterialTextView historySize; + private MaterialButtonToggleGroup listeningToggleGroup; - private boolean isListening = true; + // State private boolean isRecording = false; + private float memorizedDuration = 0; - private LinearLayout ready_section; - private Button recordLastFiveMinutesButton; - private Button recordMaxButton; - private Button recordLastMinuteButton; - - private TextView history_limit; - private TextView history_size; - private TextView history_size_title; - - private LinearLayout rec_section; - private TextView rec_indicator; - private TextView rec_time; - - private ImageButton rate_on_google_play; - private ImageView heart; - - @Override - public void onStart() { - super.onStart(); - final Activity activity = getActivity(); assert activity != null; - activity.bindService(new Intent(activity, SaidItService.class), echoConnection, Context.BIND_AUTO_CREATE); - } - - @Override - public void onStop() { - super.onStop(); - final Activity activity = getActivity(); assert activity != null; - activity.unbindService(echoConnection); - } - - class ActivityResult { - final int requestCode; - final int resultCode; - final Intent data; - - ActivityResult(int requestCode, int resultCode, Intent data) { - this.requestCode = requestCode; - this.resultCode = resultCode; - this.data = data; + public void setService(SaidItService service) { + this.echo = service; + if (getView() != null) { + getView().postOnAnimation(updater); } } - private Runnable updater = new Runnable() { - @Override - public void run() { - final View view = getView(); - if(view == null) return; - if(echo == null) return; - echo.getState(serviceStateCallback); + private final MaterialButtonToggleGroup.OnButtonCheckedListener listeningToggleListener = (group, checkedId, isChecked) -> { + if (isChecked && echo != null) { + if (checkedId == R.id.listening_button) { + echo.enableListening(); + } else if (checkedId == R.id.disabled_button) { + echo.disableListening(); + } } }; - SaidItService echo; - private ServiceConnection echoConnection = new ServiceConnection() { - + private final Runnable updater = new Runnable() { @Override - public void onServiceConnected(ComponentName className, - IBinder binder) { - SaidItService.BackgroundRecorderBinder typedBinder = (SaidItService.BackgroundRecorderBinder) binder; - echo = typedBinder.getService(); - ViewCompat.postOnAnimation(getView(), updater); - } - - @Override - public void onServiceDisconnected(ComponentName arg0) { - echo = null; + public void run() { + if (getView() == null || echo == null) return; + echo.getState(serviceStateCallback); } }; - public int getStatusBarHeight() { - int result = 0; - int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android"); - if (resourceId > 0) { - result = getResources().getDimensionPixelSize(resourceId); - } - return result; - } - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_background_recorder, container, false); - - if(rootView == null) return null; - - final Activity activity = getActivity(); - final AssetManager assets = activity.getAssets(); - final Typeface robotoCondensedBold = Typeface.createFromAsset(assets,"RobotoCondensedBold.ttf"); - final Typeface robotoCondensedRegular = Typeface.createFromAsset(assets, "RobotoCondensed-Regular.ttf"); - final float density = activity.getResources().getDisplayMetrics().density; - - Views.search((ViewGroup) rootView, new Views.SearchViewCallback() { - @Override - public void onView(View view, ViewGroup parent) { - - if (view instanceof Button) { - final Button button = (Button) view; - button.setTypeface(robotoCondensedBold); - final int shadowColor; - if (Build.VERSION.SDK_INT >= 16) - shadowColor = button.getShadowColor(); - else - shadowColor = 0x80000000; - button.setShadowLayer(0.01f, 0, density * 2, shadowColor); - } else if (view instanceof TextView) { - - final TextView textView = (TextView) view; - textView.setTypeface(robotoCondensedRegular); - } - } - }); - - history_limit = (TextView) rootView.findViewById(R.id.history_limit); - history_size = (TextView) rootView.findViewById(R.id.history_size); - history_size_title = (TextView) rootView.findViewById(R.id.history_size_title); - - history_limit.setTypeface(robotoCondensedBold); - history_size.setTypeface(robotoCondensedBold); - - listenButton = (Button) rootView.findViewById(R.id.listen_button); - if(listenButton != null) { - listenButton.setOnClickListener(listenButtonClickListener); - } - - if(Build.VERSION.SDK_INT >= 19) { - final int statusBarHeight = getStatusBarHeight(); - listenButton.setPadding(listenButton.getPaddingLeft(), listenButton.getPaddingTop() + statusBarHeight, listenButton.getPaddingRight(), listenButton.getPaddingBottom()); - final ViewGroup.LayoutParams layoutParams = listenButton.getLayoutParams(); - layoutParams.height += statusBarHeight; - listenButton.setLayoutParams(layoutParams); - } - - - record_pause_button = (Button) rootView.findViewById(R.id.rec_stop_button); - record_pause_button.setOnClickListener(recordButtonClickListener); - - recordLastMinuteButton = (Button) rootView.findViewById(R.id.record_last_minute); - recordLastMinuteButton.setOnClickListener(recordButtonClickListener); - recordLastMinuteButton.setOnLongClickListener(recordButtonClickListener); - - recordLastFiveMinutesButton = (Button) rootView.findViewById(R.id.record_last_5_minutes); - recordLastFiveMinutesButton.setOnClickListener(recordButtonClickListener); - recordLastFiveMinutesButton.setOnLongClickListener(recordButtonClickListener); - - recordMaxButton = (Button) rootView.findViewById(R.id.record_last_max); - recordMaxButton.setOnClickListener(recordButtonClickListener); - recordMaxButton.setOnLongClickListener(recordButtonClickListener); - - ready_section = (LinearLayout) rootView.findViewById(R.id.ready_section); - rec_section = (LinearLayout) rootView.findViewById(R.id.rec_section); - rec_indicator = (TextView) rootView.findViewById(R.id.rec_indicator); - rec_time = (TextView) rootView.findViewById(R.id.rec_time); - - rate_on_google_play = (ImageButton) rootView.findViewById(R.id.rate_on_google_play); - - final Animation pulse = AnimationUtils.loadAnimation(activity, R.anim.pulse); - heart = (ImageView) rootView.findViewById(R.id.heart); - heart.startAnimation(pulse); - - rate_on_google_play.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + activity.getPackageName()))); + final Activity activity = requireActivity(); + + // Find new UI elements + Toolbar toolbar = rootView.findViewById(R.id.toolbar); + recordingGroup = rootView.findViewById(R.id.recording_group); + listeningGroup = rootView.findViewById(R.id.listening_group); + recordingTime = rootView.findViewById(R.id.recording_time); + historySize = rootView.findViewById(R.id.history_size); + MaterialButton saveClipButton = rootView.findViewById(R.id.save_clip_button); + MaterialButton settingsButton = rootView.findViewById(R.id.settings_button); + MaterialButton recordingsButton = rootView.findViewById(R.id.recordings_button); + MaterialButton stopRecordingButton = rootView.findViewById(R.id.rec_stop_button); + listeningToggleGroup = rootView.findViewById(R.id.listening_toggle_group); + // Set listeners + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.action_help) { + startActivity(new Intent(requireActivity(), HowToActivity.class)); + return true; } + return false; }); + settingsButton.setOnClickListener(v -> startActivity(new Intent(activity, SettingsActivity.class))); + recordingsButton.setOnClickListener(v -> startActivity(new Intent(activity, RecordingsActivity.class))); - heart.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - heart.animate().scaleX(10).scaleY(10).alpha(0).setDuration(2000).start(); - Handler handler = new Handler(activity.getMainLooper()); - handler.postDelayed(new Runnable() { - @Override - public void run() { - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + activity.getPackageName()))); - } - }, 1000); - handler.postDelayed(new Runnable() { - @Override - public void run() { - heart.setAlpha(0f); - heart.setScaleX(1); - heart.setScaleY(1); - heart.animate().alpha(1).start(); - - } - }, 3000); + stopRecordingButton.setOnClickListener(v -> { + if (echo != null) { + echo.stopRecording(new PromptFileReceiver(activity)); } }); - rootView.findViewById(R.id.settings_button).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - startActivity(new Intent(activity, SettingsActivity.class)); - } + saveClipButton.setOnClickListener(v -> { + SaveClipBottomSheet bottomSheet = SaveClipBottomSheet.newInstance(memorizedDuration); + bottomSheet.setSaveClipListener(this); + bottomSheet.show(getParentFragmentManager(), "SaveClipBottomSheet"); }); - serviceStateCallback.state(isListening, isRecording, 0, 0, 0); + listeningToggleGroup.addOnButtonCheckedListener(listeningToggleListener); return rootView; } - private SaidItService.StateCallback serviceStateCallback = new SaidItService.StateCallback() { + @Override + public void onSaveClip(String fileName, float durationInSeconds) { + if (echo != null) { + AlertDialog progressDialog = new MaterialAlertDialogBuilder(requireActivity()) + .setTitle("Saving Recording") + .setMessage("Please wait...") + .setCancelable(false) + .create(); + progressDialog.show(); + + echo.dumpRecording(durationInSeconds, new PromptFileReceiver(getActivity(), progressDialog), fileName); + } + } + + + private final SaidItService.StateCallback serviceStateCallback = new SaidItService.StateCallback() { @Override public void state(final boolean listeningEnabled, final boolean recording, final float memorized, final float totalMemory, final float recorded) { - final Activity activity = getActivity(); - if(activity == null) return; - final Resources resources = activity.getResources(); - if((isRecording != recording) || (isListening != listeningEnabled)) { - if(recording != isRecording) { - isRecording = recording; - if (recording) { - rec_section.setVisibility(View.VISIBLE); - } else { - rec_section.setVisibility(View.GONE); - } - } - - if(listeningEnabled != isListening) { - isListening = listeningEnabled; - if (listeningEnabled) { - listenButton.setText(R.string.listening_enabled_disable); - listenButton.setBackgroundResource(R.drawable.top_green_button); - listenButton.setShadowLayer(0.01f, 0, resources.getDimensionPixelOffset(R.dimen.shadow_offset), resources.getColor(R.color.dark_green)); - } else { - listenButton.setText(R.string.listening_disabled_enable); - listenButton.setBackgroundResource(R.drawable.top_gray_button); - listenButton.setShadowLayer(0.01f, 0, resources.getDimensionPixelOffset(R.dimen.shadow_offset), 0xff666666); - } - - if(Build.VERSION.SDK_INT >= 19) { - final int statusBarHeight = getStatusBarHeight(); - listenButton.setPadding(listenButton.getPaddingLeft(), listenButton.getPaddingTop() + statusBarHeight, listenButton.getPaddingRight(), listenButton.getPaddingBottom()); - } - listenButton.setGravity(Gravity.CENTER); - } - - if (listeningEnabled && !recording) { - ready_section.setVisibility(View.VISIBLE); - } else { - ready_section.setVisibility(View.GONE); - } + memorizedDuration = memorized; + + if (isRecording != recording) { + isRecording = recording; + recordingGroup.setVisibility(recording ? View.VISIBLE : View.GONE); + listeningGroup.setVisibility(recording ? View.GONE : View.VISIBLE); } - TimeFormat.naturalLanguage(resources, totalMemory, timeFormatResult); - - if(!history_limit.getText().equals(timeFormatResult.text)) { - history_limit.setText(timeFormatResult.text); + if (isRecording) { + recordingTime.setText(TimeFormat.shortTimer(recorded)); + } else { + historySize.setText(TimeFormat.shortTimer(memorized)); } - TimeFormat.naturalLanguage(resources, memorized, timeFormatResult); - - if(!history_size.getText().equals(timeFormatResult.text)) { - history_size_title.setText(resources.getQuantityText(R.plurals.history_size_title, timeFormatResult.count)); - history_size.setText(timeFormatResult.text); - recordMaxButton.setText(TimeFormat.shortTimer(memorized)); + // Update listening toggle state without triggering listener + listeningToggleGroup.removeOnButtonCheckedListener(listeningToggleListener); + if (listeningEnabled) { + listeningToggleGroup.check(R.id.listening_button); + listeningGroup.setAlpha(1.0f); + } else { + listeningToggleGroup.check(R.id.disabled_button); + listeningGroup.setAlpha(0.5f); } + listeningToggleGroup.addOnButtonCheckedListener(listeningToggleListener); - TimeFormat.naturalLanguage(resources, recorded, timeFormatResult); - - if(!rec_time.getText().equals(timeFormatResult.text)) { - rec_indicator.setText(resources.getQuantityText(R.plurals.recorded, timeFormatResult.count)); - rec_time.setText(timeFormatResult.text); + if (getView() != null) { + getView().postOnAnimationDelayed(updater, 100); } - - ViewCompat.postOnAnimation(history_size, updater); } }; - final TimeFormat.Result timeFormatResult = new TimeFormat.Result(); - - - - - private class ListenButtonClickListener implements View.OnClickListener { - - final WorkingDialog dialog = new WorkingDialog() { - @Override - public void onStart() { - super.onStart(); - new Handler().post(new Runnable() { - @Override - public void run() { - echo.enableListening(); - echo.getState(new SaidItService.StateCallback() { - @Override - public void state(boolean listeningEnabled, boolean recording, float memorized, float totalMemory, float recorded) { - dismiss(); - } - }); - } - }); + @Override + public void onStart() { + super.onStart(); + SaidItActivity activity = (SaidItActivity) getActivity(); + if (activity != null) { + echo = activity.getEchoService(); + if (echo != null && getView() != null) { + getView().postOnAnimation(updater); } - }; - - public ListenButtonClickListener() { - dialog.setDescriptionStringId(R.string.work_preparing_memory); - } - - @Override - public void onClick(View v) { - echo.getState(new SaidItService.StateCallback() { - @Override - public void state(final boolean listeningEnabled, boolean recording, float memorized, float totalMemory, float recorded) { - if (listeningEnabled) { - echo.disableListening(); - } else { - dialog.show(getFragmentManager(), "Preparing memory"); - } - } - }); } } - private class RecordButtonClickListener implements View.OnClickListener, View.OnLongClickListener { - @Override - public void onClick(final View v) { - record(v, false); - } - - @Override - public boolean onLongClick(final View v) { - record(v, true); - return true; - } - - public void record(final View button, final boolean keepRecording) { - echo.getState(new SaidItService.StateCallback() { - @Override - public void state(final boolean listeningEnabled, final boolean recording, float memorized, float totalMemory, float recorded) { - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - if (recording) { - echo.stopRecording(new PromptFileReceiver(getActivity())); - } else { - final float seconds = getPrependedSeconds(button); - if (keepRecording) { - echo.startRecording(seconds); - } else { - echo.dumpRecording(seconds, new PromptFileReceiver(getActivity())); - } - } - } - }); - } - }); + public void startTour() { + // A small delay to ensure the UI is fully drawn before starting the tour. + if (getView() != null) { + getView().postDelayed(this::startInteractiveTour, 500); } + } - float getPrependedSeconds(View button) { - switch (button.getId()) { - case R.id.record_last_minute: return 60; - case R.id.record_last_5_minutes: return 60 * 5; - case R.id.record_last_max: return 60 * 60 * 24 * 365; - } - return 0; - } + private void startInteractiveTour() { + if (getActivity() == null || getView() == null) return; + + final TapTargetSequence sequence = new TapTargetSequence(getActivity()) + .targets( + TapTarget.forView(getView().findViewById(R.id.listening_toggle_group), getString(R.string.tour_listening_toggle_title), getString(R.string.tour_listening_toggle_desc)) + .cancelable(false).tintTarget(false), + TapTarget.forView(getView().findViewById(R.id.history_size), getString(R.string.tour_memory_holds_title), getString(R.string.tour_memory_holds_desc)) + .cancelable(false).tintTarget(false), + TapTarget.forView(getView().findViewById(R.id.save_clip_button), getString(R.string.tour_save_clip_title), getString(R.string.tour_save_clip_desc)) + .cancelable(false).tintTarget(false), + TapTarget.forView(getView().findViewById(R.id.bottom_buttons_layout), getString(R.string.tour_bottom_buttons_title), getString(R.string.tour_bottom_buttons_desc)) + .cancelable(false).tintTarget(false) + ); + sequence.start(); } + // --- File Receiver and Notification Logic --- - static Notification buildNotificationForFile(Context context, File outFile) { + static Notification buildNotificationForFile(Context context, Uri fileUri, String fileName) { Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(Uri.fromFile(outFile), "audio/wav"); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); - - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context); - notificationBuilder.setContentTitle(context.getString(R.string.recording_saved)); - notificationBuilder.setContentText(outFile.getName()); - notificationBuilder.setSmallIcon(R.drawable.ic_stat_notify_recorded); - notificationBuilder.setTicker(outFile.getName()); - notificationBuilder.setContentIntent(pendingIntent); - notificationBuilder.setAutoCancel(true); + intent.setDataAndType(fileUri, "audio/mp4"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, YOUR_NOTIFICATION_CHANNEL_ID) + .setContentTitle(context.getString(R.string.recording_saved)) + .setContentText(fileName) + .setSmallIcon(R.drawable.ic_stat_notify_recorded) + .setTicker(fileName) + .setContentIntent(pendingIntent) + .setAutoCancel(true); + notificationBuilder.setPriority(NotificationCompat.PRIORITY_DEFAULT); return notificationBuilder.build(); } static class NotifyFileReceiver implements SaidItService.WavFileReceiver { - - private Context context; + private final Context context; public NotifyFileReceiver(Context context) { this.context = context; } @Override - public void fileReady(final File file, float runtime) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(43, buildNotificationForFile(context, file)); + public void onSuccess(final Uri fileUri, float runtime) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return; + } + notificationManager.notify(43, buildNotificationForFile(context, fileUri, "Recording Saved")); + } + + @Override + public void onFailure(Exception e) { + // Do nothing for background notifications } } + static class PromptFileReceiver implements SaidItService.WavFileReceiver { + private final Activity activity; + private final AlertDialog progressDialog; - private Activity activity; + public PromptFileReceiver(Activity activity, AlertDialog dialog) { + this.activity = activity; + this.progressDialog = dialog; + } public PromptFileReceiver(Activity activity) { - this.activity = activity; + this(activity, null); } @Override - public void fileReady(final File file, float runtime) { - new RecordingDoneDialog() - .setFile(file) - .setRuntime(runtime) - .show(activity.getFragmentManager(), "Recording Done"); + public void onSuccess(final Uri fileUri, float runtime) { + if (activity != null && !activity.isFinishing()) { + activity.runOnUiThread(() -> { + if (progressDialog != null && progressDialog.isShowing()) { + progressDialog.dismiss(); + } + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.recording_done_title) + .setMessage("Recording saved to your music folder.") + .setPositiveButton(R.string.open, (dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(fileUri, "audio/mp4"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivity(intent); + }) + .setNeutralButton(R.string.share, (dialog, which) -> { + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("audio/mp4"); + shareIntent.putExtra(Intent.EXTRA_STREAM, fileUri); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivity(Intent.createChooser(shareIntent, "Send to")); + }) + .setNegativeButton(R.string.dismiss, null) + .show(); + }); + } + } + + @Override + public void onFailure(Exception e) { + if (activity != null && !activity.isFinishing()) { + activity.runOnUiThread(() -> { + if (progressDialog != null && progressDialog.isShowing()) { + progressDialog.dismiss(); + } + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.error_title) + .setMessage(R.string.error_saving_failed) + .setPositiveButton(android.R.string.ok, null) + .show(); + }); + } } } } diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.java index 3b3023ac..d0e39471 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.java +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidItService.java @@ -1,537 +1,518 @@ -package eu.mrogalski.saidit; - -import android.app.AlarmManager; -import android.app.Notification; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Intent; -import android.content.SharedPreferences; -import android.media.AudioFormat; -import android.media.AudioManager; -import android.media.AudioRecord; -import android.media.AudioTrack; -import android.media.MediaRecorder; -import android.os.Binder; -import android.os.Environment; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.IBinder; -import android.os.SystemClock; -import android.support.v4.app.NotificationCompat; -import android.text.format.DateUtils; -import android.util.Log; -import android.widget.Toast; - -import java.io.File; -import java.io.IOException; - -import simplesound.pcm.WavAudioFormat; -import simplesound.pcm.WavFileWriter; -import static eu.mrogalski.saidit.SaidIt.*; - -public class SaidItService extends Service { - static final String TAG = SaidItService.class.getSimpleName(); - - volatile int SAMPLE_RATE; - volatile int FILL_RATE; - - - File wavFile; - AudioRecord audioRecord; // used only in the audio thread - WavFileWriter wavFileWriter; // used only in the audio thread - final AudioMemory audioMemory = new AudioMemory(); // used only in the audio thread - volatile private int readLimit = Integer.MAX_VALUE; // used to control responsiveness of audio thread - - HandlerThread audioThread; - Handler audioHandler; // used to post messages to audio thread - - @Override - public void onCreate() { - - Log.d(TAG, "Reading native sample rate"); - - final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); - SAMPLE_RATE = preferences.getInt(SAMPLE_RATE_KEY, AudioTrack.getNativeOutputSampleRate (AudioManager.STREAM_MUSIC)); - Log.d(TAG, "Sample rate: " + SAMPLE_RATE); - FILL_RATE = 2 * SAMPLE_RATE; - - audioThread = new HandlerThread("audioThread", Thread.MAX_PRIORITY); - audioThread.start(); - audioHandler = new Handler(audioThread.getLooper()); - - if(preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true)) { - innerStartListening(); - } - - } - - @Override - public void onDestroy() { - stopRecording(null); - innerStopListening(); - } - - @Override - public IBinder onBind(Intent intent) { - readLimit = FILL_RATE / 10; - return new BackgroundRecorderBinder(); - } - - @Override - public void onRebind(Intent intent) { - readLimit = FILL_RATE / 10; - } - - @Override - public boolean onUnbind(Intent intent) { - readLimit = Integer.MAX_VALUE; - return true; - } - - public void enableListening() { - getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE) - .edit().putBoolean(AUDIO_MEMORY_ENABLED_KEY, true).commit(); - - innerStartListening(); - } - - public void disableListening() { - getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE) - .edit().putBoolean(AUDIO_MEMORY_ENABLED_KEY, false).commit(); - - innerStopListening(); - } - - int state; - - static final int STATE_READY = 0; - static final int STATE_LISTENING = 1; - static final int STATE_RECORDING = 2; - - private void innerStartListening() { - switch(state) { - case STATE_READY: - break; - case STATE_LISTENING: - case STATE_RECORDING: - return; - } - state = STATE_LISTENING; - - Log.d(TAG, "Queueing: START LISTENING"); - - startService(new Intent(this, this.getClass())); - - final long memorySize = getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE).getLong(AUDIO_MEMORY_SIZE_KEY, Runtime.getRuntime().maxMemory() / 4); - - Notification note = new Notification( 0, null, System.currentTimeMillis() ); - note.flags |= Notification.FLAG_NO_CLEAR; - startForeground(42, note); - startService(new Intent(this, FakeService.class)); - audioHandler.post(new Runnable() { - @Override - public void run() { - Log.d(TAG, "Executing: START LISTENING"); - Log.d(TAG, "Audio: INITIALIZING AUDIO_RECORD"); - - audioRecord = new AudioRecord( - MediaRecorder.AudioSource.MIC, - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - 512 * 1024); // .5MB - - if(audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { - Log.e(TAG, "Audio: INITIALIZATION ERROR - releasing resources"); - audioRecord.release(); - audioRecord = null; - state = STATE_READY; - return; - } - - Log.d(TAG, "Audio: STARTING AudioRecord"); - audioMemory.allocate(memorySize); - - Log.d(TAG, "Audio: STARTING AudioRecord"); - audioRecord.startRecording(); - audioHandler.post(audioReader); - } - }); - - - } - - private void innerStopListening() { - switch(state) { - case STATE_READY: - case STATE_RECORDING: - return; - case STATE_LISTENING: - break; - } - state = STATE_READY; - Log.d(TAG, "Queueing: STOP LISTENING"); - - stopForeground(true); - stopService(new Intent(this, this.getClass())); - - audioHandler.post(new Runnable() { - @Override - public void run() { - Log.d(TAG, "Executing: STOP LISTENING"); - if(audioRecord != null) - audioRecord.release(); - audioHandler.removeCallbacks(audioReader); - audioMemory.allocate(0); - } - }); - - } - - public void dumpRecording(final float memorySeconds, final WavFileReceiver wavFileReceiver) { - if(state != STATE_LISTENING) throw new IllegalStateException("Not listening!"); - - audioHandler.post(new Runnable() { - @Override - public void run() { - int prependBytes = (int)(memorySeconds * FILL_RATE); - int bytesAvailable = audioMemory.countFilled(); - - int skipBytes = Math.max(0, bytesAvailable - prependBytes); - - int useBytes = bytesAvailable - skipBytes; - long millis = System.currentTimeMillis() - 1000 * useBytes / FILL_RATE; - final int flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE; - final String dateTime = DateUtils.formatDateTime(SaidItService.this, millis, flags); - String filename = "Echo - " + dateTime + ".wav"; - - final String storagePath = Environment.getExternalStorageDirectory().getAbsolutePath(); - String path = storagePath + "/" + filename; - - File file = new File(path); - try { - file.createNewFile(); - } catch (IOException e) { - filename = filename.replace(':', '.'); - path = storagePath + "/" + filename; - file = new File(path); - } - final WavAudioFormat format = new WavAudioFormat.Builder().sampleRate(SAMPLE_RATE).build(); - try { - final WavFileWriter writer = new WavFileWriter(format, file); - - try { - audioMemory.read(skipBytes, new AudioMemory.Consumer() { - @Override - public int consume(byte[] array, int offset, int count) throws IOException { - writer.write(array, offset, count); - return 0; - } - }); - } catch (IOException e) { - final String errorMessage = getString(R.string.error_during_writing_history_into) + path; - Toast.makeText(SaidItService.this, errorMessage, Toast.LENGTH_LONG).show(); - Log.e(TAG, errorMessage, e); - - try { - writer.close(); - } catch (IOException e2) { - Log.e(TAG, "CLOSING ERROR", e2); - } - if(wavFileReceiver != null) - wavFileReceiver.fileReady(file, writer.getTotalSampleBytesWritten() * getBytesToSeconds()); - } - - try { - writer.close(); - } catch (IOException e) { - Log.e(TAG, "CLOSING ERROR", e); - } - if(wavFileReceiver != null) { - wavFileReceiver.fileReady(file, writer.getTotalSampleBytesWritten() * getBytesToSeconds()); - } - } catch (IOException e) { - final String errorMessage = getString(R.string.cant_create_file) + path; - Toast.makeText(SaidItService.this, errorMessage, Toast.LENGTH_LONG).show(); - Log.e(TAG, errorMessage, e); - } - } - }); - - } - - public void startRecording(final float prependedMemorySeconds) { - switch(state) { - case STATE_READY: - innerStartListening(); - break; - case STATE_LISTENING: - break; - case STATE_RECORDING: - return; - } - state = STATE_RECORDING; - - audioHandler.post(new Runnable() { - @Override - public void run() { - int prependBytes = (int)(prependedMemorySeconds * FILL_RATE); - int bytesAvailable = audioMemory.countFilled(); - - int skipBytes = Math.max(0, bytesAvailable - prependBytes); - - int useBytes = bytesAvailable - skipBytes; - long millis = System.currentTimeMillis() - 1000 * useBytes / FILL_RATE; - final int flags = DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE; - final String dateTime = DateUtils.formatDateTime(SaidItService.this, millis, flags); - String filename = "Echo - " + dateTime + ".wav"; - - final String storagePath = Environment.getExternalStorageDirectory().getAbsolutePath(); - String path = storagePath + "/" + filename; - - wavFile = new File(path); - try { - wavFile.createNewFile(); - } catch (IOException e) { - filename = filename.replace(':', '.'); - path = storagePath + "/" + filename; - wavFile = new File(path); - } - WavAudioFormat format = new WavAudioFormat.Builder().sampleRate(SAMPLE_RATE).build(); - try { - wavFileWriter = new WavFileWriter(format, wavFile); - } catch (IOException e) { - final String errorMessage = getString(R.string.cant_create_file) + path; - Toast.makeText(SaidItService.this, errorMessage, Toast.LENGTH_LONG).show(); - Log.e(TAG, errorMessage, e); - return; - } - - final String finalPath = path; - - if(skipBytes < bytesAvailable) { - try { - audioMemory.read(skipBytes, new AudioMemory.Consumer() { - @Override - public int consume(byte[] array, int offset, int count) throws IOException { - wavFileWriter.write(array, offset, count); - return 0; - } - }); - } catch (IOException e) { - final String errorMessage = getString(R.string.error_during_writing_history_into) + finalPath; - Toast.makeText(SaidItService.this, errorMessage, Toast.LENGTH_LONG).show(); - Log.e(TAG, errorMessage, e); - stopRecording(new SaidItFragment.NotifyFileReceiver(SaidItService.this)); - } - } - } - }); - - final Notification notification = buildNotification(); - startForeground(42, notification); - - } - - public long getMemorySize() { - return audioMemory.getAllocatedMemorySize(); - } - - public void setMemorySize(final long memorySize) { - final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); - preferences.edit().putLong(AUDIO_MEMORY_SIZE_KEY, memorySize).commit(); - - if(preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true)) { - audioHandler.post(new Runnable() { - @Override - public void run() { - audioMemory.allocate(memorySize); - } - }); - } - } - - public int getSamplingRate() { - return SAMPLE_RATE; - } - - public void setSampleRate(int sampleRate) { - switch(state) { - case STATE_READY: - case STATE_RECORDING: - return; - case STATE_LISTENING: - break; - } - - final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); - preferences.edit().putInt(SAMPLE_RATE_KEY, sampleRate).commit(); - - innerStopListening(); - SAMPLE_RATE = sampleRate; - FILL_RATE = 2 * SAMPLE_RATE; - innerStartListening(); - } - - public interface WavFileReceiver { - public void fileReady(File file, float runtime); - } - - public void stopRecording(final WavFileReceiver wavFileReceiver) { - switch(state) { - case STATE_READY: - case STATE_LISTENING: - return; - case STATE_RECORDING: - break; - } - state = STATE_LISTENING; - - audioHandler.post(new Runnable() { - @Override - public void run() { - try { - wavFileWriter.close(); - } catch (IOException e) { - Log.e(TAG, "CLOSING ERROR", e); - } - if(wavFileReceiver != null) { - wavFileReceiver.fileReady(wavFile, wavFileWriter.getTotalSampleBytesWritten() * getBytesToSeconds()); - } - wavFileWriter = null; - } - }); - - final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); - if(!preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true)) { - innerStopListening(); - } - - stopForeground(true); - } - - final AudioMemory.Consumer filler = new AudioMemory.Consumer() { - @Override - public int consume(final byte[] array, final int offset, final int count) throws IOException { - - final int bytes = Math.min(readLimit, count); - //Log.d(TAG, "READING " + bytes + " B"); - final int read = audioRecord.read(array, offset, bytes); - if (read == AudioRecord.ERROR_BAD_VALUE) { - Log.e(TAG, "AUDIO RECORD ERROR - BAD VALUE"); - return 0; - } - if (read == AudioRecord.ERROR_INVALID_OPERATION) { - Log.e(TAG, "AUDIO RECORD ERROR - INVALID OPERATION"); - return 0; - } - if (read == AudioRecord.ERROR) { - Log.e(TAG, "AUDIO RECORD ERROR - UNKNOWN ERROR"); - return 0; - } - if (wavFileWriter != null && read > 0) { - wavFileWriter.write(array, offset, read); - } - audioHandler.post(audioReader); - return read; - } - }; - final Runnable audioReader = new Runnable() { - @Override - public void run() { - try { - audioMemory.fill(filler); - } catch (IOException e) { - final String errorMessage = getString(R.string.error_during_recording_into) + wavFile.getName(); - Toast.makeText(SaidItService.this, errorMessage, Toast.LENGTH_LONG).show(); - Log.e(TAG, errorMessage, e); - stopRecording(new SaidItFragment.NotifyFileReceiver(SaidItService.this)); - } - } - }; - - public interface StateCallback { - public void state(boolean listeningEnabled, boolean recording, float memorized, float totalMemory, float recorded); - } - - public void getState(final StateCallback stateCallback) { - final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); - final boolean listeningEnabled = preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true); - final boolean recording = (state == STATE_RECORDING); - final Handler sourceHandler = new Handler(); - audioHandler.post(new Runnable() { - @Override - public void run() { - - audioMemory.observe(new AudioMemory.Observer() { - @Override - public void observe(final int filled, final int total, final int estimation, final boolean overwriting) { - int recorded = 0; - if(wavFileWriter != null) { - recorded += wavFileWriter.getTotalSampleBytesWritten(); - recorded += estimation; - } - final float bytesToSeconds = getBytesToSeconds(); - - final int finalRecorded = recorded; - sourceHandler.post(new Runnable() { - @Override - public void run() { - - stateCallback.state(listeningEnabled, recording, - (overwriting ? total : filled + estimation) * bytesToSeconds, - total * bytesToSeconds, - finalRecorded * bytesToSeconds); - } - }); - } - }, FILL_RATE); - } - }); - } - - public float getBytesToSeconds() { - return 1f / FILL_RATE; - } - - class BackgroundRecorderBinder extends Binder { - public SaidItService getService() { - return SaidItService.this; - } - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - return super.onStartCommand(intent, flags, startId); - } - - // Workaround for bug where recent app removal caused service to stop - @Override - public void onTaskRemoved(Intent rootIntent) { - Intent restartServiceIntent = new Intent(getApplicationContext(), this.getClass()); - restartServiceIntent.setPackage(getPackageName()); - - PendingIntent restartServicePendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT); - AlarmManager alarmService = (AlarmManager) getSystemService(ALARM_SERVICE); - alarmService.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 1000, - restartServicePendingIntent); - } - - private Notification buildNotification() { - - Intent intent = new Intent(this, SaidItActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0); - - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this); - notificationBuilder.setContentTitle(getString(R.string.recording)); - notificationBuilder.setUsesChronometer(true); - notificationBuilder.setProgress(100, 50, true); - notificationBuilder.setSmallIcon(R.drawable.ic_stat_notify_recording); - notificationBuilder.setTicker(getString(R.string.recording)); - notificationBuilder.setContentIntent(pendingIntent); - return notificationBuilder.build(); - } - -} +package eu.mrogalski.saidit; + +import android.annotation.SuppressLint; +import android.app.AlarmManager; +import android.app.Notification; +import android.app.NotificationChannel; +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.ServiceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.AudioTrack; +import android.media.MediaRecorder; +import android.media.MediaMetadataRetriever; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.SystemClock; +import android.provider.MediaStore; +import android.text.format.DateUtils; +import android.util.Log; +import android.widget.Toast; +import android.content.ContentValues; +import android.content.ContentResolver; +import android.net.Uri; + +import androidx.core.app.NotificationCompat; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + + + +import static eu.mrogalski.saidit.SaidIt.AUDIO_MEMORY_ENABLED_KEY; +import static eu.mrogalski.saidit.SaidIt.AUDIO_MEMORY_SIZE_KEY; +import static eu.mrogalski.saidit.SaidIt.PACKAGE_NAME; +import static eu.mrogalski.saidit.SaidIt.SAMPLE_RATE_KEY; + +public class SaidItService extends Service { + static final String TAG = SaidItService.class.getSimpleName(); + private static final int FOREGROUND_NOTIFICATION_ID = 458; + private static final String YOUR_NOTIFICATION_CHANNEL_ID = "SaidItServiceChannel"; + private static final String ACTION_AUTO_SAVE = "eu.mrogalski.saidit.ACTION_AUTO_SAVE"; + + volatile int SAMPLE_RATE; + volatile int FILL_RATE; + + File mediaFile; + AudioRecord audioRecord; // used only in the audio thread + AacMp4Writer aacWriter; // used only in the audio thread + final AudioMemory audioMemory = new AudioMemory(new SystemClockWrapper()); // used only in the audio thread + + HandlerThread audioThread; + Handler audioHandler; // used to post messages to audio thread + AudioMemory.Consumer filler; + Runnable audioReader; + AudioRecord.OnRecordPositionUpdateListener positionListener; + + int state; + static final int STATE_READY = 0; + static final int STATE_LISTENING = 1; + static final int STATE_RECORDING = 2; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "Reading native sample rate"); + + final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + SAMPLE_RATE = preferences.getInt(SAMPLE_RATE_KEY, AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC)); + Log.d(TAG, "Sample rate: " + SAMPLE_RATE); + FILL_RATE = 2 * SAMPLE_RATE; + + audioThread = new HandlerThread("audioThread", Thread.MAX_PRIORITY); + audioThread.start(); + audioHandler = new Handler(audioThread.getLooper()); + + filler = (array, offset, count) -> { + if (audioRecord == null) return 0; + final int read = audioRecord.read(array, offset, count, AudioRecord.READ_NON_BLOCKING); + if (read < 0) { + Log.e(TAG, "AUDIO RECORD ERROR: " + read); + return 0; + } + if (aacWriter != null && read > 0) { + aacWriter.write(array, offset, read); + } + return read; + }; + + audioReader = () -> { + try { + audioMemory.fill(filler); + } catch (IOException e) { + final String errorMessage = getString(R.string.error_during_recording_into) + (mediaFile != null ? mediaFile.getName() : ""); + showToast(errorMessage); + Log.e(TAG, errorMessage, e); + stopRecording(new SaidItFragment.NotifyFileReceiver(SaidItService.this)); + } + }; + + if (preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true)) { + innerStartListening(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + stopRecording(null); + innerStopListening(); + stopForeground(true); + } + + @Override + public IBinder onBind(Intent intent) { + return new BackgroundRecorderBinder(); + } + + @Override + public boolean onUnbind(Intent intent) { + return false; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_AUTO_SAVE.equals(intent.getAction())) { + SharedPreferences preferences = getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + if (preferences.getBoolean("auto_save_enabled", false)) { + Log.d(TAG, "Executing auto-save..."); + String timestamp = new java.text.SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", java.util.Locale.US).format(new java.util.Date()); + String autoName = "Auto-save_" + timestamp; + dumpRecording(300, new SaidItFragment.NotifyFileReceiver(this), autoName); + } + return START_STICKY; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(FOREGROUND_NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else { + startForeground(FOREGROUND_NOTIFICATION_ID, buildNotification()); + } + return START_STICKY; + } + + private void innerStartListening() { + if (state != STATE_READY) return; + state = STATE_LISTENING; + + Log.d(TAG, "Queueing: START LISTENING"); + startService(new Intent(this, this.getClass())); + final long memorySize = getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE).getLong(AUDIO_MEMORY_SIZE_KEY, Runtime.getRuntime().maxMemory() / 4); + + audioHandler.post(() -> { + Log.d(TAG, "Executing: START LISTENING"); + @SuppressLint("MissingPermission") + AudioRecord newAudioRecord = new AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + AudioMemory.CHUNK_SIZE); + + if (newAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "Audio: INITIALIZATION ERROR"); + newAudioRecord.release(); + state = STATE_READY; + return; + } + audioRecord = newAudioRecord; + audioMemory.allocate(memorySize); + // Set up event-driven periodic callbacks (~50ms) + final int periodFrames = Math.max(128, SAMPLE_RATE / 20); + positionListener = new AudioRecord.OnRecordPositionUpdateListener() { + @Override + public void onPeriodicNotification(AudioRecord recorder) { + audioHandler.post(audioReader); + } + @Override + public void onMarkerReached(AudioRecord recorder) { } + }; + audioRecord.setRecordPositionUpdateListener(positionListener, audioHandler); + audioRecord.setPositionNotificationPeriod(periodFrames); + audioRecord.startRecording(); + // Kickstart a first read to reduce latency + audioHandler.post(audioReader); + }); + + scheduleAutoSave(); + } + + private void innerStopListening() { + if (state == STATE_READY) return; + state = STATE_READY; + + Log.d(TAG, "Queueing: STOP LISTENING"); + cancelAutoSave(); + stopForeground(true); + stopService(new Intent(this, this.getClass())); + + audioHandler.post(() -> { + Log.d(TAG, "Executing: STOP LISTENING"); + if (audioRecord != null) { + try { audioRecord.setRecordPositionUpdateListener(null); } catch (Exception ignored) {} + audioRecord.release(); + audioRecord = null; + } + audioHandler.removeCallbacks(audioReader); + audioMemory.allocate(0); + }); + } + + public void enableListening() { + getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE) + .edit().putBoolean(AUDIO_MEMORY_ENABLED_KEY, true).apply(); + innerStartListening(); + } + + public void disableListening() { + getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE) + .edit().putBoolean(AUDIO_MEMORY_ENABLED_KEY, false).apply(); + innerStopListening(); + } + + public void startRecording(final float prependedMemorySeconds) { + if (state == STATE_RECORDING) return; + if (state == STATE_READY) innerStartListening(); + state = STATE_RECORDING; + + audioHandler.post(() -> { + flushAudioRecord(); + try { + mediaFile = File.createTempFile("saidit", ".m4a", getCacheDir()); + // 96 kbps for mono voice + aacWriter = new AacMp4Writer(SAMPLE_RATE, 1, 96_000, mediaFile); + Log.d(TAG, "Recording to: " + mediaFile.getAbsolutePath()); + + // Write prepended memory + if (prependedMemorySeconds > 0) { + final int bytesPerSecond = (int) (1f / getBytesToSeconds()); + final int bytesToDump = (int) (prependedMemorySeconds * bytesPerSecond); + audioMemory.dump((array, offset, count) -> { aacWriter.write(array, offset, count); return count; }, bytesToDump); + } + } catch (IOException e) { + Log.e(TAG, "ERROR creating AAC/MP4 file", e); + showToast(getString(R.string.error_creating_recording_file)); + state = STATE_LISTENING; // Revert state + } + }); + } + + public void stopRecording(final WavFileReceiver wavFileReceiver) { + if (state != STATE_RECORDING) return; + state = STATE_LISTENING; + + audioHandler.post(() -> { + flushAudioRecord(); + if (aacWriter != null) { + try { + aacWriter.close(); + } catch (IOException e) { + Log.e(TAG, "CLOSING ERROR", e); + } + } + if (wavFileReceiver != null && mediaFile != null) { + saveFileToMediaStore(mediaFile, mediaFile.getName(), wavFileReceiver); + } + aacWriter = null; + }); + } + + public void dumpRecording(final float memorySeconds, final WavFileReceiver wavFileReceiver, String newFileName) { + if (state == STATE_READY) return; + + audioHandler.post(() -> { + flushAudioRecord(); + File dumpFile = null; + try { + String fileName = newFileName != null ? newFileName.replaceAll("[^a-zA-Z0-9.-]", "_") : "SaidIt_dump"; + dumpFile = new File(getCacheDir(), fileName + ".m4a"); + AacMp4Writer dumper = new AacMp4Writer(SAMPLE_RATE, 1, 96_000, dumpFile); + Log.d(TAG, "Dumping to: " + dumpFile.getAbsolutePath()); + final int bytesPerSecond = (int) (1f / getBytesToSeconds()); + final int bytesToDump = (int) (memorySeconds * bytesPerSecond); + audioMemory.dump((array, offset, count) -> { dumper.write(array, offset, count); return count; }, bytesToDump); + dumper.close(); + + if (wavFileReceiver != null) { + final File finalDumpFile = dumpFile; + saveFileToMediaStore(finalDumpFile, (newFileName != null ? newFileName : "SaidIt Recording") + ".m4a", wavFileReceiver); + } + } catch (IOException e) { + Log.e(TAG, "ERROR dumping AAC/MP4 file", e); + showToast(getString(R.string.error_saving_recording)); + if (dumpFile != null) { + dumpFile.delete(); + } + if (wavFileReceiver != null) { + wavFileReceiver.onFailure(e); + } + } + }); + } + + public void scheduleAutoSave() { + SharedPreferences preferences = getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + boolean autoSaveEnabled = preferences.getBoolean("auto_save_enabled", false); + if (autoSaveEnabled) { + long durationMillis = preferences.getInt("auto_save_duration", 600) * 1000L; + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + Intent intent = new Intent(this, SaidItService.class); + intent.setAction(ACTION_AUTO_SAVE); + PendingIntent pendingIntent = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + Log.d(TAG, "Scheduling auto-save for every " + durationMillis / 1000 + " seconds."); + alarmManager.setRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + durationMillis, durationMillis, pendingIntent); + } + } + + public void cancelAutoSave() { + Log.d(TAG, "Cancelling auto-save."); + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + Intent intent = new Intent(this, SaidItService.class); + intent.setAction(ACTION_AUTO_SAVE); + PendingIntent pendingIntent = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + alarmManager.cancel(pendingIntent); + } + + private void flushAudioRecord() { + // Only allowed on the audio thread + assert audioHandler.getLooper() == Looper.myLooper(); + audioHandler.removeCallbacks(audioReader); // remove any delayed callbacks + audioReader.run(); + } + + private void showToast(String message) { + Toast.makeText(SaidItService.this, message, Toast.LENGTH_LONG).show(); + } + + public long getMemorySize() { + return audioMemory.getAllocatedMemorySize(); + } + + public void setMemorySize(final long memorySize) { + final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + preferences.edit().putLong(AUDIO_MEMORY_SIZE_KEY, memorySize).apply(); + + if(preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true)) { + audioHandler.post(() -> { + audioMemory.allocate(memorySize); + }); + } + } + + public int getSamplingRate() { + return SAMPLE_RATE; + } + + public void setSampleRate(int sampleRate) { + if (state == STATE_RECORDING) return; + if (state == STATE_READY) { + final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + preferences.edit().putInt(SAMPLE_RATE_KEY, sampleRate).apply(); + SAMPLE_RATE = sampleRate; + FILL_RATE = 2 * SAMPLE_RATE; + return; + } + + final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + preferences.edit().putInt(SAMPLE_RATE_KEY, sampleRate).apply(); + + innerStopListening(); + SAMPLE_RATE = sampleRate; + FILL_RATE = 2 * SAMPLE_RATE; + innerStartListening(); + } + + public void getState(final StateCallback stateCallback) { + final SharedPreferences preferences = this.getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + final boolean listeningEnabled = preferences.getBoolean(AUDIO_MEMORY_ENABLED_KEY, true); + final boolean recording = (state == STATE_RECORDING); + final Handler sourceHandler = new Handler(Looper.getMainLooper()); + audioHandler.post(() -> { + flushAudioRecord(); + final AudioMemory.Stats stats = audioMemory.getStats(FILL_RATE); + + int recorded = 0; + if(aacWriter != null) { + recorded += aacWriter.getTotalSampleBytesWritten(); + recorded += stats.estimation; + } + final float bytesToSeconds = getBytesToSeconds(); + final int finalRecorded = recorded; + sourceHandler.post(() -> stateCallback.state(listeningEnabled, recording, + (stats.overwriting ? stats.total : stats.filled + stats.estimation) * bytesToSeconds, + stats.total * bytesToSeconds, + finalRecorded * bytesToSeconds)); + }); + } + + public float getBytesToSeconds() { + return 1f / FILL_RATE; + } + + private void saveFileToMediaStore(File sourceFile, String displayName, WavFileReceiver receiver) { + ContentResolver resolver = getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.Audio.Media.DISPLAY_NAME, displayName); + values.put(MediaStore.Audio.Media.MIME_TYPE, "audio/mp4"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.put(MediaStore.Audio.Media.IS_PENDING, 1); + } + + Uri collection = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ? MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + : MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + Uri itemUri = resolver.insert(collection, values); + + try (InputStream in = new FileInputStream(sourceFile); + OutputStream out = resolver.openOutputStream(itemUri)) { + byte[] buffer = new byte[4096]; + int len; + while ((len = in.read(buffer)) > 0) { + out.write(buffer, 0, len); + } + } catch (IOException e) { + Log.e(TAG, "Error saving file to MediaStore", e); + if (itemUri != null) { + resolver.delete(itemUri, null, null); + } + itemUri = null; + } finally { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.clear(); + values.put(MediaStore.Audio.Media.IS_PENDING, 0); + if (itemUri != null) { + resolver.update(itemUri, values, null, null); + } + } + + if (itemUri != null) { + final long duration = getMediaDuration(sourceFile); + values.clear(); + values.put(MediaStore.Audio.Media.DURATION, duration); + resolver.update(itemUri, values, null, null); + + final Uri finalUri = itemUri; + new Handler(Looper.getMainLooper()).post(() -> { + if (receiver != null) { + receiver.onSuccess(finalUri, duration / 1000f); + } + }); + } + if (itemUri == null && receiver != null) { + new Handler(Looper.getMainLooper()).post(() -> receiver.onFailure(new IOException("Failed to write to MediaStore"))); + } + sourceFile.delete(); + } + } + + private long getMediaDuration(File file) { + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + try { + mmr.setDataSource(file.getAbsolutePath()); + String dur = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + if (dur != null) { + return Long.parseLong(dur); + } + } catch (Exception e) { + Log.e(TAG, "Could not read media duration", e); + } finally { + try { mmr.release(); } catch (Exception ignored) {} + } + return 0; + } + + private Notification buildNotification() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(YOUR_NOTIFICATION_CHANNEL_ID, "SaidIt Service", NotificationManager.IMPORTANCE_LOW); + getSystemService(NotificationManager.class).createNotificationChannel(channel); + } + + Intent notificationIntent = new Intent(this, SaidItActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE); + + return new NotificationCompat.Builder(this, YOUR_NOTIFICATION_CHANNEL_ID) + .setContentTitle(getText(R.string.app_name)) + .setContentText(getText(R.string.notification_text)) + .setSmallIcon(R.drawable.ic_hearing) + .setContentIntent(pendingIntent) + .build(); + } + + public interface WavFileReceiver { + void onSuccess(Uri fileUri, float runtime); + void onFailure(Exception e); + } + + public interface StateCallback { + void state(boolean listeningEnabled, boolean recording, float memorized, float totalMemory, float recorded); + } + + class BackgroundRecorderBinder extends Binder { + public SaidItService getService() { + return SaidItService.this; + } + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SaveClipBottomSheet.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SaveClipBottomSheet.java new file mode 100644 index 00000000..603018b7 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SaveClipBottomSheet.java @@ -0,0 +1,98 @@ +package eu.mrogalski.saidit; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.textfield.TextInputEditText; + +import eu.mrogalski.android.TimeFormat; + +public class SaveClipBottomSheet extends BottomSheetDialogFragment { + + public interface SaveClipListener { + void onSaveClip(String fileName, float durationInSeconds); + } + + private static final String ARG_MEMORIZED_DURATION = "memorized_duration"; + private float memorizedDuration; + private SaveClipListener listener; + + public static SaveClipBottomSheet newInstance(float memorizedDuration) { + SaveClipBottomSheet fragment = new SaveClipBottomSheet(); + Bundle args = new Bundle(); + args.putFloat(ARG_MEMORIZED_DURATION, memorizedDuration); + fragment.setArguments(args); + return fragment; + } + + public void setSaveClipListener(SaveClipListener listener) { + this.listener = listener; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getArguments() != null) { + memorizedDuration = getArguments().getFloat(ARG_MEMORIZED_DURATION); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.bottom_sheet_save_clip, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final TextInputEditText fileNameInput = view.findViewById(R.id.recording_name); + final ChipGroup durationChipGroup = view.findViewById(R.id.duration_chip_group); + final Chip durationAllChip = view.findViewById(R.id.duration_all); + final MaterialButton saveButton = view.findViewById(R.id.save_button); + + // Update the "All memory" chip with the actual duration + durationAllChip.setText(getString(R.string.all_memory) + " (" + TimeFormat.shortTimer(memorizedDuration) + ")"); + + // Set default selection + ((Chip)view.findViewById(R.id.duration_1m)).setChecked(true); + + saveButton.setOnClickListener(v -> { + String fileName = fileNameInput.getText() != null ? fileNameInput.getText().toString().trim() : ""; + if (fileName.isEmpty()) { + Toast.makeText(getContext(), "Please enter a file name", Toast.LENGTH_SHORT).show(); + return; + } + + int checkedChipId = durationChipGroup.getCheckedChipId(); + float durationInSeconds = 0; + + if (checkedChipId == R.id.duration_1m) { + durationInSeconds = 60; + } else if (checkedChipId == R.id.duration_5m) { + durationInSeconds = 300; + } else if (checkedChipId == R.id.duration_30m) { + durationInSeconds = 1800; + } else if (checkedChipId == R.id.duration_all) { + durationInSeconds = memorizedDuration; + } + + if (listener != null) { + listener.onSaveClip(fileName, durationInSeconds); + } + dismiss(); + }); + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SettingsActivity.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SettingsActivity.java index 23762ffe..131c796d 100644 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/SettingsActivity.java +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SettingsActivity.java @@ -1,269 +1,239 @@ package eu.mrogalski.saidit; -import android.app.Activity; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; -import android.content.res.AssetManager; -import android.content.res.Resources; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.MediaCodecInfo; -import android.media.MediaCodecList; +import static eu.mrogalski.saidit.SaidIt.PACKAGE_NAME; + +import android.content.SharedPreferences; import android.os.Bundle; -import android.os.Handler; import android.os.IBinder; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.LinearLayout; import android.widget.TextView; -import eu.mrogalski.StringFormat; -import eu.mrogalski.android.TimeFormat; -import eu.mrogalski.android.Views; - -public class SettingsActivity extends Activity { - static final String TAG = SettingsActivity.class.getSimpleName(); - private final MemoryOnClickListener memoryClickListener = new MemoryOnClickListener(); - private final QualityOnClickListener qualityClickListener = new QualityOnClickListener(); - +import androidx.appcompat.app.AppCompatActivity; - final WorkingDialog dialog = new WorkingDialog(); +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.button.MaterialButtonToggleGroup; +import com.google.android.material.slider.Slider; +import com.google.android.material.switchmaterial.SwitchMaterial; - @Override - protected void onStart() { - super.onStart(); - Intent intent = new Intent(this, SaidItService.class); - bindService(intent, connection, Context.BIND_AUTO_CREATE); - } +import eu.mrogalski.StringFormat; +import eu.mrogalski.android.TimeFormat; - @Override - protected void onStop() { - super.onStop(); - unbindService(connection); - } +public class SettingsActivity extends AppCompatActivity { + + private SaidItService service; + private TextView historyLimitTextView; + private MaterialButtonToggleGroup memoryToggleGroup; + private MaterialButtonToggleGroup qualityToggleGroup; + private Button memoryLowButton, memoryMediumButton, memoryHighButton; + private Button quality8kHzButton, quality16kHzButton, quality48kHzButton; + private SwitchMaterial autoSaveSwitch; + private Slider autoSaveDurationSlider; + private TextView autoSaveDurationLabel; + + private SharedPreferences sharedPreferences; + + private boolean isBound = false; + + private final MaterialButtonToggleGroup.OnButtonCheckedListener memoryToggleListener = (group, checkedId, isChecked) -> { + if (isChecked && isBound) { + final long maxMemory = Runtime.getRuntime().maxMemory(); + long memorySize = maxMemory / 4; // Default to low + if (checkedId == R.id.memory_medium) { + memorySize = maxMemory / 2; + } else if (checkedId == R.id.memory_high) { + memorySize = (long) (maxMemory * 0.90); + } + service.setMemorySize(memorySize); + updateHistoryLimit(); + } + }; - SaidItService service; - ServiceConnection connection = new ServiceConnection() { + private final MaterialButtonToggleGroup.OnButtonCheckedListener qualityToggleListener = (group, checkedId, isChecked) -> { + if (isChecked && isBound) { + int sampleRate = 8000; // Default to 8kHz + if (checkedId == R.id.quality_16kHz) { + sampleRate = 16000; + } else if (checkedId == R.id.quality_48kHz) { + sampleRate = 48000; + } + service.setSampleRate(sampleRate); + updateHistoryLimit(); + } + }; + private final ServiceConnection connection = new ServiceConnection() { @Override - public void onServiceConnected(ComponentName className, - IBinder binder) { + public void onServiceConnected(ComponentName className, IBinder binder) { SaidItService.BackgroundRecorderBinder typedBinder = (SaidItService.BackgroundRecorderBinder) binder; service = typedBinder.getService(); + isBound = true; syncUI(); } @Override public void onServiceDisconnected(ComponentName arg0) { + isBound = false; service = null; } }; - final TimeFormat.Result timeFormatResult = new TimeFormat.Result(); - - private void syncUI() { - final long maxMemory = Runtime.getRuntime().maxMemory(); - ((Button) findViewById(R.id.memory_low)).setText(StringFormat.shortFileSize(maxMemory / 4)); - ((Button) findViewById(R.id.memory_medium)).setText(StringFormat.shortFileSize(maxMemory / 2)); - ((Button) findViewById(R.id.memory_high)).setText(StringFormat.shortFileSize(maxMemory * 3 / 4)); - - TimeFormat.naturalLanguage(getResources(), service.getBytesToSeconds() * service.getMemorySize(), timeFormatResult); - ((TextView)findViewById(R.id.history_limit)).setText(timeFormatResult.text); - - highlightButtons(); - } - - void highlightButtons() { - final long maxMemory = Runtime.getRuntime().maxMemory(); - - int button = (int)(service.getMemorySize() / (maxMemory / 4)); // 1 - memory_low; 2 - memory_medium; 3 - memory_high - highlightButton(R.id.memory_low, R.id.memory_medium, R.id.memory_high, button); - - int samplingRate = service.getSamplingRate(); - if(samplingRate >= 44100) button = 3; - else if(samplingRate >= 16000) button = 2; - else button = 1; - highlightButton(R.id.quality_8kHz, R.id.quality_16kHz, R.id.quality_48kHz, button); - } - - private void highlightButton(int button1, int button2, int button3, int i) { - findViewById(button1).setBackgroundResource(1 == i ? R.drawable.green_button : R.drawable.gray_button); - findViewById(button2).setBackgroundResource(2 == i ? R.drawable.green_button : R.drawable.gray_button); - findViewById(button3).setBackgroundResource(3 == i ? R.drawable.green_button : R.drawable.gray_button); - } - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + + // Initialize UI components + MaterialToolbar toolbar = findViewById(R.id.toolbar); + historyLimitTextView = findViewById(R.id.history_limit); + memoryToggleGroup = findViewById(R.id.memory_toggle_group); + qualityToggleGroup = findViewById(R.id.quality_toggle_group); + memoryLowButton = findViewById(R.id.memory_low); + memoryMediumButton = findViewById(R.id.memory_medium); + memoryHighButton = findViewById(R.id.memory_high); + quality8kHzButton = findViewById(R.id.quality_8kHz); + quality16kHzButton = findViewById(R.id.quality_16kHz); + quality48kHzButton = findViewById(R.id.quality_48kHz); + autoSaveSwitch = findViewById(R.id.auto_save_switch); + autoSaveDurationSlider = findViewById(R.id.auto_save_duration_slider); + autoSaveDurationLabel = findViewById(R.id.auto_save_duration_label); + Button howToButton = findViewById(R.id.how_to_button); + Button showTourButton = findViewById(R.id.show_tour_button); + + sharedPreferences = getSharedPreferences(PACKAGE_NAME, MODE_PRIVATE); + + + // Setup Toolbar + toolbar.setNavigationOnClickListener(v -> finish()); + + // Setup How-To Button + howToButton.setOnClickListener(v -> startActivity(new Intent(this, HowToActivity.class))); + showTourButton.setOnClickListener(v -> { + sharedPreferences.edit().putBoolean("show_tour_on_next_launch", true).apply(); + finish(); + }); - final AssetManager assets = getAssets(); - final Resources resources = getResources(); - - final float density = resources.getDisplayMetrics().density; - - final Typeface robotoCondensedBold = Typeface.createFromAsset(assets,"RobotoCondensedBold.ttf"); - final Typeface robotoCondensedRegular = Typeface.createFromAsset(assets, "RobotoCondensed-Regular.ttf"); - - final ViewGroup root = (ViewGroup) getLayoutInflater().inflate(R.layout.activity_settings, null); - Views.search(root, new Views.SearchViewCallback() { - @Override - public void onView(View view, ViewGroup parent) { - if(view instanceof Button) { - final Button button = (Button) view; - button.setTypeface(robotoCondensedBold); - } else if(view instanceof TextView) { - final String tag = (String) view.getTag(); - final TextView textView = (TextView) view; - if(tag != null) { - if(tag.equals("bold")) { - textView.setTypeface(robotoCondensedBold); - } else { - textView.setTypeface(robotoCondensedRegular); - } - } else { - textView.setTypeface(robotoCondensedRegular); - } + // Setup Listeners + memoryToggleGroup.addOnButtonCheckedListener(memoryToggleListener); + qualityToggleGroup.addOnButtonCheckedListener(qualityToggleListener); + + autoSaveSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + sharedPreferences.edit().putBoolean("auto_save_enabled", isChecked).apply(); + autoSaveDurationSlider.setEnabled(isChecked); + autoSaveDurationLabel.setEnabled(isChecked); + if (isBound) { + if (isChecked) { + service.scheduleAutoSave(); + } else { + service.cancelAutoSave(); } } }); - root.findViewById(R.id.settings_return).setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - finish(); + autoSaveDurationSlider.addOnChangeListener((slider, value, fromUser) -> { + int minutes = (int) value; + updateAutoSaveLabel(minutes); + if (fromUser) { + sharedPreferences.edit().putInt("auto_save_duration", minutes * 60).apply(); + if (isBound) { + service.scheduleAutoSave(); + } } }); + } - final LinearLayout settingsLayout = (LinearLayout) root.findViewById(R.id.settings_layout); - - final FrameLayout myFrameLayout = new FrameLayout(this) { - @Override - protected boolean fitSystemWindows(Rect insets) { - settingsLayout.setPadding(insets.left, insets.top, insets.right, insets.bottom); - return true; - } - }; - - myFrameLayout.addView(root); - - root.findViewById(R.id.memory_low).setOnClickListener(memoryClickListener); - root.findViewById(R.id.memory_medium).setOnClickListener(memoryClickListener); - root.findViewById(R.id.memory_high).setOnClickListener(memoryClickListener); - - initSampleRateButton(root, R.id.quality_8kHz, 8000, 11025); - initSampleRateButton(root, R.id.quality_16kHz, 16000, 22050); - initSampleRateButton(root, R.id.quality_48kHz, 48000, 44100); + @Override + protected void onStart() { + super.onStart(); + Intent intent = new Intent(this, SaidItService.class); + bindService(intent, connection, Context.BIND_AUTO_CREATE); + } - //debugPrintCodecs(); + @Override + protected void onStop() { + super.onStop(); + if (isBound) { + unbindService(connection); + isBound = false; + } + } - dialog.setDescriptionStringId(R.string.work_preparing_memory); + private void syncUI() { + if (!isBound || service == null) return; - setContentView(myFrameLayout); - } + // Remove listeners to prevent programmatic changes from triggering them + memoryToggleGroup.removeOnButtonCheckedListener(memoryToggleListener); + qualityToggleGroup.removeOnButtonCheckedListener(qualityToggleListener); - private void debugPrintCodecs() { - final int codecCount = MediaCodecList.getCodecCount(); - for(int i = 0; i < codecCount; ++i) { - final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); - if(!info.isEncoder()) continue; - boolean audioFound = false; - String types = ""; - final String[] supportedTypes = info.getSupportedTypes(); - for(int j = 0; j < supportedTypes.length; ++j) { - if(j > 0) - types += ", "; - types += supportedTypes[j]; - if(supportedTypes[j].startsWith("audio")) audioFound = true; - } - if(!audioFound) continue; - Log.d(TAG, "Codec " + i + ": " + info.getName() + " (" + types + ") encoder: " + info.isEncoder()); + // Set memory button text + final long maxMemory = Runtime.getRuntime().maxMemory(); + memoryLowButton.setText(StringFormat.shortFileSize(maxMemory / 4)); + memoryMediumButton.setText(StringFormat.shortFileSize(maxMemory / 2)); + memoryHighButton.setText(StringFormat.shortFileSize((long) (maxMemory * 0.90))); + + // Set memory button state + long currentMemory = service.getMemorySize(); + if (currentMemory <= maxMemory / 4) { + memoryToggleGroup.check(R.id.memory_low); + } else if (currentMemory <= maxMemory / 2) { + memoryToggleGroup.check(R.id.memory_medium); + } else { + memoryToggleGroup.check(R.id.memory_high); } - } - private void initSampleRateButton(ViewGroup layout, int buttonId, int primarySampleRate, int secondarySampleRate) { - Button button = (Button) layout.findViewById(buttonId); - button.setOnClickListener(qualityClickListener); - if(testSampleRateValid(primarySampleRate)) { - button.setText(String.format("%d kHz", primarySampleRate / 1000)); - button.setTag(primarySampleRate); - } else if(testSampleRateValid(secondarySampleRate)) { - button.setText(String.format("%d kHz", secondarySampleRate / 1000)); - button.setTag(secondarySampleRate); + // Set quality button state + int currentRate = service.getSamplingRate(); + if (currentRate >= 48000) { + qualityToggleGroup.check(R.id.quality_48kHz); + } else if (currentRate >= 16000) { + qualityToggleGroup.check(R.id.quality_16kHz); } else { - button.setVisibility(View.GONE); + qualityToggleGroup.check(R.id.quality_8kHz); } - } - private boolean testSampleRateValid(int sampleRate) { - final int bufferSize = AudioRecord.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); - return bufferSize > 0; - } + // Load and apply auto-save settings + boolean autoSaveEnabled = sharedPreferences.getBoolean("auto_save_enabled", false); + autoSaveSwitch.setChecked(autoSaveEnabled); + autoSaveDurationSlider.setEnabled(autoSaveEnabled); + autoSaveDurationLabel.setEnabled(autoSaveEnabled); - private class MemoryOnClickListener implements View.OnClickListener { - @Override - public void onClick(View v) { - final long memory = getMultiplier(v) * Runtime.getRuntime().maxMemory() / 4; - dialog.show(getFragmentManager(), "Preparing memory"); - - new Handler().post(new Runnable() { - @Override - public void run() { - service.setMemorySize(memory); - service.getState(new SaidItService.StateCallback() { - @Override - public void state(boolean listeningEnabled, boolean recording, float memorized, float totalMemory, float recorded) { - syncUI(); - if (dialog.isVisible()) dialog.dismiss(); - } - }); - } - }); - } + int autoSaveDurationSeconds = sharedPreferences.getInt("auto_save_duration", 600); // Default to 10 minutes + int autoSaveDurationMinutes = autoSaveDurationSeconds / 60; + autoSaveDurationSlider.setValue(autoSaveDurationMinutes); + updateAutoSaveLabel(autoSaveDurationMinutes); - private int getMultiplier(View button) { - switch (button.getId()) { - case R.id.memory_high: return 3; - case R.id.memory_medium: return 2; - case R.id.memory_low: return 1; - } - return 0; - } + updateHistoryLimit(); + + // Re-add listeners + memoryToggleGroup.addOnButtonCheckedListener(memoryToggleListener); + qualityToggleGroup.addOnButtonCheckedListener(qualityToggleListener); } - private class QualityOnClickListener implements View.OnClickListener { - @Override - public void onClick(View v) { - final int sampleRate = getSampleRate(v); - dialog.show(getFragmentManager(), "Preparing memory"); - - new Handler().post(new Runnable() { - @Override - public void run() { - service.setSampleRate(sampleRate); - service.getState(new SaidItService.StateCallback() { - @Override - public void state(boolean listeningEnabled, boolean recording, float memorized, float totalMemory, float recorded) { - syncUI(); - if (dialog.isVisible()) dialog.dismiss(); - } - }); - } - }); + private void updateHistoryLimit() { + if (isBound && service != null) { + TimeFormat.Result timeFormatResult = new TimeFormat.Result(); + float historyInSeconds = service.getBytesToSeconds() * service.getMemorySize(); + TimeFormat.naturalLanguage(getResources(), historyInSeconds, timeFormatResult); + historyLimitTextView.setText(timeFormatResult.text); } + } - private int getSampleRate(View button) { - Object tag = button.getTag(); - if(tag instanceof Integer) { - return ((Integer) tag).intValue(); + private void updateAutoSaveLabel(int totalMinutes) { + if (totalMinutes < 60) { + autoSaveDurationLabel.setText(getResources().getQuantityString(R.plurals.minute_plural, totalMinutes, totalMinutes)); + } else { + int hours = totalMinutes / 60; + int minutes = totalMinutes % 60; + String hourText = getResources().getQuantityString(R.plurals.hour_plural, hours, hours); + if (minutes == 0) { + autoSaveDurationLabel.setText(hourText); + } else { + String minuteText = getResources().getQuantityString(R.plurals.minute_plural, minutes, minutes); + autoSaveDurationLabel.setText(getString(R.string.time_join, hourText, minuteText)); } - return 8000; } } } diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SystemClockWrapper.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SystemClockWrapper.java new file mode 100644 index 00000000..7e8027a1 --- /dev/null +++ b/SaidIt/src/main/java/eu/mrogalski/saidit/SystemClockWrapper.java @@ -0,0 +1,10 @@ +package eu.mrogalski.saidit; + +import android.os.SystemClock; + +public class SystemClockWrapper implements Clock { + @Override + public long uptimeMillis() { + return SystemClock.uptimeMillis(); + } +} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/ThemedDialog.java b/SaidIt/src/main/java/eu/mrogalski/saidit/ThemedDialog.java deleted file mode 100644 index 28b6fd9c..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/ThemedDialog.java +++ /dev/null @@ -1,67 +0,0 @@ -package eu.mrogalski.saidit; - -import android.app.Activity; -import android.app.Dialog; -import android.app.DialogFragment; -import android.content.res.AssetManager; -import android.content.res.Resources; -import android.graphics.Typeface; -import android.os.Bundle; -import android.view.View; -import android.view.ViewGroup; -import android.view.Window; -import android.widget.Button; -import android.widget.TextView; - -import eu.mrogalski.android.Views; - -public class ThemedDialog extends DialogFragment { - static final String TAG = ThemedDialog.class.getName(); - - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Dialog dialog = super.onCreateDialog(savedInstanceState); - dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); - dialog.getWindow().getDecorView().setBackgroundDrawable(null); - return dialog; - } - - protected void fixFonts(View root) { - final Activity activity = getActivity(); - final Resources resources = activity.getResources(); - - final AssetManager assets = activity.getAssets(); - - final Typeface robotoCondensedBold = Typeface.createFromAsset(assets,"RobotoCondensedBold.ttf"); - final Typeface robotoCondensedRegular = Typeface.createFromAsset(assets, "RobotoCondensed-Regular.ttf"); - - final float density = resources.getDisplayMetrics().density; - - Views.search((ViewGroup) root, new Views.SearchViewCallback() { - @Override - public void onView(View view, ViewGroup parent) { - if (view instanceof Button) { - final Button button = (Button) view; - button.setTypeface(robotoCondensedRegular); - } else if (view instanceof TextView) { - final String tag = (String) view.getTag(); - final TextView textView = (TextView) view; - if (tag != null) { - if (tag.equals("titleBar")) { - textView.setTypeface(robotoCondensedBold); - textView.setShadowLayer(0.01f, 0, density * 2, resources.getColor(getShadowColorId())); - } else if (tag.equals("bold")) { - textView.setTypeface(robotoCondensedBold); - } - } else { - textView.setTypeface(robotoCondensedRegular); - } - } - } - }); - } - - int getShadowColorId() { - return R.color.dark_green; - } -} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/WorkingDialog.java b/SaidIt/src/main/java/eu/mrogalski/saidit/WorkingDialog.java deleted file mode 100644 index 8082c798..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/WorkingDialog.java +++ /dev/null @@ -1,57 +0,0 @@ -package eu.mrogalski.saidit; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -public class WorkingDialog extends ThemedDialog { - private int descriptionStringId = R.string.work_default; - - @Override - public void onSaveInstanceState( Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt(getDescriptionKey(), getDescriptionStringId()); - } - - private String getDescriptionKey() { - return WorkingDialog.class.getName() + "_description_id"; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if(savedInstanceState != null && savedInstanceState.containsKey(getDescriptionKey())) { - descriptionStringId = savedInstanceState.getInt(getDescriptionKey()); - } - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - final View root = inflater.inflate(R.layout.progress_dialog, container); - - fixFonts(root); - - setDescriptionOnView(root); - - return root; - } - - private void setDescriptionOnView(View root) { - ((TextView) root.findViewById(R.id.progress_description)).setText(getDescriptionStringId()); - } - - - public int getDescriptionStringId() { - return descriptionStringId; - } - - public void setDescriptionStringId(int descriptionStringId) { - this.descriptionStringId = descriptionStringId; - final View root = getView(); - if(root != null) { - setDescriptionOnView(root); - } - } -} diff --git a/SaidIt/src/main/res/color/button_text.xml b/SaidIt/src/main/res/color/button_text.xml deleted file mode 100644 index 7c1b6ff4..00000000 --- a/SaidIt/src/main/res/color/button_text.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable-xxhdpi/github_logo.png b/SaidIt/src/main/res/drawable-xxhdpi/github_logo.png new file mode 100644 index 00000000..ae47fd46 Binary files /dev/null and b/SaidIt/src/main/res/drawable-xxhdpi/github_logo.png differ diff --git a/SaidIt/src/main/res/drawable/circle_button.xml b/SaidIt/src/main/res/drawable/circle_button.xml deleted file mode 100644 index 3fd282aa..00000000 --- a/SaidIt/src/main/res/drawable/circle_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/SaidIt/src/main/res/drawable/circle_button_normal.xml b/SaidIt/src/main/res/drawable/circle_button_normal.xml deleted file mode 100644 index 752cd53b..00000000 --- a/SaidIt/src/main/res/drawable/circle_button_normal.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/circle_button_pressed.xml b/SaidIt/src/main/res/drawable/circle_button_pressed.xml deleted file mode 100644 index c2c9a8bd..00000000 --- a/SaidIt/src/main/res/drawable/circle_button_pressed.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/cling_button_bg.xml b/SaidIt/src/main/res/drawable/cling_button_bg.xml deleted file mode 100644 index 7fd5d37e..00000000 --- a/SaidIt/src/main/res/drawable/cling_button_bg.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - diff --git a/SaidIt/src/main/res/drawable/dashed_line.xml b/SaidIt/src/main/res/drawable/dashed_line.xml deleted file mode 100644 index 093fbd2c..00000000 --- a/SaidIt/src/main/res/drawable/dashed_line.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/dialog_content.xml b/SaidIt/src/main/res/drawable/dialog_content.xml deleted file mode 100644 index 9ccdab7e..00000000 --- a/SaidIt/src/main/res/drawable/dialog_content.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/dialog_title.xml b/SaidIt/src/main/res/drawable/dialog_title.xml deleted file mode 100644 index f8b34c79..00000000 --- a/SaidIt/src/main/res/drawable/dialog_title.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/error_dialog_title.xml b/SaidIt/src/main/res/drawable/error_dialog_title.xml deleted file mode 100644 index f9de4472..00000000 --- a/SaidIt/src/main/res/drawable/error_dialog_title.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gold_button.xml b/SaidIt/src/main/res/drawable/gold_button.xml deleted file mode 100644 index 71ede43a..00000000 --- a/SaidIt/src/main/res/drawable/gold_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gold_button_normal.xml b/SaidIt/src/main/res/drawable/gold_button_normal.xml deleted file mode 100644 index 909fa1ec..00000000 --- a/SaidIt/src/main/res/drawable/gold_button_normal.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gold_button_pressed.xml b/SaidIt/src/main/res/drawable/gold_button_pressed.xml deleted file mode 100644 index 77dae58e..00000000 --- a/SaidIt/src/main/res/drawable/gold_button_pressed.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gray_button.xml b/SaidIt/src/main/res/drawable/gray_button.xml deleted file mode 100644 index 44dc5897..00000000 --- a/SaidIt/src/main/res/drawable/gray_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gray_button_focused.xml b/SaidIt/src/main/res/drawable/gray_button_focused.xml deleted file mode 100644 index a836056a..00000000 --- a/SaidIt/src/main/res/drawable/gray_button_focused.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gray_button_normal.xml b/SaidIt/src/main/res/drawable/gray_button_normal.xml deleted file mode 100644 index 12996fa2..00000000 --- a/SaidIt/src/main/res/drawable/gray_button_normal.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/gray_button_pressed.xml b/SaidIt/src/main/res/drawable/gray_button_pressed.xml deleted file mode 100644 index 38cb6169..00000000 --- a/SaidIt/src/main/res/drawable/gray_button_pressed.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/green_button.xml b/SaidIt/src/main/res/drawable/green_button.xml deleted file mode 100644 index 23d2aaa3..00000000 --- a/SaidIt/src/main/res/drawable/green_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/green_button_normal.xml b/SaidIt/src/main/res/drawable/green_button_normal.xml deleted file mode 100644 index d0107561..00000000 --- a/SaidIt/src/main/res/drawable/green_button_normal.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/green_button_pressed.xml b/SaidIt/src/main/res/drawable/green_button_pressed.xml deleted file mode 100644 index de121364..00000000 --- a/SaidIt/src/main/res/drawable/green_button_pressed.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/ic_arrow_back.xml b/SaidIt/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..51f0c418 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/SaidIt/src/main/res/drawable/ic_delete.xml b/SaidIt/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..bf998503 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/SaidIt/src/main/res/drawable/ic_folder.xml b/SaidIt/src/main/res/drawable/ic_folder.xml new file mode 100644 index 00000000..9385ffb6 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/ic_hearing.xml b/SaidIt/src/main/res/drawable/ic_hearing.xml new file mode 100644 index 00000000..cfe41c0f --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_hearing.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/ic_help.xml b/SaidIt/src/main/res/drawable/ic_help.xml new file mode 100644 index 00000000..a2378d89 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_help.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/ic_pause.xml b/SaidIt/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..3e6bb8cd --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,10 @@ + + + diff --git a/SaidIt/src/main/res/drawable/ic_play_arrow.xml b/SaidIt/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 00000000..037f8791 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,10 @@ + + + diff --git a/SaidIt/src/main/res/drawable/ic_save.xml b/SaidIt/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..7a110e66 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/ic_stop.xml b/SaidIt/src/main/res/drawable/ic_stop.xml new file mode 100644 index 00000000..6d1ef416 --- /dev/null +++ b/SaidIt/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/red_circle_button.xml b/SaidIt/src/main/res/drawable/red_circle_button.xml deleted file mode 100644 index 757582c3..00000000 --- a/SaidIt/src/main/res/drawable/red_circle_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/SaidIt/src/main/res/drawable/red_circle_button_normal.xml b/SaidIt/src/main/res/drawable/red_circle_button_normal.xml deleted file mode 100644 index ab654596..00000000 --- a/SaidIt/src/main/res/drawable/red_circle_button_normal.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/red_circle_button_pressed.xml b/SaidIt/src/main/res/drawable/red_circle_button_pressed.xml deleted file mode 100644 index c1860a11..00000000 --- a/SaidIt/src/main/res/drawable/red_circle_button_pressed.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/top_gray_button.xml b/SaidIt/src/main/res/drawable/top_gray_button.xml deleted file mode 100644 index 6ba7be27..00000000 --- a/SaidIt/src/main/res/drawable/top_gray_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/SaidIt/src/main/res/drawable/top_gray_button_normal.xml b/SaidIt/src/main/res/drawable/top_gray_button_normal.xml deleted file mode 100644 index caf8eef9..00000000 --- a/SaidIt/src/main/res/drawable/top_gray_button_normal.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/top_gray_button_pressed.xml b/SaidIt/src/main/res/drawable/top_gray_button_pressed.xml deleted file mode 100644 index 832347d0..00000000 --- a/SaidIt/src/main/res/drawable/top_gray_button_pressed.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/top_green_button.xml b/SaidIt/src/main/res/drawable/top_green_button.xml deleted file mode 100644 index 2395336d..00000000 --- a/SaidIt/src/main/res/drawable/top_green_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/SaidIt/src/main/res/drawable/top_green_button_normal.xml b/SaidIt/src/main/res/drawable/top_green_button_normal.xml deleted file mode 100644 index 744a8c02..00000000 --- a/SaidIt/src/main/res/drawable/top_green_button_normal.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/top_green_button_pressed.xml b/SaidIt/src/main/res/drawable/top_green_button_pressed.xml deleted file mode 100644 index 8ab86d61..00000000 --- a/SaidIt/src/main/res/drawable/top_green_button_pressed.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/white_button.xml b/SaidIt/src/main/res/drawable/white_button.xml deleted file mode 100644 index 098d7a0f..00000000 --- a/SaidIt/src/main/res/drawable/white_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/white_button_focused.xml b/SaidIt/src/main/res/drawable/white_button_focused.xml deleted file mode 100644 index 1fe0a8ca..00000000 --- a/SaidIt/src/main/res/drawable/white_button_focused.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/white_button_normal.xml b/SaidIt/src/main/res/drawable/white_button_normal.xml deleted file mode 100644 index cd904a07..00000000 --- a/SaidIt/src/main/res/drawable/white_button_normal.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/white_button_pressed.xml b/SaidIt/src/main/res/drawable/white_button_pressed.xml deleted file mode 100644 index 050ed24b..00000000 --- a/SaidIt/src/main/res/drawable/white_button_pressed.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/drawable/window_background.xml b/SaidIt/src/main/res/drawable/window_background.xml deleted file mode 100644 index e7af684d..00000000 --- a/SaidIt/src/main/res/drawable/window_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/SaidIt/src/main/res/font/inter.xml b/SaidIt/src/main/res/font/inter.xml new file mode 100644 index 00000000..dc84b89f --- /dev/null +++ b/SaidIt/src/main/res/font/inter.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/font/inter_bold.ttf b/SaidIt/src/main/res/font/inter_bold.ttf new file mode 100644 index 00000000..46b3583c Binary files /dev/null and b/SaidIt/src/main/res/font/inter_bold.ttf differ diff --git a/SaidIt/src/main/res/font/inter_regular.ttf b/SaidIt/src/main/res/font/inter_regular.ttf new file mode 100644 index 00000000..6b088a71 Binary files /dev/null and b/SaidIt/src/main/res/font/inter_regular.ttf differ diff --git a/SaidIt/src/main/res/layout/activity_how_to.xml b/SaidIt/src/main/res/layout/activity_how_to.xml new file mode 100644 index 00000000..7f813235 --- /dev/null +++ b/SaidIt/src/main/res/layout/activity_how_to.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/SaidIt/src/main/res/layout/activity_recordings.xml b/SaidIt/src/main/res/layout/activity_recordings.xml new file mode 100644 index 00000000..f25c9b94 --- /dev/null +++ b/SaidIt/src/main/res/layout/activity_recordings.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SaidIt/src/main/res/layout/activity_settings.xml b/SaidIt/src/main/res/layout/activity_settings.xml index 0787018b..02337e42 100644 --- a/SaidIt/src/main/res/layout/activity_settings.xml +++ b/SaidIt/src/main/res/layout/activity_settings.xml @@ -1,196 +1,264 @@ - - + android:layout_height="match_parent" + android:fitsSystemWindows="true" + android:orientation="vertical" + tools:context=".SettingsActivity"> - - - - - + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_arrow_back" + app:title="@string/settings" /> - + - + - -