diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7e61449217..8b1d95b069 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -102,9 +102,11 @@ android:authorities="@string/file_authority" android:grantUriPermissions="true" android:exported="false"> + tools:replace="android:authorities"> + android:resource="@xml/file_paths" + tools:replace="android:resource" /> 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 3f9736d8da..9bc73b3ec1 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 @@ -87,7 +87,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 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 3f042e521b..28c6b982b5 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,6 @@ package com.quran.labs.androidquran.ui; +import static com.quran.labs.androidquran.database.DatabaseUtils.closeCursor; 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; @@ -15,13 +16,17 @@ import android.content.pm.ActivityInfo; import android.content.res.Configuration; import android.content.res.Resources; +import android.database.Cursor; +import android.database.SQLException; import android.os.Build; import android.os.Bundle; +import android.os.Environment; import android.os.Handler; import android.os.Message; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.SparseBooleanArray; +import android.util.SparseIntArray; import android.view.HapticFeedbackConstants; import android.view.KeyEvent; import android.view.Menu; @@ -70,14 +75,17 @@ 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.dao.audio.AudioPathInfo; import com.quran.labs.androidquran.dao.audio.AudioRequest; import com.quran.labs.androidquran.data.Constants; import com.quran.labs.androidquran.data.QuranDataProvider; import com.quran.labs.androidquran.data.QuranDisplayData; +import com.quran.labs.androidquran.database.SuraTimingDatabaseHandler; import com.quran.labs.androidquran.database.TranslationsDBAdapter; 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.audio.util.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; @@ -123,8 +131,10 @@ import com.quran.reading.common.AudioEventPresenter; import com.quran.reading.common.ReadingEventPresenter; +import java.io.File; import java.lang.ref.WeakReference; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -141,6 +151,8 @@ import io.reactivex.rxjava3.observers.DisposableObserver; import io.reactivex.rxjava3.observers.DisposableSingleObserver; import io.reactivex.rxjava3.schedulers.Schedulers; +import kotlin.TuplesKt; +import kotlin.jvm.internal.Intrinsics; import timber.log.Timber; /** @@ -216,8 +228,10 @@ public class PagerActivity extends AppCompatActivity implements private int defaultNavigationBarColor; private boolean isSplitScreen = false; - @Nullable private QuranAyahInfo lastSelectedTranslationAyah; - @Nullable private LocalTranslation[] lastActivatedLocalTranslations; + @Nullable + private QuranAyahInfo lastSelectedTranslationAyah; + @Nullable + private LocalTranslation[] lastActivatedLocalTranslations; private PagerActivityComponent pagerActivityComponent; @@ -249,6 +263,12 @@ public class PagerActivity extends AppCompatActivity implements private final PagerHandler handler = new PagerHandler(this); + private ArrayList audioCacheFilePaths = new ArrayList<>(); + private SuraAyah selectedStartSuraAyah = null; + private SuraAyah selectedEndSuraAyah = null; + private QariItem selectedQari = null; + + private static class PagerHandler extends Handler { private final WeakReference activity; @@ -285,12 +305,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 @@ -418,7 +447,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(); @@ -538,8 +568,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; + } )); } @@ -1368,16 +1404,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); @@ -1394,7 +1430,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; @@ -1427,7 +1464,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); @@ -1476,7 +1514,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(); } @@ -1517,7 +1556,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); } @@ -1760,6 +1799,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 { @@ -1796,7 +1837,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); } } @@ -1825,7 +1867,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(); } @@ -1837,6 +1880,67 @@ public void onError(@NonNull Throwable e) { ); } + public void shareAyahAudio(SuraAyah start, SuraAyah end) { + audioCacheFilePaths.clear(); + + kotlin.Pair pair2 = getReorderedAyatPair(start, end); + selectedStartSuraAyah = (SuraAyah) pair2.component1(); + selectedEndSuraAyah = (SuraAyah) pair2.component2(); + + selectedQari = audioStatusBar.getAudioInfo(); + AudioPathInfo audioPathInfo = audioUtils.getLocalAudioPathInfo(selectedQari); + + assert audioPathInfo != null; + boolean gaplessDatabaseExists = audioPathInfo.getGaplessDatabase() != null; + + if (gaplessDatabaseExists) { + if (audioFilesExist(audioPathInfo)) { + AudioShareUtils audioShareUtils = new AudioShareUtils(); + String path = audioShareUtils.createSharableAudioFile(this, selectedStartSuraAyah, + selectedEndSuraAyah, 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); + } + } + } + + private kotlin.Pair getReorderedAyatPair(SuraAyah start, SuraAyah end) { + kotlin.Pair pair; + if (start.compareTo(end) <= 0) { + pair = TuplesKt.to(start, end); + } else { + Timber.Forest.e( + new IllegalStateException("End isn't larger than the start: " + start + " to " + end)); + pair = TuplesKt.to(end, start); + } + return pair; + } + + private boolean audioFilesExist(AudioPathInfo audioPathInfo) { + return audioUtils.haveAllFiles(audioPathInfo.getUrlFormat(), audioPathInfo.getLocalDirectory(), + selectedStartSuraAyah, selectedEndSuraAyah, true); + } + + private void shareAudioSegment(String path) { + shareUtil.shareAudioFileIntent(PagerActivity.this, new File(path)); + } + + private void requestDownload(AudioPathInfo audioPathInfo) { + AudioRequest audioRequest = new AudioRequest( + selectedStartSuraAyah, selectedEndSuraAyah, selectedQari, 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 5cde50f940..4df8b728cf 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 @@ -8,6 +8,7 @@ import com.quran.data.model.SuraAyah import com.quran.labs.androidquran.common.audio.model.AudioConfiguration import com.quran.labs.androidquran.common.audio.model.QariItem import com.quran.labs.androidquran.common.audio.util.QariUtil +import com.quran.labs.androidquran.dao.audio.AudioPathInfo import com.quran.labs.androidquran.service.AudioService import timber.log.Timber import java.io.File @@ -283,6 +284,21 @@ class AudioUtils @Inject constructor( } } + 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 ZIP_EXTENSION = ".zip" const val AUDIO_EXTENSION = ".mp3" 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 a2f0450851..7680397dcf 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 @@ -15,6 +18,7 @@ import com.quran.labs.androidquran.data.QuranDisplayData import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils import com.quran.labs.androidquran.ui.util.ToastCompat import dagger.Reusable +import java.io.File import java.text.NumberFormat import java.util.Locale import javax.inject.Inject @@ -124,4 +128,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/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 73bc2d4901..5b205754d8 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -240,6 +240,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 9ec44ee78d..11195285fa 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -286,6 +286,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 e338fbdf8b..0f34787ecd 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -305,6 +305,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 bab66e2b91..ae8670b660 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -311,6 +311,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 137dbd1829..339b705e93 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -170,6 +170,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 3834c97f48..649ecb3d82 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -167,6 +167,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 b1172bd431..05d65e6d42 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -258,6 +258,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 86a3318dcc..4a1fa3d20d 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -305,6 +305,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 ad25141740..228bcf8c5b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -234,6 +234,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 eda7bd57b3..23ac5275cf 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -290,6 +290,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 b7bee30bae..66b23401ea 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -123,6 +123,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 77500bfc18..f030f479ab 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -291,6 +291,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 c34e4e6625..0eebc3becd 100644 --- a/app/src/main/res/values-ku/strings.xml +++ b/app/src/main/res/values-ku/strings.xml @@ -187,6 +187,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 ebf680ce38..749c5f8da2 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -260,6 +260,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 6545623e91..b182bbc86c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -282,6 +282,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 0945ece736..d2423a99b6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -295,6 +295,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 286e311cad..3d9ddaf84d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -205,6 +205,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 5af0673500..d3da9addd8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -344,6 +344,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 51def7be8c..13d074e440 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -293,6 +293,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 05009fcd2a..d37de82a85 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -305,6 +305,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 43c1d06432..6e12fc4138 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -293,6 +293,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 ee9dacdc34..a38f9f2193 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -284,6 +284,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 9ec44ee78d..11195285fa 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -286,6 +286,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 eb2bf74b2a..497a034ad0 100644 --- a/app/src/main/res/values-ug/strings.xml +++ b/app/src/main/res/values-ug/strings.xml @@ -160,6 +160,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 4de0b3641e..519ed660b8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -296,6 +296,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 f6a801f835..2187c221b0 100644 --- a/app/src/main/res/values-uz/strings.xml +++ b/app/src/main/res/values-uz/strings.xml @@ -319,6 +319,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 61d3a72da6..672ee8cf84 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -175,6 +175,7 @@ 复制成功 + Share Audio File 为收藏加标签 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af568b9731..7aded67319 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,6 +365,7 @@ Ayah Copied + Share Audio File Tag Bookmark @@ -411,6 +412,7 @@ Download selection Delete selection + Quran Audio files Update Several Quran audio files have been updated. Quran has removed 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/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/feature/audio/build.gradle b/feature/audio/build.gradle index 43eeea22b8..b04dd3534e 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}" @@ -36,4 +37,6 @@ dependencies { testImplementation "junit:junit:${junitVersion}" testImplementation "com.google.truth:truth:${truthVersion}" + + implementation 'com.jakewharton.timber:timber:5.0.1' } diff --git a/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/database/SuraTimingDatabaseHandler.kt b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/database/SuraTimingDatabaseHandler.kt new file mode 100644 index 0000000000..70db297573 --- /dev/null +++ b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/database/SuraTimingDatabaseHandler.kt @@ -0,0 +1,126 @@ +package com.quran.labs.androidquran.feature.audio.database + +import android.database.Cursor +import android.database.DefaultDatabaseErrorHandler +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabaseCorruptException +import timber.log.Timber +import java.io.File +import java.lang.Exception +import java.sql.SQLException +import java.util.HashMap + +class SuraTimingDatabaseHandler private constructor(path: String) { + private var database: SQLiteDatabase? = null + + object TimingsTable { + const val TABLE_NAME = "timings" + const val COL_SURA = "sura" + const val COL_AYAH = "ayah" + const val COL_TIME = "time" + } + + object PropertiesTable { + const val TABLE_NAME = "properties" + const val COL_PROPERTY = "property" + const val COL_VALUE = "value" + } + + companion object { + private val databaseMap: MutableMap = HashMap() + + @JvmStatic + @Synchronized + fun getDatabaseHandler(path: String): SuraTimingDatabaseHandler { + var handler = databaseMap[path] + if (handler == null) { + handler = SuraTimingDatabaseHandler(path) + databaseMap[path] = handler + } + return handler + } + + @Synchronized + fun clearDatabaseHandlerIfExists(databasePath: String) { + try { + val handler = databaseMap.remove(databasePath) + if (handler != null) { + handler.database?.close() + databaseMap.remove(databasePath) + } + } catch (e: Exception) { + Timber.e(e) + } + } + } + + init { + Timber.d("opening gapless data file, %s", path) + database = try { + SQLiteDatabase.openDatabase( + path, null, + SQLiteDatabase.NO_LOCALIZED_COLLATORS, DefaultDatabaseErrorHandler() + ) + } catch (sce: SQLiteDatabaseCorruptException) { + Timber.d("database corrupted: %s", path) + null + } catch (se: SQLException) { + Timber.d("database at $path ${if (File(path).exists()) "exists " else "doesn 't exist"}") + Timber.e(se) + null + } + } + + private fun validDatabase(): Boolean = database?.isOpen ?: false + + fun getAyahTimings(sura: Int): Cursor? { + if (!validDatabase()) return null + + return try { + database?.query( + TimingsTable.TABLE_NAME, arrayOf( + TimingsTable.COL_SURA, + TimingsTable.COL_AYAH, TimingsTable.COL_TIME + ), + TimingsTable.COL_SURA + "=" + sura, + null, null, null, TimingsTable.COL_AYAH + " ASC" + ) + } catch (e: Exception) { + null + } + } + + fun getVersion(): Int { + if (!validDatabase()) { + return -1 + } + var cursor: Cursor? = null + return try { + cursor = database?.query( + PropertiesTable.TABLE_NAME, arrayOf(PropertiesTable.COL_VALUE), + PropertiesTable.COL_PROPERTY + "= 'version'", null, null, null, null + ) + if (cursor != null && cursor.moveToFirst()) { + cursor.getInt(0) + } else { + 1 + } + } catch (e: Exception) { + 1 + } finally { + DatabaseUtils.closeCursor(cursor) + } + } + + object DatabaseUtils { + @JvmStatic + fun closeCursor(cursor: Cursor?) { + try { + cursor?.close() + } catch (e: Exception) { + // no op + } + } + } +} + diff --git a/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/AudioShareUtils.kt b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/AudioShareUtils.kt new file mode 100644 index 0000000000..f36310be79 --- /dev/null +++ b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/AudioShareUtils.kt @@ -0,0 +1,267 @@ +package com.quran.labs.androidquran.feature.audio.util + +import android.content.Context +import android.database.Cursor +import android.database.SQLException +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Environment +import android.util.SparseIntArray +import android.widget.Toast +import com.quran.data.model.SuraAyah +import com.quran.labs.androidquran.common.audio.model.QariItem +import com.quran.labs.androidquran.feature.audio.database.SuraTimingDatabaseHandler +import com.quran.labs.androidquran.feature.audio.database.SuraTimingDatabaseHandler.DatabaseUtils.closeCursor +import com.quran.labs.androidquran.feature.audio.util.soundfile.CheapSoundFile +import kotlinx.coroutines.* +import okio.BufferedSink +import okio.buffer +import okio.sink +import okio.source +import timber.log.Timber +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 { + + val audioCacheDirectory = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).path + + File.separator + "quran_android_cache") + var audioCacheFilePaths: MutableList = ArrayList() + var selectedQari: QariItem? = null + var selectedStartSuraAyah: SuraAyah? = null + var selectedEndSuraAyah: SuraAyah? = null + + + data class ProperAyahOrder(val start: SuraAyah, val end: SuraAyah) + + private fun getReorderedAyahPair(start: SuraAyah, end: SuraAyah): ProperAyahOrder { + val (actualStart, actualEnd) = if (start <= end) { + start to end + } else { + end to start + } + return ProperAyahOrder(actualStart, actualEnd) + } + + + fun createSharableAudioFile(context: Context, start: SuraAyah, end: SuraAyah, qari: QariItem, urlFormat: String, gaplessDatabase: String): String? { + selectedStartSuraAyah = getReorderedAyahPair(start, end).start + selectedEndSuraAyah = getReorderedAyahPair(start, end).end + selectedQari = qari + + if (!audioCacheDirectory.exists()) { + if (!audioCacheDirectory.mkdirs()) { + Toast.makeText(context, "could not create directory", Toast.LENGTH_SHORT).show() + return null; + } + } + + var sharablePath: String? = null + + return runBlocking { + GlobalScope.launch(Dispatchers.IO) { + val mapArray = async { + getTimingData(start, end, gaplessDatabase) + }.await() + + val startAyah = start.ayah + val endAyah = end.ayah + val startSurahTimingDataArray: SparseIntArray = mapArray[0] + val endSurahTimingDataArray: SparseIntArray = mapArray[1] + + val isFirstAyahInSurah = startAyah == 1 + val startTimeOfAyahAfterEndAyah = endSurahTimingDataArray[endAyah + 1] + val isLastAyahInSurah = startTimeOfAyahAfterEndAyah == 0 + + val startAyahTime = if (isFirstAyahInSurah) { + 0 + } else { + startSurahTimingDataArray[startAyah] + } + + val endAyahTime = if (isLastAyahInSurah) { + getSurahDuration(context, + getSurahAudioPath(urlFormat, end.sura)!!) + } else { + startTimeOfAyahAfterEndAyah + } + + val startAndEndAyahAreInSameSurah = start.sura == end.sura + + if (startAndEndAyahAreInSameSurah) { + val audioSegmentPath: String = getSurahSegment( + getSurahAudioPath(urlFormat, start.sura)!!, startAyahTime, + endAyahTime)!! + audioCacheFilePaths.add(audioSegmentPath) + sharablePath = getRenamedSharableAudioFile(audioSegmentPath) + } else { + val segmentPaths = java.util.ArrayList() + val endOfSurah = -1 + val startOfSurah = 0 + val startSegmentPath: String = getSurahSegmentPath(context, urlFormat, start.sura, + startAyahTime, endOfSurah) + val lastSegmentPath: String = getSurahSegmentPath(context, urlFormat, end.sura, + startOfSurah, endAyahTime) + + 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) + continue + } + if (isMiddleSurah) { + segmentPaths.add(getSurahAudioPath(urlFormat, surahIndex)!!) + continue + } + segmentPaths.add(lastSegmentPath) + audioCacheFilePaths.add(lastSegmentPath) + } + + val audioSegmentsWereCreated = segmentPaths.isNotEmpty() + + if (audioSegmentsWereCreated) { + val sharableAudioFilePath: String = getMergedAudioFromSegments( + segmentPaths) + sharablePath = getRenamedSharableAudioFile(sharableAudioFilePath) + } else { + sharablePath = null + } + } + }.join() + return@runBlocking sharablePath + } + + } + + private fun getSurahSegmentPath(context: Context, 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(audioFilePath, startAyahTime, upperBoundTime)!! + } + + private fun getRenamedSharableAudioFile(audioSegmentPath: String): String { + val newAudioFileName: String = selectedQari!!.path + "_" + selectedStartSuraAyah!!.sura + "-" + selectedStartSuraAyah!!.ayah + "_" + selectedEndSuraAyah!!.sura + "-" + selectedEndSuraAyah!!.ayah + val newAudioFilePath: String = audioCacheDirectory.toString() + File.separator + newAudioFileName + ".mp3" + File(audioSegmentPath).renameTo(File(newAudioFilePath)) + audioCacheFilePaths.remove(audioSegmentPath) + for (path in audioCacheFilePaths) { + File(path).delete() + } + audioCacheFilePaths.clear() + 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): ArrayList { + val databasePath = gaplessDatabase!! + + val db: SuraTimingDatabaseHandler = SuraTimingDatabaseHandler.getDatabaseHandler( + databasePath) + var firstSurahMap = SparseIntArray() + var lastSurahMap = SparseIntArray() + var firstSurahCursor: Cursor? = null + var lastSurahCursor: Cursor? = null + + try { + firstSurahCursor = db.getAyahTimings(start.sura) + firstSurahMap = populateArrayFromCursor(firstSurahCursor)!! + lastSurahCursor = db.getAyahTimings(end.sura) + lastSurahMap = populateArrayFromCursor(lastSurahCursor) + } catch (sqlException: SQLException) { + Timber.e(sqlException) + } finally { + closeCursor(firstSurahCursor) + closeCursor(lastSurahCursor) + } + + return ArrayList( + listOf(firstSurahMap, lastSurahMap)) + } + + private fun populateArrayFromCursor(cursor: Cursor?): SparseIntArray { + val sparseIntArray = SparseIntArray() + if (cursor != null && cursor.moveToFirst()) { + do { + val ayah = cursor.getInt(1) + val time = cursor.getInt(2) + sparseIntArray.put(ayah, time) + } while (cursor.moveToNext()) + } + return sparseIntArray + } + + private fun getSurahSegment(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 = arrayOfNulls(1) + try { + soundFile[0] = CheapSoundFile.create(path, null) + val startTime = lowerCut.toFloat() / 1000 + val endTime = upperCut.toFloat() / 1000 + val samplesPerFrame = soundFile[0]?.samplesPerFrame + val sampleRate = soundFile[0]?.sampleRate + val avg = sampleRate?.div(samplesPerFrame!!) + val startFrames = (startTime * avg!!).roundToInt() + val endFrames = (endTime * avg).roundToInt() + soundFile[0]?.WriteFile(destFile, startFrames, endFrames - startFrames) + } catch (e: IOException) { + e.printStackTrace() + } + return destFile.absolutePath + } + + private fun getMergedAudioFromSegments(segments: ArrayList): String { + var mergedAudioPath = segments[0] + if (segments.size > 1) { + for (i in 1 until segments.size) { + mergedAudioPath = mergeAudios(mergedAudioPath, segments[i])!! + audioCacheFilePaths.add(mergedAudioPath) + } + } + return mergedAudioPath + } + + private fun mergeAudios(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/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/soundfile/CheapMP3.java b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/soundfile/CheapMP3.java new file mode 100644 index 0000000000..bedbc1af1c --- /dev/null +++ b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/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.audio.util.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/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/soundfile/CheapSoundFile.java b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/soundfile/CheapSoundFile.java new file mode 100644 index 0000000000..9ea9dd27fa --- /dev/null +++ b/feature/audio/src/main/java/com/quran/labs/androidquran/feature/audio/util/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.audio.util.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 { + } +}