diff --git a/app/build.gradle b/app/build.gradle index 0662fe0044..ff023ad3fa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -129,8 +129,10 @@ dependencies { implementation project(path: ':common:toolbar') implementation project(path: ':common:upgrade') implementation project(path: ':common:ui:core') + implementation project(path: ':common:util') implementation project(path: ':feature:audio') + implementation project(path: ':feature:audioshare') implementation project(path: ':feature:downloadmanager') implementation project(path: ':feature:qarilist') @@ -170,7 +172,7 @@ dependencies { kapt("com.squareup.moshi:moshi-kotlin-codegen:${moshiVersion}") implementation "dev.chrisbanes:insetter-ktx:0.3.1" - implementation 'com.jakewharton.timber:timber:5.0.1' + implementation "com.jakewharton.timber:timber:${timberVersion}" debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' implementation 'com.google.firebase:firebase-crashlytics:18.2.13' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a7d698a0ec..22a51739d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,7 +101,7 @@ android:exported="false"> + android:resource="@xml/file_paths" /> diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioRequest.kt b/app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioRequest.kt index d24f389fff..02a73133b3 100644 --- a/app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioRequest.kt +++ b/app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioRequest.kt @@ -3,6 +3,7 @@ package com.quran.labs.androidquran.dao.audio import android.os.Parcelable import com.quran.labs.androidquran.common.audio.model.QariItem import com.quran.data.model.SuraAyah +import com.quran.labs.androidquran.common.audio.model.AudioPathInfo import kotlinx.parcelize.Parcelize @Parcelize @@ -13,7 +14,8 @@ data class AudioRequest(val start: SuraAyah, val rangeRepeatInfo: Int = 0, val enforceBounds: Boolean, val shouldStream: Boolean, - val audioPathInfo: AudioPathInfo) : Parcelable { + val audioPathInfo: AudioPathInfo +) : Parcelable { fun isGapless() = qari.isGapless fun needsIsti3athaAudio() = !isGapless() || audioPathInfo.gaplessDatabase?.contains("minshawi_murattal") ?: false diff --git a/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java b/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java index 79040def61..be1715f33d 100644 --- a/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java +++ b/app/src/main/java/com/quran/labs/androidquran/data/AyahInfoDatabaseHandler.java @@ -7,7 +7,7 @@ import android.graphics.RectF; import androidx.annotation.NonNull; -import com.quran.labs.androidquran.database.DatabaseUtils; +import com.quran.common.util.database.DatabaseUtils; import com.quran.labs.androidquran.util.QuranFileUtils; import com.quran.page.common.data.AyahBounds; import com.quran.page.common.data.AyahCoordinates; diff --git a/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java b/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java index e7da9e8bb6..43e96f1dee 100644 --- a/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java +++ b/app/src/main/java/com/quran/labs/androidquran/data/QuranDataProvider.java @@ -11,13 +11,13 @@ import android.net.Uri; import android.provider.BaseColumns; +import com.quran.common.util.database.DatabaseUtils; import com.quran.data.core.QuranInfo; import com.quran.labs.androidquran.BuildConfig; import com.quran.labs.androidquran.QuranApplication; import com.quran.labs.androidquran.R; import com.quran.labs.androidquran.common.LocalTranslation; import com.quran.labs.androidquran.database.DatabaseHandler; -import com.quran.labs.androidquran.database.DatabaseUtils; import com.quran.labs.androidquran.database.TranslationsDBAdapter; import com.quran.labs.androidquran.util.QuranFileUtils; import com.quran.labs.androidquran.util.QuranUtils; diff --git a/app/src/main/java/com/quran/labs/androidquran/database/AudioDatabaseVersionChecker.kt b/app/src/main/java/com/quran/labs/androidquran/database/AudioDatabaseVersionChecker.kt index 1bb60957e8..964cf60d7f 100644 --- a/app/src/main/java/com/quran/labs/androidquran/database/AudioDatabaseVersionChecker.kt +++ b/app/src/main/java/com/quran/labs/androidquran/database/AudioDatabaseVersionChecker.kt @@ -1,5 +1,6 @@ package com.quran.labs.androidquran.database +import com.quran.labs.androidquran.common.audio.timing.SuraTimingDatabaseHandler import com.quran.labs.androidquran.feature.audio.VersionableDatabaseChecker import javax.inject.Inject diff --git a/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.kt b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.kt index ccb254ea9b..f1b241ed71 100644 --- a/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.kt +++ b/app/src/main/java/com/quran/labs/androidquran/database/DatabaseHandler.kt @@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat import com.quran.common.search.ArabicSearcher import com.quran.common.search.DefaultSearcher import com.quran.common.search.Searcher +import com.quran.common.util.database.DatabaseUtils import com.quran.data.model.QuranText import com.quran.data.model.VerseRange import com.quran.labs.androidquran.R diff --git a/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java b/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java index 54937c461a..3da020fcd3 100644 --- a/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java +++ b/app/src/main/java/com/quran/labs/androidquran/model/translation/ArabicDatabaseUtils.java @@ -3,6 +3,7 @@ import android.content.Context; import android.database.Cursor; +import com.quran.common.util.database.DatabaseUtils; import com.quran.data.core.QuranInfo; import com.quran.data.model.QuranText; import com.quran.data.model.bookmark.Bookmark; @@ -10,7 +11,6 @@ import com.quran.labs.androidquran.data.QuranFileConstants; import com.quran.data.model.SuraAyah; import com.quran.labs.androidquran.database.DatabaseHandler; -import com.quran.labs.androidquran.database.DatabaseUtils; import com.quran.labs.androidquran.util.QuranFileUtils; import java.util.ArrayList; diff --git a/app/src/main/java/com/quran/labs/androidquran/presenter/audio/AudioPresenter.kt b/app/src/main/java/com/quran/labs/androidquran/presenter/audio/AudioPresenter.kt index 43536c5264..f42fb30e01 100644 --- a/app/src/main/java/com/quran/labs/androidquran/presenter/audio/AudioPresenter.kt +++ b/app/src/main/java/com/quran/labs/androidquran/presenter/audio/AudioPresenter.kt @@ -4,13 +4,14 @@ import android.content.Context import android.content.Intent import com.quran.data.model.SuraAyah import com.quran.labs.androidquran.R +import com.quran.labs.androidquran.common.audio.model.AudioDownloadMetadata +import com.quran.labs.androidquran.common.audio.model.AudioPathInfo import com.quran.labs.androidquran.common.audio.model.QariItem -import com.quran.labs.androidquran.dao.audio.AudioPathInfo +import com.quran.labs.androidquran.common.audio.util.AudioFileUtil import com.quran.labs.androidquran.dao.audio.AudioRequest import com.quran.labs.androidquran.data.QuranDisplayData import com.quran.labs.androidquran.presenter.Presenter import com.quran.labs.androidquran.service.QuranDownloadService -import com.quran.labs.androidquran.common.audio.model.AudioDownloadMetadata import com.quran.labs.androidquran.service.util.ServiceIntentHelper import com.quran.labs.androidquran.ui.PagerActivity import com.quran.labs.androidquran.util.AudioUtils @@ -22,6 +23,7 @@ import javax.inject.Inject class AudioPresenter @Inject constructor(private val quranDisplayData: QuranDisplayData, private val audioUtil: AudioUtils, + private val audioFileUtil: AudioFileUtil, private val quranFileUtils: QuranFileUtils) : Presenter { private var pagerActivity: PagerActivity? = null private var lastAudioRequest: AudioRequest? = null @@ -33,7 +35,7 @@ constructor(private val quranDisplayData: QuranDisplayData, rangeRepeat: Int, enforceRange: Boolean, shouldStream: Boolean) { - val audioPathInfo = getLocalAudioPathInfo(qari) + val audioPathInfo = audioFileUtil.getLocalAudioPathInfo(qari) if (audioPathInfo != null) { // override streaming if all the files are already downloaded val stream = if (shouldStream) { @@ -88,7 +90,7 @@ constructor(private val quranDisplayData: QuranDisplayData, lastAudioRequest?.let { play(it) } } - private fun getDownloadIntent(context: Context, request: AudioRequest): Intent? { + fun getDownloadIntent(context: Context, request: AudioRequest): Intent? { val qari = request.qari val audioPathInfo = request.audioPathInfo val path = audioPathInfo.localDirectory @@ -136,23 +138,6 @@ constructor(private val quranDisplayData: QuranDisplayData, return ServiceIntentHelper.getAudioDownloadIntent(context, url, destination, title) } - private fun getLocalAudioPathInfo(qari: QariItem): AudioPathInfo? { - pagerActivity?.let { - val localPath = audioUtil.getLocalQariUrl(qari) - if (localPath != null) { - val databasePath = audioUtil.getQariDatabasePathIfGapless(qari) - val urlFormat = if (databasePath.isNullOrEmpty()) { - localPath + File.separator + "%d" + File.separator + - "%d" + AudioUtils.AUDIO_EXTENSION - } else { - localPath + File.separator + "%03d" + AudioUtils.AUDIO_EXTENSION - } - return AudioPathInfo(urlFormat, localPath, databasePath) - } - } - return null - } - private fun haveAllFiles(audioPathInfo: AudioPathInfo, start: SuraAyah, end: SuraAyah): Boolean { return audioUtil.haveAllFiles(audioPathInfo.urlFormat, audioPathInfo.localDirectory, start, end, audioPathInfo.gaplessDatabase != null) diff --git a/app/src/main/java/com/quran/labs/androidquran/service/AudioService.kt b/app/src/main/java/com/quran/labs/androidquran/service/AudioService.kt index 06ac074ba6..cd49367b2e 100644 --- a/app/src/main/java/com/quran/labs/androidquran/service/AudioService.kt +++ b/app/src/main/java/com/quran/labs/androidquran/service/AudioService.kt @@ -57,17 +57,17 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.media.session.MediaButtonReceiver +import com.quran.common.util.database.DatabaseUtils import com.quran.data.core.QuranInfo import com.quran.data.model.SuraAyah import com.quran.labs.androidquran.QuranApplication import com.quran.labs.androidquran.R +import com.quran.labs.androidquran.common.audio.timing.SuraTimingDatabaseHandler.Companion.getDatabaseHandler import com.quran.labs.androidquran.dao.audio.AudioPlaybackInfo import com.quran.labs.androidquran.dao.audio.AudioRequest import com.quran.labs.androidquran.data.Constants import com.quran.labs.androidquran.data.QuranDisplayData import com.quran.labs.androidquran.data.QuranFileConstants -import com.quran.labs.androidquran.database.DatabaseUtils.closeCursor -import com.quran.labs.androidquran.database.SuraTimingDatabaseHandler.Companion.getDatabaseHandler import com.quran.labs.androidquran.extension.requiresBasmallah import com.quran.labs.androidquran.presenter.audio.service.AudioQueue import com.quran.labs.androidquran.service.util.AudioFocusHelper @@ -410,27 +410,7 @@ class AudioService : Service(), OnCompletionListener, OnPreparedListener, timingDisposable?.dispose() timingDisposable = Single.fromCallable { val db = getDatabaseHandler(databasePath) - - val map = SparseIntArray() - var cursor: Cursor? = null - try { - cursor = db.getAyahTimings(sura) - Timber.d("got cursor of data") - if (cursor != null && cursor.moveToFirst()) { - do { - val ayah = cursor.getInt(1) - val time = cursor.getInt(2) - map.put(ayah, time) - } while (cursor.moveToNext()) - } - } catch (se: SQLException) { - // don't crash the app if the database is corrupt - Timber.e(se) - } finally { - closeCursor(cursor) - } - - map + db.getAyahTimings(sura) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java b/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java index 3ab6630463..8ba6af7a6a 100644 --- a/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java +++ b/app/src/main/java/com/quran/labs/androidquran/ui/PagerActivity.java @@ -1,5 +1,9 @@ package com.quran.labs.androidquran.ui; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.AUDIO_PAGE; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.TAG_PAGE; +import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.TRANSLATION_PAGE; + import android.app.ProgressDialog; import android.app.SearchManager; import android.content.BroadcastReceiver; @@ -28,6 +32,7 @@ import android.widget.ArrayAdapter; import android.widget.FrameLayout; import android.widget.Toast; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; @@ -45,6 +50,7 @@ import androidx.viewpager.widget.NonRestoringViewPager; import androidx.viewpager.widget.ViewPager; import androidx.viewpager.widget.ViewPager.OnPageChangeListener; + import com.quran.data.core.QuranInfo; import com.quran.data.model.SuraAyah; import com.quran.data.model.selection.AyahSelection; @@ -65,6 +71,8 @@ import com.quran.labs.androidquran.common.LocalTranslationDisplaySort; import com.quran.labs.androidquran.common.QuranAyahInfo; import com.quran.labs.androidquran.common.audio.model.QariItem; +import com.quran.labs.androidquran.common.audio.model.AudioPathInfo; +import com.quran.labs.androidquran.common.audio.util.AudioFileUtil; import com.quran.labs.androidquran.dao.audio.AudioRequest; import com.quran.labs.androidquran.data.Constants; import com.quran.labs.androidquran.data.QuranDataProvider; @@ -73,6 +81,7 @@ import com.quran.labs.androidquran.di.component.activity.PagerActivityComponent; import com.quran.labs.androidquran.di.module.activity.PagerActivityModule; import com.quran.labs.androidquran.di.module.fragment.QuranPageModule; +import com.quran.labs.androidquran.feature.audioshare.AudioShareUtils; import com.quran.labs.androidquran.model.bookmark.BookmarkModel; import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils; import com.quran.labs.androidquran.presenter.audio.AudioPresenter; @@ -120,14 +129,8 @@ import com.quran.page.common.toolbar.di.AyahToolBarInjector; import com.quran.reading.common.AudioEventPresenter; import com.quran.reading.common.ReadingEventPresenter; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.observers.DisposableObserver; -import io.reactivex.rxjava3.observers.DisposableSingleObserver; -import io.reactivex.rxjava3.schedulers.Schedulers; + +import java.io.File; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collections; @@ -135,12 +138,18 @@ import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; + import javax.inject.Inject; -import timber.log.Timber; -import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.AUDIO_PAGE; -import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.TAG_PAGE; -import static com.quran.labs.androidquran.ui.helpers.SlidingPagerAdapter.TRANSLATION_PAGE; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.observers.DisposableObserver; +import io.reactivex.rxjava3.observers.DisposableSingleObserver; +import io.reactivex.rxjava3.schedulers.Schedulers; +import timber.log.Timber; /** * Activity that displays the Quran (in Arabic or translation mode). @@ -231,6 +240,7 @@ public class PagerActivity extends AppCompatActivity implements @Inject QuranAppUtils quranAppUtils; @Inject ShareUtil shareUtil; @Inject AudioUtils audioUtils; + @Inject AudioFileUtil audioFileUtil; @Inject QuranDisplayData quranDisplayData; @Inject QuranInfo quranInfo; @Inject QuranFileUtils quranFileUtils; @@ -251,6 +261,7 @@ public class PagerActivity extends AppCompatActivity implements private final PagerHandler handler = new PagerHandler(this); + private static class PagerHandler extends Handler { private final WeakReference activity; @@ -287,12 +298,21 @@ public void onCreate(Bundle savedInstanceState) { isSplitScreen = quranSettings.isQuranSplitWithTranslation(); audioEventPresenterBridge = new AudioEventPresenterBridge( audioEventPresenter, - suraAyah -> { onAudioPlaybackAyahChanged(suraAyah); return null; } + suraAyah -> { + onAudioPlaybackAyahChanged(suraAyah); + return null; + } ); readingEventPresenterBridge = new ReadingEventPresenterBridge( readingEventPresenter, - () -> { onPageClicked(); return null; }, - ayahSelection -> { onAyahSelectionChanged(ayahSelection); return null; } + () -> { + onPageClicked(); + return null; + }, + ayahSelection -> { + onAyahSelectionChanged(ayahSelection); + return null; + } ); // remove the window background to avoid overdraw. note that, per Romain's blog, this is @@ -421,7 +441,8 @@ public void onPageScrolled(int position, float positionOffset, int positionOffse } else if (position == barPos - 1 || position == barPos + 1) { // Swiping to previous or next ViewPager page (i.e. next or previous quran page) final SelectionIndicator updatedSelectionIndicator = - SelectionIndicatorKt.withXScroll(selectionIndicator, viewPager.getWidth() - positionOffsetPixels); + SelectionIndicatorKt.withXScroll(selectionIndicator, + viewPager.getWidth() - positionOffsetPixels); readingEventPresenterBridge.withSelectionIndicator(updatedSelectionIndicator); } else { readingEventPresenterBridge.clearSelectedAyah(); @@ -541,8 +562,14 @@ public void onPageSelected(int position) { this::getCurrentPage, () -> audioStatusBar, () -> ayahToolBar, - ayah -> { ensurePage(ayah.sura, ayah.ayah); return null; }, - sliderPage -> { showSlider(slidingPagerAdapter.getPagePosition(sliderPage)); return null; } + ayah -> { + ensurePage(ayah.sura, ayah.ayah); + return null; + }, + sliderPage -> { + showSlider(slidingPagerAdapter.getPagePosition(sliderPage)); + return null; + } )); } @@ -1374,16 +1401,16 @@ private void ensurePage(int sura, int ayah) { private void requestTranslationsList() { compositeDisposable.add( Single.fromCallable(() -> - translationsDBAdapter.getTranslations()) + translationsDBAdapter.getTranslations()) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeWith(new DisposableSingleObserver>() { @Override public void onSuccess(@NonNull List translationList) { final List sortedTranslations = new ArrayList<>(translationList); - Collections.sort(sortedTranslations, new LocalTranslationDisplaySort()); + Collections.sort(sortedTranslations, new LocalTranslationDisplaySort()); - int items = sortedTranslations.size(); + int items = sortedTranslations.size(); String[] titles = new String[items]; for (int i = 0; i < items; i++) { LocalTranslation item = sortedTranslations.get(i); @@ -1400,7 +1427,8 @@ public void onSuccess(@NonNull List translationList) { if (currentActiveTranslationsFilesNames.isEmpty() && items > 0) { currentActiveTranslationsFilesNames = new HashSet<>(); for (int i = 0; i < items; i++) { - currentActiveTranslationsFilesNames.add(sortedTranslations.get(i).getFilename()); + currentActiveTranslationsFilesNames.add( + sortedTranslations.get(i).getFilename()); } } activeTranslationsFilesNames = currentActiveTranslationsFilesNames; @@ -1433,7 +1461,8 @@ public void onSuccess(@NonNull Boolean isBookmarked) { if (sura == null || ayah == null) { // page bookmark bookmarksCache.put(page, isBookmarked); - bookmarksMenuItem.setIcon(isBookmarked ? com.quran.labs.androidquran.common.toolbar.R.drawable.ic_favorite : com.quran.labs.androidquran.common.toolbar.R.drawable.ic_not_favorite); + bookmarksMenuItem.setIcon( + isBookmarked ? com.quran.labs.androidquran.common.toolbar.R.drawable.ic_favorite : com.quran.labs.androidquran.common.toolbar.R.drawable.ic_not_favorite); } else { // ayah bookmark SuraAyah suraAyah = new SuraAyah(sura, ayah); @@ -1482,7 +1511,8 @@ private void refreshBookmarksMenu() { bookmarked = bookmarksCache.get(page - 1); } - menuItem.setIcon(bookmarked ? com.quran.labs.androidquran.common.toolbar.R.drawable.ic_favorite : com.quran.labs.androidquran.common.toolbar.R.drawable.ic_not_favorite); + menuItem.setIcon( + bookmarked ? com.quran.labs.androidquran.common.toolbar.R.drawable.ic_favorite : com.quran.labs.androidquran.common.toolbar.R.drawable.ic_not_favorite); } else { supportInvalidateOptionsMenu(); } @@ -1523,7 +1553,7 @@ private void playFromAyah(int page, int startSura, int startAyah) { final SuraAyah start = new SuraAyah(startSura, startAyah); final SuraAyah end = getSelectionEnd(); // handle the case of multiple ayat being selected and play them as a range if so - final SuraAyah ending = (end == null || start.equals(end) || start.after(end))? null : end; + final SuraAyah ending = (end == null || start.equals(end) || start.after(end)) ? null : end; playFromAyah(start, ending, page, 0, 0, ending != null); } @@ -1786,6 +1816,8 @@ public boolean onMenuItemClick(MenuItem item) { shareAyahLink(startSuraAyah, endSuraAyah); } else if (itemId == com.quran.labs.androidquran.common.toolbar.R.id.cab_share_ayah_text) { shareAyah(startSuraAyah, endSuraAyah, false); + } else if (itemId == com.quran.labs.androidquran.common.toolbar.R.id.cab_share_ayah_audio) { + shareAyahAudio(startSuraAyah, endSuraAyah); } else if (itemId == com.quran.labs.androidquran.common.toolbar.R.id.cab_copy_ayah) { shareAyah(startSuraAyah, endSuraAyah, true); } else { @@ -1822,7 +1854,8 @@ private void shareAyah(SuraAyah start, SuraAyah end, final boolean isCopy) { if (isCopy) { shareUtil.copyToClipboard(this, shareText); } else { - shareUtil.shareViaIntent(this, shareText, com.quran.labs.androidquran.common.toolbar.R.string.share_ayah_text); + shareUtil.shareViaIntent(this, shareText, + com.quran.labs.androidquran.common.toolbar.R.string.share_ayah_text); } } @@ -1851,7 +1884,8 @@ public void shareAyahLink(SuraAyah start, SuraAyah end) { .subscribeWith(new DisposableSingleObserver() { @Override public void onSuccess(@NonNull String url) { - shareUtil.shareViaIntent(PagerActivity.this, url, com.quran.labs.androidquran.common.toolbar.R.string.share_ayah); + shareUtil.shareViaIntent(PagerActivity.this, url, + com.quran.labs.androidquran.common.toolbar.R.string.share_ayah); dismissProgressDialog(); } @@ -1863,6 +1897,55 @@ public void onError(@NonNull Throwable e) { ); } + public void shareAyahAudio(SuraAyah start, SuraAyah end) { + final QariItem selectedQari = audioStatusBar.getAudioInfo(); + AudioPathInfo audioPathInfo = audioFileUtil.getLocalAudioPathInfo(selectedQari); + + assert audioPathInfo != null; + boolean gaplessDatabaseExists = audioPathInfo.getGaplessDatabase() != null; + + if (gaplessDatabaseExists) { + if (audioFilesExist(audioPathInfo, start, end)) { + AudioShareUtils audioShareUtils = new AudioShareUtils(); + String path = audioShareUtils.createBlockingSharableAudioFile( + this, + start, + end, + selectedQari, + audioPathInfo.getUrlFormat(), + audioPathInfo.getGaplessDatabase() + ); + + if (path != null && !path.isEmpty()){ + shareAudioSegment(path); + } else { + Toast.makeText(this, "could not share audio ayah", Toast.LENGTH_SHORT).show(); + } + } else { + requestDownload(audioPathInfo, selectedQari, start, end); + } + } + } + + private boolean audioFilesExist(AudioPathInfo audioPathInfo, SuraAyah start, SuraAyah end) { + return audioUtils.haveAllFiles(audioPathInfo.getUrlFormat(), audioPathInfo.getLocalDirectory(), + start, end, true); + } + + private void shareAudioSegment(String path) { + shareUtil.shareAudioFileIntent(PagerActivity.this, new File(path)); + } + + private void requestDownload(AudioPathInfo audioPathInfo, QariItem qari, SuraAyah start, SuraAyah end) { + AudioRequest audioRequest = new AudioRequest( + start, end, qari, 0, 0, true, false, audioPathInfo); + + Intent downloadIntent = audioPresenter.getDownloadIntent(this, audioRequest); + if (downloadIntent != null) { + handleRequiredDownload(downloadIntent); + } + } + private void showProgressDialog() { if (progressDialog == null) { progressDialog = new ProgressDialog(this); diff --git a/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.kt b/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.kt index 14136a3157..ab6d7d4485 100644 --- a/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.kt +++ b/app/src/main/java/com/quran/labs/androidquran/util/AudioUtils.kt @@ -9,10 +9,10 @@ import com.quran.data.model.audio.Qari import com.quran.labs.androidquran.common.audio.model.QariItem import com.quran.labs.androidquran.common.audio.util.QariUtil import com.quran.labs.androidquran.service.AudioService +import timber.log.Timber import java.io.File import java.util.Locale import javax.inject.Inject -import timber.log.Timber class AudioUtils @Inject constructor( private val quranInfo: QuranInfo, @@ -77,32 +77,6 @@ class AudioUtils @Inject constructor( } } - fun getLocalQariUrl(item: QariItem): String? { - val rootDirectory = quranFileUtils.audioFileDirectory() - return if (rootDirectory == null) null else rootDirectory + item.path - } - - fun getLocalQariUri(item: QariItem): String? { - val rootDirectory = quranFileUtils.audioFileDirectory() - return if (rootDirectory == null) null else - rootDirectory + item.path + File.separator + if (item.isGapless) { - "%03d$AUDIO_EXTENSION" - } else { - "%d" + File.separator + "%d" + AUDIO_EXTENSION - } - } - - fun getQariDatabasePathIfGapless(item: QariItem): String? { - var databaseName = item.databaseName - if (databaseName != null) { - val path = getLocalQariUrl(item) - if (path != null) { - databaseName = path + File.separator + databaseName + DB_EXTENSION - } - } - return databaseName - } - fun getLastAyahToPlay( startAyah: SuraAyah, currentPage: Int, @@ -293,7 +267,5 @@ class AudioUtils @Inject constructor( companion object { const val ZIP_EXTENSION = ".zip" const val AUDIO_EXTENSION = ".mp3" - - private const val DB_EXTENSION = ".db" } } diff --git a/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.kt b/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.kt index 4823b1629e..839ad13844 100644 --- a/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.kt +++ b/app/src/main/java/com/quran/labs/androidquran/util/ShareUtil.kt @@ -7,7 +7,10 @@ import android.content.Context import android.content.Intent import android.widget.Toast import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import com.quran.data.model.QuranText +import com.quran.labs.androidquran.BuildConfig import com.quran.labs.androidquran.R import com.quran.labs.androidquran.common.LocalTranslation import com.quran.labs.androidquran.common.QuranAyahInfo @@ -16,6 +19,7 @@ import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils import com.quran.labs.androidquran.ui.util.ToastCompat import com.quran.labs.androidquran.ui.util.TypefaceManager import dagger.Reusable +import java.io.File import java.text.NumberFormat import java.util.Locale import javax.inject.Inject @@ -130,4 +134,15 @@ class ShareUtil @Inject internal constructor(private val quranDisplayData: Quran append("]") } } + + + fun shareAudioFileIntent(activity: Activity, file: File) { + val authorities = BuildConfig.APPLICATION_ID + ".fileprovider" + val uri = FileProvider.getUriForFile(activity, authorities, file) + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.putExtra(Intent.EXTRA_STREAM, uri) + shareIntent.type = "audio/mp3" + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + activity.startActivity(Intent.createChooser(shareIntent, activity.getString(R.string.share_audio_file_title))) + } } diff --git a/app/src/main/java/com/quran/labs/androidquran/worker/AudioUpdateWorker.kt b/app/src/main/java/com/quran/labs/androidquran/worker/AudioUpdateWorker.kt index 2801e3d249..116c7fc325 100644 --- a/app/src/main/java/com/quran/labs/androidquran/worker/AudioUpdateWorker.kt +++ b/app/src/main/java/com/quran/labs/androidquran/worker/AudioUpdateWorker.kt @@ -8,10 +8,11 @@ import androidx.work.CoroutineWorker import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.quran.labs.androidquran.R +import com.quran.labs.androidquran.common.audio.timing.SuraTimingDatabaseHandler +import com.quran.labs.androidquran.common.audio.util.AudioFileUtil import com.quran.labs.androidquran.core.worker.WorkerTaskFactory import com.quran.labs.androidquran.data.Constants import com.quran.labs.androidquran.database.AudioDatabaseVersionChecker -import com.quran.labs.androidquran.database.SuraTimingDatabaseHandler import com.quran.labs.androidquran.feature.audio.AudioUpdater import com.quran.labs.androidquran.feature.audio.api.AudioUpdateService import com.quran.labs.androidquran.feature.audio.util.AudioFileCheckerImpl @@ -31,7 +32,8 @@ class AudioUpdateWorker( private val audioUpdateService: AudioUpdateService, private val audioUtils: AudioUtils, private val quranFileUtils: QuranFileUtils, - private val quranSettings: QuranSettings + private val quranSettings: QuranSettings, + private val audioFileUtil: AudioFileUtil ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result = coroutineScope { @@ -54,14 +56,14 @@ class AudioUpdateWorker( localFilesToDelete.forEach { localUpdate -> if (localUpdate.needsDatabaseUpgrade) { // delete the database - val dbPath = audioUtils.getQariDatabasePathIfGapless(localUpdate.qari) + val dbPath = audioFileUtil.getQariDatabasePathIfGapless(localUpdate.qari) dbPath?.let { SuraTimingDatabaseHandler.clearDatabaseHandlerIfExists(it) } Timber.d("would remove %s", dbPath) File(dbPath).delete() } val qari = localUpdate.qari - val path = audioUtils.getLocalQariUrl(qari) + val path = audioFileUtil.getLocalQariUrl(qari) localUpdate.files.forEach { // delete the file val filePath = if (qari.isGapless) { @@ -116,15 +118,21 @@ class AudioUpdateWorker( private val audioUpdateService: AudioUpdateService, private val audioUtils: AudioUtils, private val quranFileUtils: QuranFileUtils, - private val quranSettings: QuranSettings + private val quranSettings: QuranSettings, + private val audioFileUtil: AudioFileUtil ) : WorkerTaskFactory { override fun makeWorker( appContext: Context, workerParameters: WorkerParameters ): ListenableWorker { return AudioUpdateWorker( - appContext, workerParameters, audioUpdateService, audioUtils, quranFileUtils, - quranSettings + appContext, + workerParameters, + audioUpdateService, + audioUtils, + quranFileUtils, + quranSettings, + audioFileUtil ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6fd31b4f32..9e61707603 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -223,6 +223,7 @@ تم نسخ الآية + Share Audio File تصنيف المرجعية diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index 30a1b6b71b..286fe718a9 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -272,6 +272,7 @@ Âyet kopyalandı + Share Audio File Sayfa işareti etiketi diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index 0fa7371c9f..54eaba0048 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -290,6 +290,7 @@ Ajet kopiran + Share Audio File Označi zabilješku diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ffc587ed00..8e69e2a542 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -298,6 +298,7 @@ Vers kopiert + Share Audio File Ein Schlagwort zum Lesezeichen hinzufügen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7858a9be06..6e8be4412f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -164,6 +164,7 @@ Versículo copiado + Share Audio File Etiquetar Favorito Borrar Etiqueta diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8d7214050f..cec19ff3c8 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -163,6 +163,7 @@ آیه کپی شد + Share Audio File تعیین برچسب برای نشانک diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 481a38b7a6..5598b181fe 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -249,6 +249,7 @@ Aya copiée + Share Audio File Étiqueter le favori diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index b55e7bac1a..4e11fb4018 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -290,6 +290,7 @@ Ajet kopiran + Share Audio File Označi zabilješku diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 35a08fe5ab..cdc367ebd3 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -221,6 +221,7 @@ Ája kimásolva + Share Audio File Könyvelző címkézése diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index a3a4edc90d..a6d76b5fef 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -277,6 +277,7 @@ Ayat telah tersalin + Share Audio File Labeli Penanda diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7d353ec686..de12d412e8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -118,6 +118,7 @@ Avviare la riproduzione da: Inizio della pagina Ayah Copiato + Share Audio File Segnalibro Cancellare il tag Modifica tag diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 24cedcb0b8..6f12409874 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -277,6 +277,7 @@ Аят көшірілді + Share Audio File Бетбелгіні белгілеу diff --git a/app/src/main/res/values-ku/strings.xml b/app/src/main/res/values-ku/strings.xml index 6186cd0aab..8bd8ffb6d1 100644 --- a/app/src/main/res/values-ku/strings.xml +++ b/app/src/main/res/values-ku/strings.xml @@ -183,6 +183,7 @@ ئایەت لەبەریگیراوە + Share Audio File بڕگە نیشانەکرا diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 5d69155c37..1aabaf99d3 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -248,6 +248,7 @@ Ayat telah disalin + Share Audio File Beri Label pada Penanda diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4fa7dab4ec..326605d63a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -268,6 +268,7 @@ Voor de vertaalde pagina\'s (/voor vertalingen): Ga naar instellingen en wijzig Ayah Gekopieerd + Share Audio File Label Bladwijzer diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 43df4b0538..5c1ed73611 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -280,6 +280,7 @@ Ayah Skopiowany + Share Audio File Tag Zakładka diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e283b0fab6..e055cc6a44 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -201,6 +201,7 @@ escolher um leitor-qari diferente. Clique play para baixar e reproduzir a págin Ayah Copiada + Share Audio File Tagear Marcador diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c91cfb7e8c..e00ac30244 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -328,6 +328,7 @@ Аят скопирован + Share Audio File Отметить закладку diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index d912cba534..8368cdec80 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -279,6 +279,7 @@ Ajahu kopjon + Share Audio File Tag Libërshënues diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index e0b7005b1a..1d0a5e4ba9 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -290,6 +290,7 @@ Ajet kopiran + Share Audio File Označi zabelešku diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 4dcbee5e99..be7efb0168 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -279,6 +279,7 @@ Ayah Kopierad + Share Audio File Tag Bookmark diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 41f006d4fe..8285170438 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -270,6 +270,7 @@ อายะห์ คัดลอก + Share Audio File ที่คั่นหน้าแท็ก diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 30a1b6b71b..286fe718a9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -272,6 +272,7 @@ Âyet kopyalandı + Share Audio File Sayfa işareti etiketi diff --git a/app/src/main/res/values-ug/strings.xml b/app/src/main/res/values-ug/strings.xml index 7f6e4876d6..d0f58f584b 100644 --- a/app/src/main/res/values-ug/strings.xml +++ b/app/src/main/res/values-ug/strings.xml @@ -156,6 +156,7 @@ ئايەت كۆچۈرۈلدى + Share Audio File خەتكۈچكە بەلگە قوش diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f051400537..77de7df876 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -281,6 +281,7 @@ Ая скопійований + Share Audio File Закладка тега diff --git a/app/src/main/res/values-uz/strings.xml b/app/src/main/res/values-uz/strings.xml index 35efd2ea7e..ee6855682c 100644 --- a/app/src/main/res/values-uz/strings.xml +++ b/app/src/main/res/values-uz/strings.xml @@ -305,6 +305,7 @@ Oyat xotiraga koʻchirildi + Share Audio File Xatchoʻpni teglash diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2ce0b5f1b3..52574218cb 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -171,6 +171,7 @@ 复制成功 + Share Audio File 为收藏加标签 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fca7f16f62..4494ca74aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,7 @@ Ayah Copied + Share Audio File Tag Bookmark diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 783bd79240..15ebff8dcc 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,4 +3,13 @@ + + + + + + diff --git a/build.gradle b/build.gradle index a3af6b15dd..172a41322e 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ buildscript { workManagerVersion = '2.7.1' materialComponentsVersion = '1.6.1' coreKtxVersion = '1.7.0' + timberVersion = '5.0.1' anvilVersion = '2.4.2' moshiVersion = '1.14.0' diff --git a/common/audio/build.gradle b/common/audio/build.gradle index 123f2cdaba..8ea48773cd 100644 --- a/common/audio/build.gradle +++ b/common/audio/build.gradle @@ -16,11 +16,13 @@ android { dependencies { implementation project(":common:data") implementation project(":common:download") + implementation project(path: ':common:util') implementation deps.dagger.runtime implementation "androidx.annotation:annotation:${androidxAnnotationVersion}" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}" implementation "com.squareup.okio:okio:${okioVersion}" + implementation "com.jakewharton.timber:timber:${timberVersion}" testImplementation "junit:junit:${junitVersion}" testImplementation "com.google.truth:truth:${truthVersion}" diff --git a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GaplessAudioInfoCommand.kt b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GaplessAudioInfoCommand.kt index 248f36de6b..1be405e6cf 100644 --- a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GaplessAudioInfoCommand.kt +++ b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GaplessAudioInfoCommand.kt @@ -1,6 +1,6 @@ package com.quran.labs.androidquran.common.audio.cache.command -import com.quran.labs.androidquran.common.audio.util.AudioFileUtil +import com.quran.labs.androidquran.common.audio.util.AudioFileTools import okio.FileSystem import okio.Path import javax.inject.Inject @@ -12,7 +12,7 @@ class GaplessAudioInfoCommand @Inject constructor(private val fileSystem: FileSy } private fun fullGaplessDownloads(path: Path): List { - val paths = AudioFileUtil.filesMatchingSuffixWithSuffixRemoved(fileSystem, path, ".mp3") + val paths = AudioFileTools.filesMatchingSuffixWithSuffixRemoved(fileSystem, path, ".mp3") return paths .filter { it.length == 3 } .mapNotNull { it.toIntOrNull() } @@ -20,7 +20,7 @@ class GaplessAudioInfoCommand @Inject constructor(private val fileSystem: FileSy } private fun partialGaplessDownloads(path: Path): List { - val paths = AudioFileUtil.filesMatchingSuffixWithSuffixRemoved(fileSystem, path, ".mp3.part") + val paths = AudioFileTools.filesMatchingSuffixWithSuffixRemoved(fileSystem, path, ".mp3.part") return paths .filter { it.length == 3 } .mapNotNull { it.toIntOrNull() } diff --git a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GappedAudioInfoCommand.kt b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GappedAudioInfoCommand.kt index 1cad0ca264..fc51123cd3 100644 --- a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GappedAudioInfoCommand.kt +++ b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/cache/command/GappedAudioInfoCommand.kt @@ -2,7 +2,7 @@ package com.quran.labs.androidquran.common.audio.cache.command import com.quran.data.core.QuranInfo import com.quran.labs.androidquran.common.audio.model.PartiallyDownloadedSura -import com.quran.labs.androidquran.common.audio.util.AudioFileUtil +import com.quran.labs.androidquran.common.audio.util.AudioFileTools import okio.FileSystem import okio.Path import javax.inject.Inject @@ -17,7 +17,7 @@ class GappedAudioInfoCommand @Inject constructor( .filter { it.name.toIntOrNull() in 1..114 } .associate { directory -> val gappedDownloads = - AudioFileUtil.filesMatchingSuffixWithSuffixRemoved(fileSystem, directory, ".mp3") + AudioFileTools.filesMatchingSuffixWithSuffixRemoved(fileSystem, directory, ".mp3") .mapNotNull { it.toIntOrNull() } .filter { it in 1..286 } directory.toFile().nameWithoutExtension.toInt() to gappedDownloads diff --git a/app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioPathInfo.kt b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/model/AudioPathInfo.kt similarity index 81% rename from app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioPathInfo.kt rename to common/audio/src/main/java/com/quran/labs/androidquran/common/audio/model/AudioPathInfo.kt index 7a1cd0e563..ff9f483d49 100644 --- a/app/src/main/java/com/quran/labs/androidquran/dao/audio/AudioPathInfo.kt +++ b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/model/AudioPathInfo.kt @@ -1,4 +1,4 @@ -package com.quran.labs.androidquran.dao.audio +package com.quran.labs.androidquran.common.audio.model import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/app/src/main/java/com/quran/labs/androidquran/database/SuraTimingDatabaseHandler.kt b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/timing/SuraTimingDatabaseHandler.kt similarity index 80% rename from app/src/main/java/com/quran/labs/androidquran/database/SuraTimingDatabaseHandler.kt rename to common/audio/src/main/java/com/quran/labs/androidquran/common/audio/timing/SuraTimingDatabaseHandler.kt index 42e06615b2..b56090f113 100644 --- a/app/src/main/java/com/quran/labs/androidquran/database/SuraTimingDatabaseHandler.kt +++ b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/timing/SuraTimingDatabaseHandler.kt @@ -1,14 +1,14 @@ -package com.quran.labs.androidquran.database +package com.quran.labs.androidquran.common.audio.timing import android.database.Cursor import android.database.DefaultDatabaseErrorHandler import android.database.SQLException import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabaseCorruptException +import android.util.SparseIntArray +import com.quran.common.util.database.DatabaseUtils import timber.log.Timber import java.io.File -import java.lang.Exception -import java.util.HashMap class SuraTimingDatabaseHandler private constructor(path: String) { private var database: SQLiteDatabase? = null @@ -73,7 +73,7 @@ class SuraTimingDatabaseHandler private constructor(path: String) { private fun validDatabase(): Boolean = database?.isOpen ?: false - fun getAyahTimings(sura: Int): Cursor? { + private fun getAyahTimingsCursor(sura: Int): Cursor? { if (!validDatabase()) return null return try { @@ -90,6 +90,28 @@ class SuraTimingDatabaseHandler private constructor(path: String) { } } + fun getAyahTimings(sura: Int): SparseIntArray { + val map = SparseIntArray() + + var cursor: Cursor? = null + try { + cursor = getAyahTimingsCursor(sura) + if (cursor != null && cursor.moveToFirst()) { + do { + val ayah = cursor.getInt(1) + val time = cursor.getInt(2) + map.put(ayah, time) + } while (cursor.moveToNext()) + } + } catch (exception: SQLException) { + Timber.e(exception) + } finally { + DatabaseUtils.closeCursor(cursor) + } + + return map + } + fun getVersion(): Int { if (!validDatabase()) { return -1 diff --git a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileTools.kt b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileTools.kt new file mode 100644 index 0000000000..7a5643decb --- /dev/null +++ b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileTools.kt @@ -0,0 +1,18 @@ +package com.quran.labs.androidquran.common.audio.util + +import okio.FileSystem +import okio.Path + +object AudioFileTools { + + fun filesMatchingSuffixWithSuffixRemoved(fileSystem: FileSystem, path: Path, suffix: String): List { + return fileNamesMatchingSuffix(fileSystem, path, suffix) + .map { it.name.removeSuffix(suffix) } + } + + private fun fileNamesMatchingSuffix(fileSystem: FileSystem, path: Path, suffix: String): List { + return fileSystem.listOrNull(path) + ?.filter { it.name.endsWith(suffix) } + ?: emptyList() + } +} diff --git a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileUtil.kt b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileUtil.kt index 1ee5f44793..5a40fb24f2 100644 --- a/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileUtil.kt +++ b/common/audio/src/main/java/com/quran/labs/androidquran/common/audio/util/AudioFileUtil.kt @@ -1,18 +1,47 @@ package com.quran.labs.androidquran.common.audio.util -import okio.FileSystem -import okio.Path +import com.quran.data.core.QuranFileManager +import com.quran.labs.androidquran.common.audio.model.AudioPathInfo +import com.quran.labs.androidquran.common.audio.model.QariItem +import java.io.File +import javax.inject.Inject -object AudioFileUtil { +class AudioFileUtil @Inject constructor(private val quranFileManager: QuranFileManager) { - fun filesMatchingSuffixWithSuffixRemoved(fileSystem: FileSystem, path: Path, suffix: String): List { - return fileNamesMatchingSuffix(fileSystem, path, suffix) - .map { it.name.removeSuffix(suffix) } + fun getLocalQariUrl(item: QariItem): String? { + val rootDirectory = quranFileManager.audioFileDirectory() + return if (rootDirectory == null) null else rootDirectory + item.path } - private fun fileNamesMatchingSuffix(fileSystem: FileSystem, path: Path, suffix: String): List { - return fileSystem.listOrNull(path) - ?.filter { it.name.endsWith(suffix) } - ?: emptyList() + fun getQariDatabasePathIfGapless(item: QariItem): String? { + var databaseName = item.databaseName + if (databaseName != null) { + val path = getLocalQariUrl(item) + if (path != null) { + databaseName = path + File.separator + databaseName + DB_EXTENSION + } + } + return databaseName + } + + fun getLocalAudioPathInfo(qari: QariItem): AudioPathInfo? { + val localPath = getLocalQariUrl(qari) + if (localPath != null) { + val databasePath = getQariDatabasePathIfGapless(qari) + val urlFormat = if (databasePath.isNullOrEmpty()) { + localPath + File.separator + "%d" + File.separator + + "%d" + AUDIO_EXTENSION + } else { + localPath + File.separator + "%03d" + AUDIO_EXTENSION + } + return AudioPathInfo(urlFormat, localPath, databasePath) + } + return null + } + + companion object { + const val AUDIO_EXTENSION = ".mp3" + + private const val DB_EXTENSION = ".db" } } diff --git a/common/toolbar/src/main/res/drawable-hdpi/ic_qaf.png b/common/toolbar/src/main/res/drawable-hdpi/ic_qaf.png new file mode 100644 index 0000000000..efeb74fc25 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-hdpi/ic_qaf.png differ diff --git a/common/toolbar/src/main/res/drawable-hdpi/ic_speaker.png b/common/toolbar/src/main/res/drawable-hdpi/ic_speaker.png new file mode 100644 index 0000000000..22d33dd552 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-hdpi/ic_speaker.png differ diff --git a/common/toolbar/src/main/res/drawable-mdpi/ic_qaf.png b/common/toolbar/src/main/res/drawable-mdpi/ic_qaf.png new file mode 100644 index 0000000000..51bed2d502 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-mdpi/ic_qaf.png differ diff --git a/common/toolbar/src/main/res/drawable-mdpi/ic_speaker.png b/common/toolbar/src/main/res/drawable-mdpi/ic_speaker.png new file mode 100644 index 0000000000..795d731b64 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-mdpi/ic_speaker.png differ diff --git a/common/toolbar/src/main/res/drawable-xhdpi/ic_qaf.png b/common/toolbar/src/main/res/drawable-xhdpi/ic_qaf.png new file mode 100644 index 0000000000..002c076522 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-xhdpi/ic_qaf.png differ diff --git a/common/toolbar/src/main/res/drawable-xhdpi/ic_speaker.png b/common/toolbar/src/main/res/drawable-xhdpi/ic_speaker.png new file mode 100644 index 0000000000..3784a535a1 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-xhdpi/ic_speaker.png differ diff --git a/common/toolbar/src/main/res/drawable-xxhdpi/ic_qaf.png b/common/toolbar/src/main/res/drawable-xxhdpi/ic_qaf.png new file mode 100644 index 0000000000..5a00a55a55 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-xxhdpi/ic_qaf.png differ diff --git a/common/toolbar/src/main/res/drawable-xxhdpi/ic_speaker.png b/common/toolbar/src/main/res/drawable-xxhdpi/ic_speaker.png new file mode 100644 index 0000000000..d6ebc4cc6a Binary files /dev/null and b/common/toolbar/src/main/res/drawable-xxhdpi/ic_speaker.png differ diff --git a/common/toolbar/src/main/res/drawable-xxxhdpi/ic_qaf.png b/common/toolbar/src/main/res/drawable-xxxhdpi/ic_qaf.png new file mode 100644 index 0000000000..81ff927acf Binary files /dev/null and b/common/toolbar/src/main/res/drawable-xxxhdpi/ic_qaf.png differ diff --git a/common/toolbar/src/main/res/drawable-xxxhdpi/ic_speaker.png b/common/toolbar/src/main/res/drawable-xxxhdpi/ic_speaker.png new file mode 100644 index 0000000000..8f09e81ad5 Binary files /dev/null and b/common/toolbar/src/main/res/drawable-xxxhdpi/ic_speaker.png differ diff --git a/common/toolbar/src/main/res/drawable/ic_text.xml b/common/toolbar/src/main/res/drawable/ic_text.xml new file mode 100644 index 0000000000..772216cef6 --- /dev/null +++ b/common/toolbar/src/main/res/drawable/ic_text.xml @@ -0,0 +1,5 @@ + + + diff --git a/common/toolbar/src/main/res/menu/ayah_menu.xml b/common/toolbar/src/main/res/menu/ayah_menu.xml index bd95395f6a..7dffef4cb9 100644 --- a/common/toolbar/src/main/res/menu/ayah_menu.xml +++ b/common/toolbar/src/main/res/menu/ayah_menu.xml @@ -1,34 +1,47 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/common/toolbar/src/main/res/values/strings.xml b/common/toolbar/src/main/res/values/strings.xml index 5a85f64de3..d66437b049 100644 --- a/common/toolbar/src/main/res/values/strings.xml +++ b/common/toolbar/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Tag this Ayah Share Ayah Link Share Ayah Text + Share Ayah Audio Ayah Translation/Tafseer Play from Here Recite from here diff --git a/common/util/build.gradle b/common/util/build.gradle new file mode 100644 index 0000000000..a957916758 --- /dev/null +++ b/common/util/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion deps.android.build.compileSdkVersion + defaultConfig { + minSdkVersion deps.android.build.minSdkVersion + targetSdkVersion deps.android.build.targetSdkVersion + } +} + +dependencies { +} diff --git a/common/util/src/main/AndroidManifest.xml b/common/util/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9b676f398b --- /dev/null +++ b/common/util/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/java/com/quran/labs/androidquran/database/DatabaseUtils.kt b/common/util/src/main/java/com/quran/common/util/database/DatabaseUtils.kt similarity index 83% rename from app/src/main/java/com/quran/labs/androidquran/database/DatabaseUtils.kt rename to common/util/src/main/java/com/quran/common/util/database/DatabaseUtils.kt index c63c2ab132..f7e64b2bd2 100644 --- a/app/src/main/java/com/quran/labs/androidquran/database/DatabaseUtils.kt +++ b/common/util/src/main/java/com/quran/common/util/database/DatabaseUtils.kt @@ -1,4 +1,4 @@ -package com.quran.labs.androidquran.database +package com.quran.common.util.database import android.database.Cursor import java.lang.Exception diff --git a/feature/audio/build.gradle b/feature/audio/build.gradle index 43eeea22b8..68c0dead19 100644 --- a/feature/audio/build.gradle +++ b/feature/audio/build.gradle @@ -23,6 +23,7 @@ android { dependencies { implementation project(path: ':common:audio') + implementation project(path: ':common:data') implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${coroutinesVersion}" diff --git a/feature/audioshare/build.gradle b/feature/audioshare/build.gradle new file mode 100644 index 0000000000..e2d7298566 --- /dev/null +++ b/feature/audioshare/build.gradle @@ -0,0 +1,43 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' + +android { + compileSdkVersion deps.android.build.compileSdkVersion + defaultConfig { + minSdkVersion deps.android.build.minSdkVersion + targetSdkVersion deps.android.build.targetSdkVersion + } + + buildTypes { + beta { + consumerProguardFiles 'proguard.cfg' + matchingFallbacks = ['debug'] + } + + release { + consumerProguardFiles 'proguard.cfg' + } + } +} + +dependencies { + implementation project(path: ':common:audio') + implementation project(path: ':common:data') + implementation project(path: ':common:util') + + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${coroutinesVersion}" + + implementation "com.squareup.okio:okio:${okioVersion}" + + implementation "com.squareup.moshi:moshi:${moshiVersion}" + kapt("com.squareup.moshi:moshi-kotlin-codegen:${moshiVersion}") + + implementation "com.squareup.retrofit2:retrofit:${retrofitVersion}" + + testImplementation "junit:junit:${junitVersion}" + testImplementation "com.google.truth:truth:${truthVersion}" + + implementation "com.jakewharton.timber:timber:${timberVersion}" +} diff --git a/feature/audioshare/src/main/AndroidManifest.xml b/feature/audioshare/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b34d4f3292 --- /dev/null +++ b/feature/audioshare/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/AudioShareUtils.kt b/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/AudioShareUtils.kt new file mode 100644 index 0000000000..a1d170fb6c --- /dev/null +++ b/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/AudioShareUtils.kt @@ -0,0 +1,278 @@ +package com.quran.labs.androidquran.feature.audioshare + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.util.SparseIntArray +import com.quran.data.model.SuraAyah +import com.quran.labs.androidquran.common.audio.model.QariItem +import com.quran.labs.androidquran.common.audio.timing.SuraTimingDatabaseHandler +import com.quran.labs.androidquran.feature.audioshare.soundfile.CheapSoundFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okio.BufferedSink +import okio.buffer +import okio.sink +import okio.source +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.util.Locale +import java.util.UUID +import kotlin.math.roundToInt + +class AudioShareUtils { + fun createBlockingSharableAudioFile( + context: Context, + start: SuraAyah, + end: SuraAyah, + qari: QariItem, + urlFormat: String, + gaplessDatabase: String + ): String? { + return runBlocking { + createSharableAudioFile(context, start, end, qari, urlFormat, gaplessDatabase) + } + } + + suspend fun createSharableAudioFile( + context: Context, + start: SuraAyah, + end: SuraAyah, + qari: QariItem, + urlFormat: String, + gaplessDatabase: String + ): String? { + assert(end >= start) + + val audioCacheDirectory = context.cacheDir + + var sharablePath: String? + val audioCacheFilePaths = mutableListOf() + withContext(Dispatchers.IO) { + val (startSurahTimingData, endSurahTimingData) = getTimingData(start, end, gaplessDatabase) + + val startAyah = start.ayah + val endAyah = end.ayah + + val isFirstAyahInSurah = startAyah == 1 + val startTimeOfAyahAfterEndAyah = endSurahTimingData[endAyah + 1] + val isLastAyahInSurah = startTimeOfAyahAfterEndAyah == 0 + + val startAyahTime = if (isFirstAyahInSurah) { + 0 + } else { + startSurahTimingData[startAyah] + } + + val endAyahTime = if (isLastAyahInSurah) { + val endMarker = endSurahTimingData.get(999, -1) + if (endMarker > 0) { + endMarker + } else { + getSurahDuration(context, getSurahAudioPath(urlFormat, end.sura)) + } + } else { + startTimeOfAyahAfterEndAyah + } + + val startAndEndAyahAreInSameSurah = start.sura == end.sura + + if (startAndEndAyahAreInSameSurah) { + val audioSegmentPath: String? = getSurahSegment( + audioCacheDirectory, getSurahAudioPath(urlFormat, start.sura), startAyahTime, endAyahTime + ) + + sharablePath = if (audioSegmentPath != null) { + audioCacheFilePaths.add(audioSegmentPath) + getRenamedSharableAudioFile( + qari, + start, + end, + audioSegmentPath, + audioCacheDirectory.toString(), + audioCacheFilePaths + ) + } else { + null + } + audioCacheFilePaths.clear() + } else { + val segmentPaths = mutableListOf() + val endOfSurah = -1 + val startOfSurah = 0 + val startSegmentPath: String? = getSurahSegmentPath( + context, audioCacheDirectory, urlFormat, start.sura, startAyahTime, endOfSurah + ) + val lastSegmentPath: String? = getSurahSegmentPath( + context, audioCacheDirectory, urlFormat, end.sura, startOfSurah, endAyahTime + ) + + if (startSegmentPath != null && lastSegmentPath != null) { + for (surahIndex in start.sura..end.sura) { + val isTheFirstSurah = surahIndex == start.sura + val isMiddleSurah = surahIndex != start.sura && surahIndex != end.sura + if (isTheFirstSurah) { + segmentPaths.add(startSegmentPath) + audioCacheFilePaths.add(startSegmentPath) + } else if (isMiddleSurah) { + segmentPaths.add(getSurahAudioPath(urlFormat, surahIndex)) + } else { + segmentPaths.add(lastSegmentPath) + audioCacheFilePaths.add(lastSegmentPath) + } + } + + val audioSegmentsWereCreated = segmentPaths.isNotEmpty() + + if (audioSegmentsWereCreated) { + val (sharableAudioFilePath, cacheUpdates) = + getMergedAudioFromSegments(audioCacheDirectory, segmentPaths) + audioCacheFilePaths.addAll(cacheUpdates) + sharablePath = getRenamedSharableAudioFile( + qari, + start, + end, + sharableAudioFilePath, + audioCacheDirectory.toString(), + audioCacheFilePaths + ) + audioCacheFilePaths.clear() + } else { + sharablePath = null + } + } else { + sharablePath = null + } + } + } + return sharablePath + } + + private fun getSurahSegmentPath(context: Context, + audioCacheDirectory: File, + urlFormat: String, + surah: Int, + startAyahTime: Int, + endAyahTime: Int + ): String? { + var upperBoundTime = endAyahTime + val audioFilePath: String = getSurahAudioPath(urlFormat, surah) + val isFirstSegment = endAyahTime < 0 + if (isFirstSegment) { + upperBoundTime = getSurahDuration(context, audioFilePath) + } + return getSurahSegment(audioCacheDirectory, audioFilePath, startAyahTime, upperBoundTime) + } + + private fun getRenamedSharableAudioFile( + qari: QariItem, + start: SuraAyah, + end: SuraAyah, + audioSegmentPath: String, + audioCacheDirectory: String, + cachedPaths: List + ): String { + val newAudioFileName: String = + qari.path + "_" + start.sura + "-" + start.ayah + "_" + end.sura + "-" + end.ayah + val newAudioFilePath: String = audioCacheDirectory + File.separator + newAudioFileName + ".mp3" + File(audioSegmentPath).renameTo(File(newAudioFilePath)) + cachedPaths + .filter { it != audioSegmentPath } + .onEach { + File(it).delete() + } + return newAudioFilePath + } + + private fun getSurahDuration(context: Context, path: String): Int { + val mmr = MediaMetadataRetriever() + mmr.setDataSource(context, Uri.parse(path)) + val durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + return durationStr!!.toInt() + } + + private fun getSurahAudioPath(urlFormat: String, surah: Int): String { + return String.format(Locale.US, urlFormat, surah) + } + + private fun getTimingData( + start: SuraAyah, + end: SuraAyah, + gaplessDatabase: String + ): Pair { + val db: SuraTimingDatabaseHandler = + SuraTimingDatabaseHandler.getDatabaseHandler(gaplessDatabase) + val firstSurahMap = db.getAyahTimings(start.sura) + val lastSurahMap = if (start.sura == end.sura) { + firstSurahMap + } else { + db.getAyahTimings(end.sura) + } + + return firstSurahMap to lastSurahMap + } + + private fun getSurahSegment( + audioCacheDirectory: File, + path: String, + lowerCut: Int, + upperCut: Int + ): String? { + if (lowerCut == 0 && upperCut == 0) { + return null + } + val tempAudioName = UUID.randomUUID().toString() + ".mp3" + val destFile = File(audioCacheDirectory.path + File.separator + tempAudioName) + val soundFile = CheapSoundFile.create(path, null) + try { + val startTime = lowerCut.toFloat() / 1000 + val endTime = upperCut.toFloat() / 1000 + val samplesPerFrame = soundFile.samplesPerFrame + val sampleRate = soundFile.sampleRate + val avg = sampleRate.div(samplesPerFrame) + val startFrames = (startTime * avg).roundToInt() + val endFrames = (endTime * avg).roundToInt() + soundFile.WriteFile(destFile, startFrames, endFrames - startFrames) + } catch (e: IOException) { + e.printStackTrace() + } + return destFile.absolutePath + } + + private fun getMergedAudioFromSegments( + audioCacheDirectory: File, + segments: List + ): Pair> { + var mergedAudioPath = segments[0] + val extraCacheFilePaths = mutableListOf() + if (segments.size > 1) { + for (i in 1 until segments.size) { + val path = mergeAudios(audioCacheDirectory, mergedAudioPath, segments[i]) + if (path != null) { + mergedAudioPath = path + extraCacheFilePaths.add(mergedAudioPath) + } + } + } + return mergedAudioPath to extraCacheFilePaths + } + + private fun mergeAudios(audioCacheDirectory: File, path1: String, path2: String): String? { + val tempAudioName = UUID.randomUUID().toString() + ".mp3" + val destFile = File(audioCacheDirectory.path + File.separator + tempAudioName) + try { + val bufferedSink: BufferedSink = destFile.sink().buffer() + bufferedSink.writeAll(File(path1).source()) + bufferedSink.writeAll(File(path2).source()) + bufferedSink.close() + return destFile.path + } catch (e2: FileNotFoundException) { + e2.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } + return null + } +} diff --git a/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/soundfile/CheapMP3.java b/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/soundfile/CheapMP3.java new file mode 100644 index 0000000000..2562eecfc5 --- /dev/null +++ b/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/soundfile/CheapMP3.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.quran.labs.androidquran.feature.audioshare.soundfile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; + +/** + * CheapMP3 represents an MP3 file by doing a "cheap" scan of the file, + * parsing the frame headers only and getting an extremely rough estimate + * of the volume level of each frame. + * + * TODO: Useful unit tests might be to look for sync in various places: + * FF FA + * FF FB + * 00 FF FA + * FF FF FA + * ([ 00 ] * 12) FF FA + * ([ 00 ] * 13) FF FA + */ +public class CheapMP3 extends CheapSoundFile { + public static Factory getFactory() { + return new Factory() { + public CheapSoundFile create() { + return new CheapMP3(); + } + public String[] getSupportedExtensions() { + return new String[] { "mp3" }; + } + }; + } + + // Member variables representing frame data + private int mNumFrames; + private int[] mFrameOffsets; + private int[] mFrameLens; + private int[] mFrameGains; + private int mFileSize; + private int mAvgBitRate; + private int mGlobalSampleRate; + private int mGlobalChannels; + + // Member variables used during initialization + private int mMaxFrames; + private int mBitrateSum; + private int mMinGain; + private int mMaxGain; + + public CheapMP3() { + } + + public int getNumFrames() { + return mNumFrames; + } + + public int[] getFrameOffsets() { + return mFrameOffsets; + } + + public int getSamplesPerFrame() { + return 1152; + } + + public int[] getFrameLens() { + return mFrameLens; + } + + public int[] getFrameGains() { + return mFrameGains; + } + + public int getFileSizeBytes() { + return mFileSize; + } + + public int getAvgBitrateKbps() { + return mAvgBitRate; + } + + public int getSampleRate() { + return mGlobalSampleRate; + } + + public int getChannels() { + return mGlobalChannels; + } + + public String getFiletype() { + return "MP3"; + } + + /** + * MP3 supports seeking into the middle of the file, no header needed, + * so this method is supported to hear exactly what a "cut" of the file + * sounds like without needing to actually save a file to disk first. + */ + public int getSeekableFrameOffset(int frame) { + if (frame <= 0) { + return 0; + } else if (frame >= mNumFrames) { + return mFileSize; + } else { + return mFrameOffsets[frame]; + } + } + + public void ReadFile(File inputFile) + throws java.io.FileNotFoundException, + java.io.IOException { + super.ReadFile(inputFile); + mNumFrames = 0; + mMaxFrames = 64; // This will grow as needed + mFrameOffsets = new int[mMaxFrames]; + mFrameLens = new int[mMaxFrames]; + mFrameGains = new int[mMaxFrames]; + mBitrateSum = 0; + mMinGain = 255; + mMaxGain = 0; + + // No need to handle filesizes larger than can fit in a 32-bit int + mFileSize = (int)mInputFile.length(); + + FileInputStream stream = new FileInputStream(mInputFile); + + int pos = 0; + int offset = 0; + byte[] buffer = new byte[12]; + while (pos < mFileSize - 12) { + // Read 12 bytes at a time and look for a sync code (0xFF) + while (offset < 12) { + offset += stream.read(buffer, offset, 12 - offset); + } + int bufferOffset = 0; + while (bufferOffset < 12 && + buffer[bufferOffset] != -1) + bufferOffset++; + + if (mProgressListener != null) { + boolean keepGoing = mProgressListener.reportProgress( + pos * 1.0 / mFileSize); + if (!keepGoing) { + break; + } + } + + if (bufferOffset > 0) { + // We didn't find a sync code (0xFF) at position 0; + // shift the buffer over and try again + for (int i = 0; i < 12 - bufferOffset; i++) + buffer[i] = buffer[bufferOffset + i]; + pos += bufferOffset; + offset = 12 - bufferOffset; + continue; + } + + // Check for MPEG 1 Layer III or MPEG 2 Layer III codes + int mpgVersion = 0; + if (buffer[1] == -6 || buffer[1] == -5) { + mpgVersion = 1; + } else if (buffer[1] == -14 || buffer[1] == -13) { + mpgVersion = 2; + } else { + bufferOffset = 1; + for (int i = 0; i < 12 - bufferOffset; i++) + buffer[i] = buffer[bufferOffset + i]; + pos += bufferOffset; + offset = 12 - bufferOffset; + continue; + } + + // The third byte has the bitrate and samplerate + int bitRate; + int sampleRate; + if (mpgVersion == 1) { + // MPEG 1 Layer III + bitRate = BITRATES_MPEG1_L3[(buffer[2] & 0xF0) >> 4]; + sampleRate = SAMPLERATES_MPEG1_L3[(buffer[2] & 0x0C) >> 2]; + } else { + // MPEG 2 Layer III + bitRate = BITRATES_MPEG2_L3[(buffer[2] & 0xF0) >> 4]; + sampleRate = SAMPLERATES_MPEG2_L3[(buffer[2] & 0x0C) >> 2]; + } + + if (bitRate == 0 || sampleRate == 0) { + bufferOffset = 2; + for (int i = 0; i < 12 - bufferOffset; i++) + buffer[i] = buffer[bufferOffset + i]; + pos += bufferOffset; + offset = 12 - bufferOffset; + continue; + } + + // From here on we assume the frame is good + mGlobalSampleRate = sampleRate; + int padding = (buffer[2] & 2) >> 1; + int frameLen = 144 * bitRate * 1000 / sampleRate + padding; + + int gain; + if ((buffer[3] & 0xC0) == 0xC0) { + // 1 channel + mGlobalChannels = 1; + if (mpgVersion == 1) { + gain = ((buffer[10] & 0x01) << 7) + + ((buffer[11] & 0xFE) >> 1); + } else { + gain = ((buffer[9] & 0x03) << 6) + + ((buffer[10] & 0xFC) >> 2); + } + } else { + // 2 channels + mGlobalChannels = 2; + if (mpgVersion == 1) { + gain = ((buffer[9] & 0x7F) << 1) + + ((buffer[10] & 0x80) >> 7); + } else { + gain = 0; // ??? + } + } + + mBitrateSum += bitRate; + + mFrameOffsets[mNumFrames] = pos; + mFrameLens[mNumFrames] = frameLen; + mFrameGains[mNumFrames] = gain; + if (gain < mMinGain) + mMinGain = gain; + if (gain > mMaxGain) + mMaxGain = gain; + + mNumFrames++; + if (mNumFrames == mMaxFrames) { + // We need to grow our arrays. Rather than naively + // doubling the array each time, we estimate the exact + // number of frames we need and add 10% padding. In + // practice this seems to work quite well, only one + // resize is ever needed, however to avoid pathological + // cases we make sure to always double the size at a minimum. + + mAvgBitRate = mBitrateSum / mNumFrames; + int totalFramesGuess = + ((mFileSize / mAvgBitRate) * sampleRate) / 144000; + int newMaxFrames = totalFramesGuess * 11 / 10; + if (newMaxFrames < mMaxFrames * 2) + newMaxFrames = mMaxFrames * 2; + + int[] newOffsets = new int[newMaxFrames]; + int[] newLens = new int[newMaxFrames]; + int[] newGains = new int[newMaxFrames]; + for (int i = 0; i < mNumFrames; i++) { + newOffsets[i] = mFrameOffsets[i]; + newLens[i] = mFrameLens[i]; + newGains[i] = mFrameGains[i]; + } + mFrameOffsets = newOffsets; + mFrameLens = newLens; + mFrameGains = newGains; + mMaxFrames = newMaxFrames; + } + + stream.skip(frameLen - 12); + pos += frameLen; + offset = 0; + } + + // We're done reading the file, do some postprocessing + if (mNumFrames > 0) + mAvgBitRate = mBitrateSum / mNumFrames; + else + mAvgBitRate = 0; + } + + public void WriteFile(File outputFile, int startFrame, int numFrames) + throws java.io.IOException { + outputFile.createNewFile(); + FileInputStream in = new FileInputStream(mInputFile); + FileOutputStream out = new FileOutputStream(outputFile); + int maxFrameLen = 0; + for (int i = 0; i < numFrames; i++) { + if (mFrameLens[startFrame + i] > maxFrameLen) + maxFrameLen = mFrameLens[startFrame + i]; + } + byte[] buffer = new byte[maxFrameLen]; + int pos = 0; + for (int i = 0; i < numFrames; i++) { + int skip = mFrameOffsets[startFrame + i] - pos; + int len = mFrameLens[startFrame + i]; + if (skip > 0) { + in.skip(skip); + pos += skip; + } + in.read(buffer, 0, len); + out.write(buffer, 0, len); + pos += len; + } + in.close(); + out.close(); + } + + static private int BITRATES_MPEG1_L3[] = { + 0, 32, 40, 48, 56, 64, 80, 96, + 112, 128, 160, 192, 224, 256, 320, 0 }; + static private int BITRATES_MPEG2_L3[] = { + 0, 8, 16, 24, 32, 40, 48, 56, + 64, 80, 96, 112, 128, 144, 160, 0 }; + static private int SAMPLERATES_MPEG1_L3[] = { + 44100, 48000, 32000, 0 }; + static private int SAMPLERATES_MPEG2_L3[] = { + 22050, 24000, 16000, 0 }; +} diff --git a/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/soundfile/CheapSoundFile.java b/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/soundfile/CheapSoundFile.java new file mode 100644 index 0000000000..9d34bda407 --- /dev/null +++ b/feature/audioshare/src/main/java/com/quran/labs/androidquran/feature/audioshare/soundfile/CheapSoundFile.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.quran.labs.androidquran.feature.audioshare.soundfile; + +import java.io.File; +import java.io.FileInputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; + +/** + * CheapSoundFile is the parent class of several subclasses that each + * do a "cheap" scan of various sound file formats, parsing as little + * as possible in order to understand the high-level frame structure + * and get a rough estimate of the volume level of each frame. Each + * subclass is able to: + * - open a sound file + * - return the sample rate and number of frames + * - return an approximation of the volume level of each frame + * - write a new sound file with a subset of the frames + * + * A frame should represent no less than 1 ms and no more than 100 ms of + * audio. This is compatible with the native frame sizes of most audio + * file formats already, but if not, this class should expose virtual + * frames in that size range. + */ +public class CheapSoundFile { + public interface ProgressListener { + /** + * Will be called by the CheapSoundFile subclass periodically + * with values between 0.0 and 1.0. Return true to continue + * loading the file, and false to cancel. + */ + boolean reportProgress(double fractionComplete); + } + + public interface Factory { + public CheapSoundFile create(); + public String[] getSupportedExtensions(); + } + + static Factory[] sSubclassFactories = new Factory[] { + CheapMP3.getFactory() + }; + + static ArrayList sSupportedExtensions = new ArrayList(); + static HashMap sExtensionMap = + new HashMap(); + + static { + for (Factory f : sSubclassFactories) { + for (String extension : f.getSupportedExtensions()) { + sSupportedExtensions.add(extension); + sExtensionMap.put(extension, f); + } + } + } + + /** + * Static method to create the appropriate CheapSoundFile subclass + * given a filename. + * + * TODO: make this more modular rather than hardcoding the logic + */ + public static CheapSoundFile create(String fileName, + ProgressListener progressListener) + throws java.io.FileNotFoundException, + java.io.IOException { + File f = new File(fileName); + if (!f.exists()) { + throw new java.io.FileNotFoundException(fileName); + } + String name = f.getName().toLowerCase(); + String[] components = name.split("\\."); + if (components.length < 2) { + return null; + } + Factory factory = sExtensionMap.get(components[components.length - 1]); + if (factory == null) { + return null; + } + CheapSoundFile soundFile = factory.create(); + soundFile.setProgressListener(progressListener); + soundFile.ReadFile(f); + return soundFile; + } + + public static boolean isFilenameSupported(String filename) { + String[] components = filename.toLowerCase().split("\\."); + if (components.length < 2) { + return false; + } + return sExtensionMap.containsKey(components[components.length - 1]); + } + + /** + * Return the filename extensions that are recognized by one of + * our subclasses. + */ + public static String[] getSupportedExtensions() { + return sSupportedExtensions.toArray( + new String[sSupportedExtensions.size()]); + } + + protected ProgressListener mProgressListener = null; + protected File mInputFile = null; + + protected CheapSoundFile() { + } + + public void ReadFile(File inputFile) + throws java.io.FileNotFoundException, + java.io.IOException { + mInputFile = inputFile; + } + + public void setProgressListener(ProgressListener progressListener) { + mProgressListener = progressListener; + } + + public int getNumFrames() { + return 0; + } + + public int getSamplesPerFrame() { + return 0; + } + + public int[] getFrameOffsets() { + return null; + } + + public int[] getFrameLens() { + return null; + } + + public int[] getFrameGains() { + return null; + } + + public int getFileSizeBytes() { + return 0; + } + + public int getAvgBitrateKbps() { + return 0; + } + + public int getSampleRate() { + return 0; + } + + public int getChannels() { + return 0; + } + + public String getFiletype() { + return "Unknown"; + } + + /** + * If and only if this particular file format supports seeking + * directly into the middle of the file without reading the rest of + * the header, this returns the byte offset of the given frame, + * otherwise returns -1. + */ + public int getSeekableFrameOffset(int frame) { + return -1; + } + + private static final char[] HEX_CHARS = { + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' }; + public static String bytesToHex (byte hash[]) { + char buf[] = new char[hash.length * 2]; + for (int i = 0, x = 0; i < hash.length; i++) { + buf[x++] = HEX_CHARS[(hash[i] >>> 4) & 0xf]; + buf[x++] = HEX_CHARS[hash[i] & 0xf]; + } + return new String(buf); + } + + public String computeMd5OfFirst10Frames() + throws java.io.FileNotFoundException, + java.io.IOException, + java.security.NoSuchAlgorithmException { + int[] frameOffsets = getFrameOffsets(); + int[] frameLens = getFrameLens(); + int numFrames = frameLens.length; + if (numFrames > 10) { + numFrames = 10; + } + + MessageDigest digest = MessageDigest.getInstance("MD5"); + FileInputStream in = new FileInputStream(mInputFile); + int pos = 0; + for (int i = 0; i < numFrames; i++) { + int skip = frameOffsets[i] - pos; + int len = frameLens[i]; + if (skip > 0) { + in.skip(skip); + pos += skip; + } + byte[] buffer = new byte[len]; + in.read(buffer, 0, len); + digest.update(buffer); + pos += len; + } + in.close(); + byte[] hash = digest.digest(); + return bytesToHex(hash); + } + + public void WriteFile(File outputFile, int startFrame, int numFrames) + throws java.io.IOException { + } +} diff --git a/settings.gradle b/settings.gradle index 1bd2ed2b15..4198ddcadb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,10 +11,12 @@ include ':common:reading' include ':common:recitation' include ':common:search' include ':common:toolbar' +include ':common:util' include ':common:upgrade' include ':common:ui:core' include ':feature:analytics-noop' include ':feature:audio' +include ':feature:audioshare' include ':feature:downloadmanager' include ':feature:qarilist' include ':feature:recitation'