diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ea00e03d3..6ec4806b652 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,16 @@ on: branches: - dev push: - branches: + branches: - dev - master jobs: - build-and-test: + build-and-test-jvm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - uses: gradle/wrapper-validation-action@v1 - name: create and checkout branch # push events already checked out the branch @@ -32,7 +33,7 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} restore-keys: ${{ runner.os }}-gradle - - name: Build debug APK and run Tests + - name: Build debug APK and run jvm tests run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace - name: Upload APK @@ -40,6 +41,31 @@ jobs: with: name: app path: app/build/outputs/apk/debug/*.apk + test-android: + runs-on: macos-latest + strategy: + matrix: + api-level: [21, 29] + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 1.8 + uses: actions/setup-java@v1.4.3 + with: + java-version: 1.8 + + - name: Cache Gradle dependencies + uses: actions/cache@v2 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Run android tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + script: ./gradlew connectedCheck # sonar: # runs-on: ubuntu-latest # steps: diff --git a/app/build.gradle b/app/build.gradle index 88ed8998e3b..e8518890c16 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -96,16 +96,19 @@ android { } ext { - icepickVersion = '3.2.0' checkstyleVersion = '8.38' - stethoVersion = '1.5.1' - leakCanaryVersion = '2.5' - exoPlayerVersion = '2.12.3' + androidxLifecycleVersion = '2.2.0' androidxRoomVersion = '2.3.0-alpha03' + + icepickVersion = '3.2.0' + exoPlayerVersion = '2.13.2' + googleAutoServiceVersion = '1.0-rc7' groupieVersion = '2.8.1' markwonVersion = '4.6.0' - googleAutoServiceVersion = '1.0-rc7' + + leakCanaryVersion = '2.5' + stethoVersion = '1.5.1' mockitoVersion = '3.6.0' } @@ -171,82 +174,99 @@ sonarqube { } dependencies { +/** Desugaring **/ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" - - implementation "frankiesardo:icepick:${icepickVersion}" - kapt "frankiesardo:icepick-processor:${icepickVersion}" +/** NewPipe libraries **/ + // You can use a local version by uncommenting a few lines in settings.gradle + implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.0' +/** Checkstyle **/ checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" - ktlint "com.pinterest:ktlint:0.40.0" - - debugImplementation "com.facebook.stetho:stetho:${stethoVersion}" - debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" + ktlint 'com.pinterest:ktlint:0.40.0' - debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}" - implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" - implementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}" +/** Kotlin **/ + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" - implementation "androidx.multidex:multidex:2.0.1" +/** AndroidX **/ + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" + implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' + implementation 'androidx.media:media:1.2.1' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.preference:preference:1.1.1' + implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation "androidx.room:room-runtime:${androidxRoomVersion}" + implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" + kapt "androidx.room:room-compiler:${androidxRoomVersion}" + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'androidx.webkit:webkit:1.4.0' + implementation 'com.google.android.material:material:1.2.1' - // NewPipe dependencies - // You can use a local version by uncommenting a few lines in settings.gradle - implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.0' - implementation "com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" +/** Third-party libraries **/ + // Instance state boilerplate elimination + implementation "frankiesardo:icepick:${icepickVersion}" + kapt "frankiesardo:icepick-processor:${icepickVersion}" + // HTML parser implementation "org.jsoup:jsoup:1.13.1" + // HTTP client //noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users implementation "com.squareup.okhttp3:okhttp:3.12.13" + // Media player implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}" implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}" - implementation "com.google.android.material:material:1.2.1" - + // Metadata generator for service descriptors compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}" kapt "com.google.auto.service:auto-service:${googleAutoServiceVersion}" - implementation "androidx.appcompat:appcompat:1.2.0" - implementation "androidx.preference:preference:1.1.1" - implementation "androidx.recyclerview:recyclerview:1.1.0" - implementation "androidx.cardview:cardview:1.0.0" - implementation "androidx.constraintlayout:constraintlayout:2.0.4" - implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' - implementation 'androidx.media:media:1.2.1' - implementation 'androidx.webkit:webkit:1.4.0' - - implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" - implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" - - implementation "androidx.room:room-runtime:${androidxRoomVersion}" - implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" - kapt "androidx.room:room-compiler:${androidxRoomVersion}" - - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - + // Manager for complex RecyclerView layouts implementation "com.xwray:groupie:${groupieVersion}" implementation "com.xwray:groupie-viewbinding:${groupieVersion}" + // Circular ImageView implementation "de.hdodenhof:circleimageview:3.1.0" + // Image loading implementation "com.nostra13.universalimageloader:universal-image-loader:1.9.5" + // Markdown library for Android implementation "io.noties.markwon:core:${markwonVersion}" implementation "io.noties.markwon:linkify:${markwonVersion}" + // File picker implementation "com.nononsenseapps:filepicker:4.2.1" + // Crash reporting implementation "ch.acra:acra-core:5.7.0" + // Reactive extensions for Java VM implementation "io.reactivex.rxjava3:rxjava:3.0.7" implementation "io.reactivex.rxjava3:rxandroid:3.0.0" + // RxJava binding APIs for Android UI widgets implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0" + // Date and time formatting implementation "org.ocpsoft.prettytime:prettytime:5.0.0.Final" +/** Debugging **/ + // Memory leak detection + implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" + implementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}" + debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}" + // Debug bridge for Android + debugImplementation "com.facebook.stetho:stetho:${stethoVersion}" + debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" + +/** Testing **/ testImplementation 'junit:junit:4.13.1' testImplementation "org.mockito:mockito-core:${mockitoVersion}" testImplementation "org.mockito:mockito-inline:${mockitoVersion}" diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java deleted file mode 100644 index ec28be237ef..00000000000 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java +++ /dev/null @@ -1,192 +0,0 @@ -package org.schabi.newpipe.about; - -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import com.google.android.material.tabs.TabLayoutMediator; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ActivityAboutBinding; -import org.schabi.newpipe.databinding.FragmentAboutBinding; -import org.schabi.newpipe.util.ThemeHelper; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.ShareUtils.openUrlInBrowser; - -public class AboutActivity extends AppCompatActivity { - /** - * List of all software components. - */ - private static final SoftwareComponent[] SOFTWARE_COMPONENTS = { - new SoftwareComponent("ACRA", "2013", "Kevin Gaudin", - "https://github.com/ACRA/acra", StandardLicenses.APACHE2), - new SoftwareComponent("AndroidX", "2005 - 2011", "The Android Open Source Project", - "https://developer.android.com/jetpack", StandardLicenses.APACHE2), - new SoftwareComponent("CircleImageView", "2014 - 2020", "Henning Dodenhof", - "https://github.com/hdodenhof/CircleImageView", - StandardLicenses.APACHE2), - new SoftwareComponent("ExoPlayer", "2014 - 2020", "Google, Inc.", - "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2), - new SoftwareComponent("GigaGet", "2014 - 2015", "Peter Cai", - "https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3), - new SoftwareComponent("Groupie", "2016", "Lisa Wray", - "https://github.com/lisawray/groupie", StandardLicenses.MIT), - new SoftwareComponent("Icepick", "2015", "Frankie Sardo", - "https://github.com/frankiesardo/icepick", StandardLicenses.EPL1), - new SoftwareComponent("Jsoup", "2009 - 2020", "Jonathan Hedley", - "https://github.com/jhy/jsoup", StandardLicenses.MIT), - new SoftwareComponent("Markwon", "2019", "Dimitry Ivanov", - "https://github.com/noties/Markwon", StandardLicenses.APACHE2), - new SoftwareComponent("Material Components for Android", "2016 - 2020", "Google, Inc.", - "https://github.com/material-components/material-components-android", - StandardLicenses.APACHE2), - new SoftwareComponent("NewPipe Extractor", "2017 - 2020", "Christian Schabesberger", - "https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3), - new SoftwareComponent("NoNonsense-FilePicker", "2016", "Jonas Kalderstam", - "https://github.com/spacecowboy/NoNonsense-FilePicker", - StandardLicenses.MPL2), - new SoftwareComponent("OkHttp", "2019", "Square, Inc.", - "https://square.github.io/okhttp/", StandardLicenses.APACHE2), - new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", - "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2), - new SoftwareComponent("RxAndroid", "2015", "The RxAndroid authors", - "https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2), - new SoftwareComponent("RxBinding", "2015", "Jake Wharton", - "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2), - new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", - "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2), - new SoftwareComponent("Universal Image Loader", "2011 - 2015", "Sergey Tarasevich", - "https://github.com/nostra13/Android-Universal-Image-Loader", - StandardLicenses.APACHE2), - }; - - private static final int POS_ABOUT = 0; - private static final int POS_LICENSE = 1; - private static final int TOTAL_COUNT = 2; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - assureCorrectAppLanguage(this); - super.onCreate(savedInstanceState); - ThemeHelper.setTheme(this); - setTitle(getString(R.string.title_activity_about)); - - final ActivityAboutBinding aboutBinding = ActivityAboutBinding.inflate(getLayoutInflater()); - setContentView(aboutBinding.getRoot()); - - setSupportActionBar(aboutBinding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - // Create the adapter that will return a fragment for each of the three - // primary sections of the activity. - final SectionsPagerAdapter mSectionsPagerAdapter = new SectionsPagerAdapter(this); - - // Set up the ViewPager with the sections adapter. - aboutBinding.container.setAdapter(mSectionsPagerAdapter); - - new TabLayoutMediator(aboutBinding.tabs, aboutBinding.container, (tab, position) -> { - switch (position) { - default: - case POS_ABOUT: - tab.setText(R.string.tab_about); - break; - case POS_LICENSE: - tab.setText(R.string.tab_licenses); - break; - } - }).attach(); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - final int id = item.getItemId(); - - switch (id) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - /** - * A placeholder fragment containing a simple view. - */ - public static class AboutFragment extends Fragment { - public AboutFragment() { - } - - /** - * Created a new instance of this fragment for the given section number. - * - * @return New instance of {@link AboutFragment} - */ - public static AboutFragment newInstance() { - return new AboutFragment(); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final FragmentAboutBinding aboutBinding = - FragmentAboutBinding.inflate(inflater, container, false); - final Context context = getContext(); - - aboutBinding.appVersion.setText(BuildConfig.VERSION_NAME); - - aboutBinding.githubLink.setOnClickListener(nv -> - openUrlInBrowser(context, context.getString(R.string.github_url), false)); - - aboutBinding.donationLink.setOnClickListener(v -> - openUrlInBrowser(context, context.getString(R.string.donation_url), false)); - - aboutBinding.websiteLink.setOnClickListener(nv -> - openUrlInBrowser(context, context.getString(R.string.website_url), false)); - - aboutBinding.privacyPolicyLink.setOnClickListener(v -> - openUrlInBrowser(context, context.getString(R.string.privacy_policy_url), - false)); - - return aboutBinding.getRoot(); - } - } - - /** - * A {@link FragmentStateAdapter} that returns a fragment corresponding to - * one of the sections/tabs/pages. - */ - public static class SectionsPagerAdapter extends FragmentStateAdapter { - public SectionsPagerAdapter(final FragmentActivity fa) { - super(fa); - } - - @NonNull - @Override - public Fragment createFragment(final int position) { - switch (position) { - default: - case POS_ABOUT: - return AboutFragment.newInstance(); - case POS_LICENSE: - return LicenseFragment.newInstance(SOFTWARE_COMPONENTS); - } - } - - @Override - public int getItemCount() { - // Show 2 total pages. - return TOTAL_COUNT; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt new file mode 100644 index 00000000000..2f015a049ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -0,0 +1,191 @@ +package org.schabi.newpipe.about + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.ActivityAboutBinding +import org.schabi.newpipe.databinding.FragmentAboutBinding +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ShareUtils +import org.schabi.newpipe.util.ThemeHelper + +class AboutActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + Localization.assureCorrectAppLanguage(this) + super.onCreate(savedInstanceState) + ThemeHelper.setTheme(this) + title = getString(R.string.title_activity_about) + val aboutBinding = ActivityAboutBinding.inflate(layoutInflater) + setContentView(aboutBinding.root) + setSupportActionBar(aboutBinding.aboutToolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + // Create the adapter that will return a fragment for each of the three + // primary sections of the activity. + val mAboutStateAdapter = AboutStateAdapter(this) + + // Set up the ViewPager with the sections adapter. + aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter + TabLayoutMediator( + aboutBinding.aboutTabLayout, + aboutBinding.aboutViewPager2 + ) { tab: TabLayout.Tab, position: Int -> + when (position) { + POS_ABOUT -> tab.setText(R.string.tab_about) + POS_LICENSE -> tab.setText(R.string.tab_licenses) + else -> throw IllegalArgumentException("Unknown position for ViewPager2") + } + }.attach() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + + /** + * A placeholder fragment containing a simple view. + */ + class AboutFragment : Fragment() { + private fun Button.openLink(url: Int) { + setOnClickListener { + ShareUtils.openUrlInBrowser( + context, + requireContext().getString(url), + false + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false) + aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME + aboutBinding.aboutGithubLink.openLink(R.string.github_url) + aboutBinding.aboutDonationLink.openLink(R.string.donation_url) + aboutBinding.aboutWebsiteLink.openLink(R.string.website_url) + aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) + return aboutBinding.root + } + } + + /** + * A [FragmentStateAdapter] that returns a fragment corresponding to + * one of the sections/tabs/pages. + */ + private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + override fun createFragment(position: Int): Fragment { + return when (position) { + POS_ABOUT -> AboutFragment() + POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) + else -> throw IllegalArgumentException("Unknown position for ViewPager2") + } + } + + override fun getItemCount(): Int { + // Show 2 total pages. + return TOTAL_COUNT + } + } + + companion object { + /** + * List of all software components. + */ + private val SOFTWARE_COMPONENTS = arrayOf( + SoftwareComponent( + "ACRA", "2013", "Kevin Gaudin", + "https://github.com/ACRA/acra", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "AndroidX", "2005 - 2011", "The Android Open Source Project", + "https://developer.android.com/jetpack", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "CircleImageView", "2014 - 2020", "Henning Dodenhof", + "https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "ExoPlayer", "2014 - 2020", "Google, Inc.", + "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "GigaGet", "2014 - 2015", "Peter Cai", + "https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3 + ), + SoftwareComponent( + "Groupie", "2016", "Lisa Wray", + "https://github.com/lisawray/groupie", StandardLicenses.MIT + ), + SoftwareComponent( + "Icepick", "2015", "Frankie Sardo", + "https://github.com/frankiesardo/icepick", StandardLicenses.EPL1 + ), + SoftwareComponent( + "Jsoup", "2009 - 2020", "Jonathan Hedley", + "https://github.com/jhy/jsoup", StandardLicenses.MIT + ), + SoftwareComponent( + "Markwon", "2019", "Dimitry Ivanov", + "https://github.com/noties/Markwon", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "Material Components for Android", "2016 - 2020", "Google, Inc.", + "https://github.com/material-components/material-components-android", + StandardLicenses.APACHE2 + ), + SoftwareComponent( + "NewPipe Extractor", "2017 - 2020", "Christian Schabesberger", + "https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3 + ), + SoftwareComponent( + "NoNonsense-FilePicker", "2016", "Jonas Kalderstam", + "https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2 + ), + SoftwareComponent( + "OkHttp", "2019", "Square, Inc.", + "https://square.github.io/okhttp/", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "PrettyTime", "2012 - 2020", "Lincoln Baxter, III", + "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "RxAndroid", "2015", "The RxAndroid authors", + "https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "RxBinding", "2015", "Jake Wharton", + "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "RxJava", "2016 - 2020", "RxJava Contributors", + "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "Universal Image Loader", "2011 - 2015", "Sergey Tarasevich", + "https://github.com/nostra13/Android-Universal-Image-Loader", + StandardLicenses.APACHE2 + ) + ) + private const val POS_ABOUT = 0 + private const val POS_LICENSE = 1 + private const val TOTAL_COUNT = 2 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.java b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.java deleted file mode 100644 index f5bf4df19e9..00000000000 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.schabi.newpipe.about; - -import android.os.Bundle; -import android.view.ContextMenu; -import android.view.LayoutInflater; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentLicensesBinding; -import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding; -import org.schabi.newpipe.util.ShareUtils; - -import java.io.Serializable; -import java.util.Arrays; -import java.util.Comparator; -import java.util.Objects; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -/** - * Fragment containing the software licenses. - */ -public class LicenseFragment extends Fragment { - private static final String ARG_COMPONENTS = "components"; - private static final String LICENSE_KEY = "ACTIVE_LICENSE"; - - private SoftwareComponent[] softwareComponents; - private SoftwareComponent componentForContextMenu; - private License activeLicense; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - public static LicenseFragment newInstance(final SoftwareComponent[] softwareComponents) { - final Bundle bundle = new Bundle(); - bundle.putParcelableArray(ARG_COMPONENTS, Objects.requireNonNull(softwareComponents)); - final LicenseFragment fragment = new LicenseFragment(); - fragment.setArguments(bundle); - return fragment; - } - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - softwareComponents = (SoftwareComponent[]) getArguments() - .getParcelableArray(ARG_COMPONENTS); - - if (savedInstanceState != null) { - final Serializable license = savedInstanceState.getSerializable(LICENSE_KEY); - if (license != null) { - activeLicense = (License) license; - } - } - // Sort components by name - Arrays.sort(softwareComponents, Comparator.comparing(SoftwareComponent::getName)); - } - - @Override - public void onDestroy() { - compositeDisposable.dispose(); - super.onDestroy(); - } - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - final FragmentLicensesBinding binding = FragmentLicensesBinding - .inflate(inflater, container, false); - - binding.appReadLicense.setOnClickListener(v -> { - activeLicense = StandardLicenses.GPL3; - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - StandardLicenses.GPL3)); - }); - - for (final SoftwareComponent component : softwareComponents) { - final ItemSoftwareComponentBinding componentBinding = ItemSoftwareComponentBinding - .inflate(inflater, container, false); - componentBinding.name.setText(component.getName()); - componentBinding.copyright.setText(getString(R.string.copyright, - component.getYears(), - component.getCopyrightOwner(), - component.getLicense().getAbbreviation())); - - final View root = componentBinding.getRoot(); - root.setTag(component); - root.setOnClickListener(v -> { - activeLicense = component.getLicense(); - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - component.getLicense())); - }); - binding.softwareComponents.addView(root); - registerForContextMenu(root); - } - if (activeLicense != null) { - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - activeLicense)); - } - return binding.getRoot(); - } - - @Override - public void onCreateContextMenu(final ContextMenu menu, final View v, - final ContextMenu.ContextMenuInfo menuInfo) { - final MenuInflater inflater = getActivity().getMenuInflater(); - final SoftwareComponent component = (SoftwareComponent) v.getTag(); - menu.setHeaderTitle(component.getName()); - inflater.inflate(R.menu.software_component, menu); - super.onCreateContextMenu(menu, v, menuInfo); - componentForContextMenu = (SoftwareComponent) v.getTag(); - } - - @Override - public boolean onContextItemSelected(@NonNull final MenuItem item) { - // item.getMenuInfo() is null so we use the tag of the view - final SoftwareComponent component = componentForContextMenu; - if (component == null) { - return false; - } - switch (item.getItemId()) { - case R.id.action_website: - ShareUtils.openUrlInBrowser(getActivity(), component.getLink()); - return true; - case R.id.action_show_license: - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - component.getLicense())); - } - return false; - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle savedInstanceState) { - super.onSaveInstanceState(savedInstanceState); - if (activeLicense != null) { - savedInstanceState.putSerializable(LICENSE_KEY, activeLicense); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt new file mode 100644 index 00000000000..d72ecf8948c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt @@ -0,0 +1,131 @@ +package org.schabi.newpipe.about + +import android.os.Bundle +import android.view.ContextMenu +import android.view.ContextMenu.ContextMenuInfo +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.R +import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense +import org.schabi.newpipe.databinding.FragmentLicensesBinding +import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding +import org.schabi.newpipe.util.ShareUtils +import java.util.Arrays +import java.util.Objects + +/** + * Fragment containing the software licenses. + */ +class LicenseFragment : Fragment() { + private lateinit var softwareComponents: Array + private var componentForContextMenu: SoftwareComponent? = null + private var activeLicense: License? = null + private val compositeDisposable = CompositeDisposable() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + softwareComponents = + arguments?.getParcelableArray(ARG_COMPONENTS) as Array + if (savedInstanceState != null) { + val license = savedInstanceState.getSerializable(LICENSE_KEY) + if (license != null) { + activeLicense = license as License? + } + } + // Sort components by name + Arrays.sort(softwareComponents, Comparator.comparing(SoftwareComponent::name)) + } + + override fun onDestroy() { + compositeDisposable.dispose() + super.onDestroy() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = FragmentLicensesBinding.inflate(inflater, container, false) + binding.licensesAppReadLicense.setOnClickListener { + activeLicense = StandardLicenses.GPL3 + compositeDisposable.add( + showLicense(activity, StandardLicenses.GPL3) + ) + } + for (component in softwareComponents) { + val componentBinding = ItemSoftwareComponentBinding + .inflate(inflater, container, false) + componentBinding.name.text = component.name + componentBinding.copyright.text = getString( + R.string.copyright, + component.years, + component.copyrightOwner, + component.license.abbreviation + ) + val root: View = componentBinding.root + root.tag = component + root.setOnClickListener { + activeLicense = component.license + compositeDisposable.add( + showLicense(activity, component.license) + ) + } + binding.licensesSoftwareComponents.addView(root) + registerForContextMenu(root) + } + if (activeLicense != null) { + compositeDisposable.add( + showLicense(activity, activeLicense!!) + ) + } + return binding.root + } + + override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?) { + val inflater = requireActivity().menuInflater + val component = v.tag as SoftwareComponent + menu.setHeaderTitle(component.name) + inflater.inflate(R.menu.software_component, menu) + super.onCreateContextMenu(menu, v, menuInfo) + componentForContextMenu = component + } + + override fun onContextItemSelected(item: MenuItem): Boolean { + // item.getMenuInfo() is null so we use the tag of the view + val component = componentForContextMenu ?: return false + when (item.itemId) { + R.id.menu_software_website -> { + ShareUtils.openUrlInBrowser(activity, component.link) + return true + } + R.id.menu_software_show_license -> compositeDisposable.add( + showLicense(activity, component.license) + ) + } + return false + } + + override fun onSaveInstanceState(savedInstanceState: Bundle) { + super.onSaveInstanceState(savedInstanceState) + if (activeLicense != null) { + savedInstanceState.putSerializable(LICENSE_KEY, activeLicense) + } + } + + companion object { + private const val ARG_COMPONENTS = "components" + private const val LICENSE_KEY = "ACTIVE_LICENSE" + fun newInstance(softwareComponents: Array): LicenseFragment { + val fragment = LicenseFragment() + fragment.arguments = + bundleOf(ARG_COMPONENTS to Objects.requireNonNull(softwareComponents)) + return fragment + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.java b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.java deleted file mode 100644 index b0241049d29..00000000000 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.schabi.newpipe.about; - -import android.content.Context; -import android.util.Base64; -import android.webkit.WebView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.ThemeHelper; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -public final class LicenseFragmentHelper { - private LicenseFragmentHelper() { } - - /** - * @param context the context to use - * @param license the license - * @return String which contains a HTML formatted license page - * styled according to the context's theme - */ - private static String getFormattedLicense(@NonNull final Context context, - @NonNull final License license) { - final StringBuilder licenseContent = new StringBuilder(); - final String webViewData; - try (BufferedReader in = new BufferedReader(new InputStreamReader( - context.getAssets().open(license.getFilename()), StandardCharsets.UTF_8))) { - String str; - while ((str = in.readLine()) != null) { - licenseContent.append(str); - } - - // split the HTML file and insert the stylesheet into the HEAD of the file - webViewData = licenseContent.toString().replace("", - ""); - } catch (final IOException e) { - throw new IllegalArgumentException( - "Could not get license file: " + license.getFilename(), e); - } - return webViewData; - } - - /** - * @param context the Android context - * @return String which is a CSS stylesheet according to the context's theme - */ - private static String getLicenseStylesheet(@NonNull final Context context) { - final boolean isLightTheme = ThemeHelper.isLightThemeSelected(context); - return "body{padding:12px 15px;margin:0;" - + "background:#" + getHexRGBColor(context, isLightTheme - ? R.color.light_license_background_color - : R.color.dark_license_background_color) + ";" - + "color:#" + getHexRGBColor(context, isLightTheme - ? R.color.light_license_text_color - : R.color.dark_license_text_color) + "}" - + "a[href]{color:#" + getHexRGBColor(context, isLightTheme - ? R.color.light_youtube_primary_color - : R.color.dark_youtube_primary_color) + "}" - + "pre{white-space:pre-wrap}"; - } - - /** - * Cast R.color to a hexadecimal color value. - * - * @param context the context to use - * @param color the color number from R.color - * @return a six characters long String with hexadecimal RGB values - */ - private static String getHexRGBColor(@NonNull final Context context, final int color) { - return context.getResources().getString(color).substring(3); - } - - static Disposable showLicense(@Nullable final Context context, @NonNull final License license) { - if (context == null) { - return Disposable.empty(); - } - - return Observable.fromCallable(() -> getFormattedLicense(context, license)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(formattedLicense -> { - final String webViewData = Base64.encodeToString(formattedLicense - .getBytes(StandardCharsets.UTF_8), Base64.NO_PADDING); - final WebView webView = new WebView(context); - webView.loadData(webViewData, "text/html; charset=UTF-8", "base64"); - - final AlertDialog.Builder alert = new AlertDialog.Builder(context); - alert.setTitle(license.getName()); - alert.setView(webView); - assureCorrectAppLanguage(context); - alert.setNegativeButton(context.getString(R.string.finish), - (dialog, which) -> dialog.dismiss()); - alert.show(); - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt new file mode 100644 index 00000000000..bdb3edabdc5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt @@ -0,0 +1,116 @@ +package org.schabi.newpipe.about + +import android.content.Context +import android.util.Base64 +import android.webkit.WebView +import androidx.appcompat.app.AlertDialog +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.R +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ThemeHelper +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + +object LicenseFragmentHelper { + /** + * @param context the context to use + * @param license the license + * @return String which contains a HTML formatted license page + * styled according to the context's theme + */ + private fun getFormattedLicense(context: Context, license: License): String { + val licenseContent = StringBuilder() + val webViewData: String + try { + BufferedReader( + InputStreamReader( + context.assets.open(license.filename), + StandardCharsets.UTF_8 + ) + ).use { `in` -> + var str: String? + while (`in`.readLine().also { str = it } != null) { + licenseContent.append(str) + } + + // split the HTML file and insert the stylesheet into the HEAD of the file + webViewData = "$licenseContent".replace( + "", + "" + ) + } + } catch (e: IOException) { + throw IllegalArgumentException( + "Could not get license file: " + license.filename, e + ) + } + return webViewData + } + + /** + * @param context the Android context + * @return String which is a CSS stylesheet according to the context's theme + */ + private fun getLicenseStylesheet(context: Context): String { + val isLightTheme = ThemeHelper.isLightThemeSelected(context) + return ( + "body{padding:12px 15px;margin:0;" + "background:#" + getHexRGBColor( + context, + if (isLightTheme) R.color.light_license_background_color + else R.color.dark_license_background_color + ) + ";" + "color:#" + getHexRGBColor( + context, + if (isLightTheme) R.color.light_license_text_color + else R.color.dark_license_text_color + ) + "}" + "a[href]{color:#" + getHexRGBColor( + context, + if (isLightTheme) R.color.light_youtube_primary_color + else R.color.dark_youtube_primary_color + ) + "}" + "pre{white-space:pre-wrap}" + ) + } + + /** + * Cast R.color to a hexadecimal color value. + * + * @param context the context to use + * @param color the color number from R.color + * @return a six characters long String with hexadecimal RGB values + */ + private fun getHexRGBColor(context: Context, color: Int): String { + return context.getString(color).substring(3) + } + + @JvmStatic + fun showLicense(context: Context?, license: License): Disposable { + return if (context == null) { + Disposable.empty() + } else { + Observable.fromCallable { getFormattedLicense(context, license) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { formattedLicense: String -> + val webViewData = Base64.encodeToString( + formattedLicense + .toByteArray(StandardCharsets.UTF_8), + Base64.NO_PADDING + ) + val webView = WebView(context) + webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") + val alert = AlertDialog.Builder(context) + alert.setTitle(license.name) + alert.setView(webView) + Localization.assureCorrectAppLanguage(context) + alert.setNegativeButton( + context.getString(R.string.finish) + ) { dialog, _ -> dialog.dismiss() } + alert.show() + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.java b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.java deleted file mode 100644 index 60b1e168c6f..00000000000 --- a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.schabi.newpipe.about; - -/** - * Class containing information about standard software licenses. - */ -public final class StandardLicenses { - public static final License GPL3 - = new License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html"); - public static final License APACHE2 - = new License("Apache License, Version 2.0", "ALv2", "apache2.html"); - public static final License MPL2 - = new License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html"); - public static final License MIT - = new License("MIT License", "MIT", "mit.html"); - public static final License EPL1 - = new License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html"); - - private StandardLicenses() { } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt new file mode 100644 index 00000000000..c5b9618fe1e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.about + +/** + * Class containing information about standard software licenses. + */ +object StandardLicenses { + @JvmField + val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html") + + @JvmField + val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html") + + @JvmField + val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html") + + @JvmField + val MIT = License("MIT License", "MIT", "mit.html") + + @JvmField + val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html") +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index a5dfe205761..72e3300ed1d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -1009,6 +1009,12 @@ private boolean shouldShowComments() { } public void updateTabLayoutVisibility() { + + if (binding == null) { + //If binding is null we do not need to and should not do anything with its object(s) + return; + } + if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { // hide tab layout if there is only one tab or if the view pager is also hidden binding.tabLayout.setVisibility(View.GONE); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 11494792319..e20df67a172 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -423,7 +423,9 @@ public void onComplete() { } @Override public void setTitle(final String title) { super.setTitle(title); - headerBinding.playlistTitleView.setText(title); + if (headerBinding != null) { + headerBinding.playlistTitleView.setText(title); + } } private void onBookmarkClicked() { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 26360137e9c..76d9d6e38ec 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -139,7 +139,7 @@ public class SearchFragment extends BaseListFragment menuItemToFilterName; + @Nullable private Map menuItemToFilterName = null; private StreamingService service; private Page nextPage; private boolean isSuggestionsEnabled = true; @@ -455,11 +455,12 @@ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { } @Override - public boolean onOptionsItemSelected(final MenuItem item) { - final List cf = new ArrayList<>(1); - cf.add(menuItemToFilterName.get(item.getItemId())); - changeContentFilter(item, cf); - + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (menuItemToFilterName != null) { + final List cf = new ArrayList<>(1); + cf.add(menuItemToFilterName.get(item.getItemId())); + changeContentFilter(item, cf); + } return true; } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java index 1915ff28323..a84c9840416 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java @@ -4,8 +4,6 @@ import android.view.ViewGroup; import android.widget.TextView; -import androidx.preference.PreferenceManager; - import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; @@ -14,6 +12,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; +import androidx.preference.PreferenceManager; + import static org.schabi.newpipe.MainActivity.DEBUG; /* diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index 5dbb67cd1d0..66bd341429f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -72,7 +72,6 @@ public class LocalPlaylistFragment extends BaseLocalListFragment= Build.VERSION_CODES.LOLLIPOP) { - trackSelector.setParameters(trackSelector.buildUponParameters() - .setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context))); + // enable media tunneling + if (DeviceUtils.shouldSupportMediaTunneling()) { + trackSelector.setParameters( + trackSelector.buildUponParameters().setTunnelingEnabled(true)); + } else if (DEBUG) { + Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] does not support media tunneling"); } } @@ -624,10 +627,10 @@ public void handleIntent(@NonNull final Intent intent) { && newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl()) && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { // Player can have state = IDLE when playback is stopped or failed - // and we should retry() in this case + // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.retry(); + simpleExoPlayer.prepare(); } simpleExoPlayer.seekTo(playQueue.getIndex(), newQueue.getItem().getRecoveryPosition()); simpleExoPlayer.setPlayWhenReady(playWhenReady); @@ -638,10 +641,10 @@ public void handleIntent(@NonNull final Intent intent) { && !playQueue.isDisposed()) { // Do not re-init the same PlayQueue. Save time // Player can have state = IDLE when playback is stopped or failed - // and we should retry() in this case + // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.retry(); + simpleExoPlayer.prepare(); } simpleExoPlayer.setPlayWhenReady(playWhenReady); @@ -706,11 +709,7 @@ && isPlaybackResumeEnabled(this) // Android TV: without it focus will frame the whole player binding.playPauseButton.requestFocus(); - if (simpleExoPlayer.getPlayWhenReady()) { - play(); - } else { - pause(); - } + playPause(); } NavigationHelper.sendPlayerStartedEvent(context); } @@ -1653,7 +1652,7 @@ public void onStartTrackingTouch(final SeekBar seekBar) { saveWasPlaying(); if (isPlaying()) { - simpleExoPlayer.setPlayWhenReady(false); + simpleExoPlayer.pause(); } showControls(0); @@ -1669,7 +1668,7 @@ public void onStopTrackingTouch(final SeekBar seekBar) { seekTo(seekBar.getProgress()); if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) { - simpleExoPlayer.setPlayWhenReady(true); + simpleExoPlayer.play(); } binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); @@ -1687,7 +1686,7 @@ public void onStopTrackingTouch(final SeekBar seekBar) { } public void saveWasPlaying() { - this.wasPlaying = simpleExoPlayer.getPlayWhenReady(); + this.wasPlaying = getPlayWhenReady(); } //endregion @@ -1912,7 +1911,7 @@ public void onPlayerStateChanged(final boolean playWhenReady, final int playback } @Override // exoplayer listener - public void onLoadingChanged(final boolean isLoading) { + public void onIsLoadingChanged(final boolean isLoading) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " + "isLoading = [" + isLoading + "]"); @@ -1956,7 +1955,8 @@ public void onPlaybackUnblock(final MediaSource mediaSource) { if (currentState == STATE_BLOCKED) { changeState(STATE_BUFFERING); } - simpleExoPlayer.prepare(mediaSource); + simpleExoPlayer.setMediaSource(mediaSource); + simpleExoPlayer.prepare(); } public void changeState(final int state) { @@ -2355,6 +2355,12 @@ public void onPositionDiscontinuity(@DiscontinuityReason final int discontinuity break; } case DISCONTINUITY_REASON_SEEK: + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); + } + if (isPrepared) { + saveStreamProgressState(); + } case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: if (playQueue.getIndex() != newWindowIndex) { @@ -2419,10 +2425,8 @@ public void onPlayerError(@NonNull final ExoPlaybackException error) { setRecovery(); reloadPlayQueueManager(); break; - case ExoPlaybackException.TYPE_OUT_OF_MEMORY: case ExoPlaybackException.TYPE_REMOTE: case ExoPlaybackException.TYPE_RENDERER: - case ExoPlaybackException.TYPE_TIMEOUT: default: showUnrecoverableError(error); onPlaybackShutdown(); @@ -2627,16 +2631,6 @@ public void seekToDefault() { simpleExoPlayer.seekToDefaultPosition(); } } - - @Override // exoplayer override - public void onSeekProcessed() { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); - } - if (isPrepared) { - saveStreamProgressState(); - } - } //endregion @@ -2664,7 +2658,7 @@ public void play() { } } - simpleExoPlayer.setPlayWhenReady(true); + simpleExoPlayer.play(); saveStreamProgressState(); } @@ -2677,7 +2671,7 @@ public void pause() { } audioReactor.abandonAudioFocus(); - simpleExoPlayer.setPlayWhenReady(false); + simpleExoPlayer.pause(); saveStreamProgressState(); } @@ -2686,7 +2680,7 @@ public void playPause() { Log.d(TAG, "onPlayPause() called"); } - if (isPlaying()) { + if (getPlayWhenReady()) { pause(); } else { play(); @@ -4012,6 +4006,10 @@ public boolean isPlaying() { return !exoPlayerIsNull() && simpleExoPlayer.isPlaying(); } + public boolean getPlayWhenReady() { + return !exoPlayerIsNull() && simpleExoPlayer.getPlayWhenReady(); + } + private boolean isLoading() { return !exoPlayerIsNull() && simpleExoPlayer.isLoading(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt index 989c78c57cb..29ae7c5c318 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt +++ b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt @@ -229,8 +229,10 @@ abstract class BasePlayerGestureListener( // because the soft input is visible (the draggable area is currently resized). player.updateScreenSize() player.checkPopupPositionBounds() - initialPopupX = player.popupLayoutParams!!.x - initialPopupY = player.popupLayoutParams!!.y + player.popupLayoutParams?.let { + initialPopupX = it.x + initialPopupY = it.y + } return super.onDown(e) } @@ -466,7 +468,7 @@ abstract class BasePlayerGestureListener( // /////////////////////////////////////////////////////////////////// private fun getDisplayPortion(e: MotionEvent): DisplayPortion { - return if (player.playerType == MainPlayer.PlayerType.POPUP) { + return if (player.playerType == MainPlayer.PlayerType.POPUP && player.popupLayoutParams != null) { when { e.x < player.popupLayoutParams!!.width / 3.0 -> DisplayPortion.LEFT e.x > player.popupLayoutParams!!.width * 2.0 / 3.0 -> DisplayPortion.RIGHT diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java index 13ee24e16cf..c4b21f203a1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -103,13 +103,13 @@ private void onAudioFocusGain() { animateAudio(DUCK_AUDIO_TO, 1.0f); if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { - player.setPlayWhenReady(true); + player.play(); } } private void onAudioFocusLoss() { Log.d(TAG, "onAudioFocusLoss() called"); - player.setPlayWhenReady(false); + player.pause(); } private void onAudioFocusLossCanDuck() { @@ -148,7 +148,7 @@ public void onAnimationEnd(final Animator animation) { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onAudioSessionId(final EventTime eventTime, final int audioSessionId) { + public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) { if (!PlayerHelper.isUsingDSP()) { return; } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index ba9a2f1ec4c..fe0233508fc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -4,7 +4,7 @@ import com.google.android.exoplayer2.LoadControl; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.upstream.Allocator; public class LoadController implements LoadControl { @@ -33,7 +33,7 @@ private LoadController(final int initialPlaybackBufferMs, final DefaultLoadControl.Builder builder = new DefaultLoadControl.Builder(); builder.setBufferDurationsMs(minimumPlaybackBufferMs, optimalPlaybackBufferMs, initialPlaybackBufferMs, initialPlaybackBufferMs); - internalLoadControl = builder.createDefaultLoadControl(); + internalLoadControl = builder.build(); } /*////////////////////////////////////////////////////////////////////////// @@ -47,9 +47,9 @@ public void onPrepared() { } @Override - public void onTracksSelected(final Renderer[] renderers, final TrackGroupArray trackGroupArray, - final TrackSelectionArray trackSelectionArray) { - internalLoadControl.onTracksSelected(renderers, trackGroupArray, trackSelectionArray); + public void onTracksSelected(final Renderer[] renderers, final TrackGroupArray trackGroups, + final ExoTrackSelection[] trackSelections) { + internalLoadControl.onTracksSelected(renderers, trackGroups, trackSelections); } @Override @@ -92,11 +92,12 @@ public boolean shouldContinueLoading(final long playbackPositionUs, @Override public boolean shouldStartPlayback(final long bufferedDurationUs, final float playbackSpeed, - final boolean rebuffering) { + final boolean rebuffering, final long targetLiveOffsetUs) { final boolean isInitialPlaybackBufferFilled = bufferedDurationUs >= this.initialPlaybackBufferUs * playbackSpeed; final boolean isInternalStartingPlayback = internalLoadControl - .shouldStartPlayback(bufferedDurationUs, playbackSpeed, rebuffering); + .shouldStartPlayback(bufferedDurationUs, playbackSpeed, rebuffering, + targetLiveOffsetUs); return isInitialPlaybackBufferFilled || isInternalStartingPlayback; } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java index b0c64143393..c7f1f9c8c84 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java @@ -36,8 +36,6 @@ public MediaSessionManager(@NonNull final Context context, @NonNull final Player player, @NonNull final MediaSessionCallback callback) { mediaSession = new MediaSessionCompat(context, TAG); - mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS - | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); mediaSession.setActive(true); mediaSession.setPlaybackState(new PlaybackStateCompat.Builder() diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 4324fcd0a0b..aab3a8d491d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -23,7 +23,7 @@ import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.text.CaptionStyleCompat; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; import com.google.android.exoplayer2.util.MimeTypes; @@ -323,7 +323,7 @@ public static int getPlaybackOptimalBufferMs() { return 60000; } - public static TrackSelection.Factory getQualitySelector() { + public static ExoTrackSelection.Factory getQualitySelector() { return new AdaptiveTrackSelection.Factory( 1000, AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java index d70707fdbf1..389be70628e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java @@ -13,7 +13,7 @@ import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.util.Assertions; /** @@ -28,7 +28,7 @@ public class CustomTrackSelector extends DefaultTrackSelector { private String preferredTextLanguage; public CustomTrackSelector(final Context context, - final TrackSelection.Factory adaptiveTrackSelectionFactory) { + final ExoTrackSelection.Factory adaptiveTrackSelectionFactory) { super(context, adaptiveTrackSelectionFactory); } @@ -50,7 +50,7 @@ public void setPreferredTextLanguage(@NonNull final String label) { @Override @Nullable - protected Pair selectTextTrack( + protected Pair selectTextTrack( final TrackGroupArray groups, @NonNull final int[][] formatSupport, @NonNull final Parameters params, @@ -86,7 +86,7 @@ protected Pair selectTextTrack( } } return selectedGroup == null ? null - : Pair.create(new TrackSelection.Definition(selectedGroup, selectedTrackIndex), + : Pair.create(new ExoTrackSelection.Definition(selectedGroup, selectedTrackIndex), Assertions.checkNotNull(selectedTrackScore)); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index e06c0ff82f6..a6dcadd5eeb 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -7,6 +7,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.util.Util; @@ -43,13 +44,13 @@ default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSou switch (type) { case C.TYPE_SS: return dataSource.getLiveSsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_DASH: return dataSource.getLiveDashMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_HLS: return dataSource.getLiveHlsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); default: throw new IllegalStateException("Unsupported type: " + type); } @@ -68,16 +69,16 @@ default MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, switch (type) { case C.TYPE_SS: return dataSource.getLiveSsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_DASH: return dataSource.getDashMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_HLS: return dataSource.getHlsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_OTHER: return dataSource.getExtractorMediaSourceFactory(cacheKey).setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); default: throw new IllegalStateException("Unsupported type: " + type); } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index a2b3a1d3de9..245a85e7101 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -6,7 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; @@ -22,7 +22,6 @@ import java.util.ArrayList; import java.util.List; -import static com.google.android.exoplayer2.C.SELECTION_FLAG_AUTOSELECT; import static com.google.android.exoplayer2.C.TIME_UNSET; public class VideoPlaybackResolver implements PlaybackResolver { @@ -101,12 +100,12 @@ public MediaSource resolve(@NonNull final StreamInfo info) { if (mimeType == null) { continue; } - - final Format textFormat = Format.createTextSampleFormat(null, mimeType, - SELECTION_FLAG_AUTOSELECT, - PlayerHelper.captionLanguageOf(context, subtitle)); final MediaSource textSource = dataSource.getSampleMediaSourceFactory() - .createMediaSource(Uri.parse(subtitle.getUrl()), textFormat, TIME_UNSET); + .createMediaSource( + new MediaItem.Subtitle(Uri.parse(subtitle.getUrl()), + mimeType, + PlayerHelper.captionLanguageOf(context, subtitle)), + TIME_UNSET); mediaSources.add(textSource); } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java index e50c858caf1..045e574be0a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java @@ -17,6 +17,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.graphics.drawable.DrawableCompat; @@ -41,9 +42,8 @@ public NotificationActionsPreference(final Context context, final AttributeSet a } - private NotificationSlot[] notificationSlots; - - private List compactSlots; + @Nullable private NotificationSlot[] notificationSlots = null; + @Nullable private List compactSlots = null; //////////////////////////////////////////////////////////////////////////// // Lifecycle @@ -85,19 +85,22 @@ private void setupActions(@NonNull final View view) { //////////////////////////////////////////////////////////////////////////// private void saveChanges() { - final SharedPreferences.Editor editor = getSharedPreferences().edit(); + if (compactSlots != null && notificationSlots != null) { + final SharedPreferences.Editor editor = getSharedPreferences().edit(); - for (int i = 0; i < 3; i++) { - editor.putInt(getContext().getString(NotificationConstants.SLOT_COMPACT_PREF_KEYS[i]), - (i < compactSlots.size() ? compactSlots.get(i) : -1)); - } + for (int i = 0; i < 3; i++) { + editor.putInt(getContext().getString( + NotificationConstants.SLOT_COMPACT_PREF_KEYS[i]), + (i < compactSlots.size() ? compactSlots.get(i) : -1)); + } - for (int i = 0; i < 5; i++) { - editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - notificationSlots[i].selectedAction); - } + for (int i = 0; i < 5; i++) { + editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), + notificationSlots[i].selectedAction); + } - editor.apply(); + editor.apply(); + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java index 52069fd0e7d..0704dad66f3 100644 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java @@ -20,6 +20,13 @@ public final class DeviceUtils { private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; private static Boolean isTV = null; + /* + * Devices that do not support media tunneling + */ + // Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo + private static final boolean HI3798MV200 = Build.VERSION.SDK_INT == 24 + && Build.DEVICE.equals("Hi3798MV200"); + private DeviceUtils() { } @@ -88,4 +95,15 @@ public static int spToPx(@Dimension(unit = Dimension.SP) final int sp, sp, context.getResources().getDisplayMetrics()); } + + /** + * Some devices have broken tunneled video playback but claim to support it. + * See https://github.com/TeamNewPipe/NewPipe/issues/5911 + * @return false if Kitkat (does not support tunneling) or affected device + */ + public static boolean shouldSupportMediaTunneling() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && !HI3798MV200; + } + } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 4f4fd528360..df804136a20 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -62,7 +62,8 @@ public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; - private NavigationHelper() { } + private NavigationHelper() { + } /*////////////////////////////////////////////////////////////////////////// // Players @@ -111,18 +112,22 @@ public static Intent getPlayerEnqueueIntent(@NonNull final Context context, public static void playOnMainPlayer(final AppCompatActivity activity, @NonNull final PlayQueue playQueue) { final PlayQueueItem item = playQueue.getItem(); - assert item != null; - openVideoDetailFragment(activity, activity.getSupportFragmentManager(), - item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, false); + if (item != null) { + openVideoDetailFragment(activity, activity.getSupportFragmentManager(), + item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, + false); + } } public static void playOnMainPlayer(final Context context, @NonNull final PlayQueue playQueue, final boolean switchingPlayers) { final PlayQueueItem item = playQueue.getItem(); - assert item != null; - openVideoDetail(context, - item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, switchingPlayers); + if (item != null) { + openVideoDetail(context, + item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, + switchingPlayers); + } } public static void playOnPopupPlayer(final Context context, diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 525b2371f50..937fcf97717 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -2,14 +2,12 @@ diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 77f94068531..3211f3bd968 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -15,16 +15,14 @@ android:paddingBottom="@dimen/activity_vertical_margin">