mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-11-03 16:53:00 +00:00 
			
		
		
		
	Merge pull request #7975 from TacoTheDank/updateCheckerRewrite
Migrate app update checker to AndroidX Work
This commit is contained in:
		@@ -381,9 +381,6 @@
 | 
			
		||||
        <service
 | 
			
		||||
            android:name=".RouterActivity$FetcherService"
 | 
			
		||||
            android:exported="false" />
 | 
			
		||||
        <service
 | 
			
		||||
            android:name=".CheckForNewAppVersion"
 | 
			
		||||
            android:exported="false" />
 | 
			
		||||
 | 
			
		||||
        <!-- opting out of sending metrics to Google in Android System WebView -->
 | 
			
		||||
        <meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
 | 
			
		||||
 
 | 
			
		||||
@@ -1,264 +0,0 @@
 | 
			
		||||
package org.schabi.newpipe;
 | 
			
		||||
 | 
			
		||||
import android.app.Application;
 | 
			
		||||
import android.app.IntentService;
 | 
			
		||||
import android.app.PendingIntent;
 | 
			
		||||
import android.content.Intent;
 | 
			
		||||
import android.content.SharedPreferences;
 | 
			
		||||
import android.content.pm.PackageManager;
 | 
			
		||||
import android.content.pm.Signature;
 | 
			
		||||
import android.net.Uri;
 | 
			
		||||
import android.util.Log;
 | 
			
		||||
 | 
			
		||||
import androidx.annotation.NonNull;
 | 
			
		||||
import androidx.annotation.Nullable;
 | 
			
		||||
import androidx.core.app.NotificationCompat;
 | 
			
		||||
import androidx.core.app.NotificationManagerCompat;
 | 
			
		||||
import androidx.core.content.pm.PackageInfoCompat;
 | 
			
		||||
import androidx.preference.PreferenceManager;
 | 
			
		||||
 | 
			
		||||
import com.grack.nanojson.JsonObject;
 | 
			
		||||
import com.grack.nanojson.JsonParser;
 | 
			
		||||
import com.grack.nanojson.JsonParserException;
 | 
			
		||||
 | 
			
		||||
import org.schabi.newpipe.error.ErrorInfo;
 | 
			
		||||
import org.schabi.newpipe.error.ErrorUtil;
 | 
			
		||||
import org.schabi.newpipe.error.UserAction;
 | 
			
		||||
import org.schabi.newpipe.extractor.downloader.Response;
 | 
			
		||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
 | 
			
		||||
 | 
			
		||||
import java.io.ByteArrayInputStream;
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.io.InputStream;
 | 
			
		||||
import java.security.MessageDigest;
 | 
			
		||||
import java.security.NoSuchAlgorithmException;
 | 
			
		||||
import java.security.cert.CertificateEncodingException;
 | 
			
		||||
import java.security.cert.CertificateException;
 | 
			
		||||
import java.security.cert.CertificateFactory;
 | 
			
		||||
import java.security.cert.X509Certificate;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
public final class CheckForNewAppVersion extends IntentService {
 | 
			
		||||
    public CheckForNewAppVersion() {
 | 
			
		||||
        super("CheckForNewAppVersion");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static final boolean DEBUG = MainActivity.DEBUG;
 | 
			
		||||
    private static final String TAG = CheckForNewAppVersion.class.getSimpleName();
 | 
			
		||||
 | 
			
		||||
    // Public key of the certificate that is used in NewPipe release versions
 | 
			
		||||
    private static final String RELEASE_CERT_PUBLIC_KEY_SHA1
 | 
			
		||||
            = "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15";
 | 
			
		||||
    private static final String NEWPIPE_API_URL = "https://newpipe.net/api/data.json";
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
 | 
			
		||||
     *
 | 
			
		||||
     * @param application The application
 | 
			
		||||
     * @return String with the APK's SHA1 fingerprint in hexadecimal
 | 
			
		||||
     */
 | 
			
		||||
    @NonNull
 | 
			
		||||
    private static String getCertificateSHA1Fingerprint(@NonNull final Application application) {
 | 
			
		||||
        final List<Signature> signatures;
 | 
			
		||||
        try {
 | 
			
		||||
            signatures = PackageInfoCompat.getSignatures(application.getPackageManager(),
 | 
			
		||||
                    application.getPackageName());
 | 
			
		||||
        } catch (final PackageManager.NameNotFoundException e) {
 | 
			
		||||
            ErrorUtil.createNotification(application, new ErrorInfo(e,
 | 
			
		||||
                    UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info"));
 | 
			
		||||
            return "";
 | 
			
		||||
        }
 | 
			
		||||
        if (signatures.isEmpty()) {
 | 
			
		||||
            return "";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        final X509Certificate c;
 | 
			
		||||
        try {
 | 
			
		||||
            final byte[] cert = signatures.get(0).toByteArray();
 | 
			
		||||
            final InputStream input = new ByteArrayInputStream(cert);
 | 
			
		||||
            final CertificateFactory cf = CertificateFactory.getInstance("X509");
 | 
			
		||||
            c = (X509Certificate) cf.generateCertificate(input);
 | 
			
		||||
        } catch (final CertificateException e) {
 | 
			
		||||
            ErrorUtil.createNotification(application, new ErrorInfo(e,
 | 
			
		||||
                    UserAction.CHECK_FOR_NEW_APP_VERSION, "Certificate error"));
 | 
			
		||||
            return "";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            final MessageDigest md = MessageDigest.getInstance("SHA1");
 | 
			
		||||
            final byte[] publicKey = md.digest(c.getEncoded());
 | 
			
		||||
            return byte2HexFormatted(publicKey);
 | 
			
		||||
        } catch (NoSuchAlgorithmException | CertificateEncodingException e) {
 | 
			
		||||
            ErrorUtil.createNotification(application, new ErrorInfo(e,
 | 
			
		||||
                    UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not retrieve SHA1 key"));
 | 
			
		||||
            return "";
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static String byte2HexFormatted(final byte[] arr) {
 | 
			
		||||
        final StringBuilder str = new StringBuilder(arr.length * 2);
 | 
			
		||||
 | 
			
		||||
        for (int i = 0; i < arr.length; i++) {
 | 
			
		||||
            String h = Integer.toHexString(arr[i]);
 | 
			
		||||
            final int l = h.length();
 | 
			
		||||
            if (l == 1) {
 | 
			
		||||
                h = "0" + h;
 | 
			
		||||
            }
 | 
			
		||||
            if (l > 2) {
 | 
			
		||||
                h = h.substring(l - 2, l);
 | 
			
		||||
            }
 | 
			
		||||
            str.append(h.toUpperCase());
 | 
			
		||||
            if (i < (arr.length - 1)) {
 | 
			
		||||
                str.append(':');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return str.toString();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Method to compare the current and latest available app version.
 | 
			
		||||
     * If a newer version is available, we show the update notification.
 | 
			
		||||
     *
 | 
			
		||||
     * @param application    The application
 | 
			
		||||
     * @param versionName    Name of new version
 | 
			
		||||
     * @param apkLocationUrl Url with the new apk
 | 
			
		||||
     * @param versionCode    Code of new version
 | 
			
		||||
     */
 | 
			
		||||
    private static void compareAppVersionAndShowNotification(@NonNull final Application application,
 | 
			
		||||
                                                             final String versionName,
 | 
			
		||||
                                                             final String apkLocationUrl,
 | 
			
		||||
                                                             final int versionCode) {
 | 
			
		||||
        if (BuildConfig.VERSION_CODE >= versionCode) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // A pending intent to open the apk location url in the browser.
 | 
			
		||||
        final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
 | 
			
		||||
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 | 
			
		||||
        final PendingIntent pendingIntent
 | 
			
		||||
                = PendingIntent.getActivity(application, 0, intent, 0);
 | 
			
		||||
 | 
			
		||||
        final String channelId = application
 | 
			
		||||
                .getString(R.string.app_update_notification_channel_id);
 | 
			
		||||
        final NotificationCompat.Builder notificationBuilder
 | 
			
		||||
                = new NotificationCompat.Builder(application, channelId)
 | 
			
		||||
                .setSmallIcon(R.drawable.ic_newpipe_update)
 | 
			
		||||
                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
 | 
			
		||||
                .setContentIntent(pendingIntent)
 | 
			
		||||
                .setAutoCancel(true)
 | 
			
		||||
                .setContentTitle(application
 | 
			
		||||
                        .getString(R.string.app_update_notification_content_title))
 | 
			
		||||
                .setContentText(application
 | 
			
		||||
                        .getString(R.string.app_update_notification_content_text)
 | 
			
		||||
                        + " " + versionName);
 | 
			
		||||
 | 
			
		||||
        final NotificationManagerCompat notificationManager
 | 
			
		||||
                = NotificationManagerCompat.from(application);
 | 
			
		||||
        notificationManager.notify(2000, notificationBuilder.build());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static boolean isReleaseApk(@NonNull final App app) {
 | 
			
		||||
        return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void checkNewVersion() throws IOException, ReCaptchaException {
 | 
			
		||||
        final App app = App.getApp();
 | 
			
		||||
 | 
			
		||||
        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
 | 
			
		||||
        final NewVersionManager manager = new NewVersionManager();
 | 
			
		||||
 | 
			
		||||
        // Check if the current apk is a github one or not.
 | 
			
		||||
        if (!isReleaseApk(app)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check if the last request has happened a certain time ago
 | 
			
		||||
        // to reduce the number of API requests.
 | 
			
		||||
        final long expiry = prefs.getLong(app.getString(R.string.update_expiry_key), 0);
 | 
			
		||||
        if (!manager.isExpired(expiry)) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Make a network request to get latest NewPipe data.
 | 
			
		||||
        final Response response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL);
 | 
			
		||||
        handleResponse(response, manager, prefs, app);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void handleResponse(@NonNull final Response response,
 | 
			
		||||
                                @NonNull final NewVersionManager manager,
 | 
			
		||||
                                @NonNull final SharedPreferences prefs,
 | 
			
		||||
                                @NonNull final App app) {
 | 
			
		||||
        try {
 | 
			
		||||
            // Store a timestamp which needs to be exceeded,
 | 
			
		||||
            // before a new request to the API is made.
 | 
			
		||||
            final long newExpiry = manager
 | 
			
		||||
                    .coerceExpiry(response.getHeader("expires"));
 | 
			
		||||
            prefs.edit()
 | 
			
		||||
                    .putLong(app.getString(R.string.update_expiry_key), newExpiry)
 | 
			
		||||
                    .apply();
 | 
			
		||||
        } catch (final Exception e) {
 | 
			
		||||
            if (DEBUG) {
 | 
			
		||||
                Log.w(TAG, "Could not extract and save new expiry date", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Parse the json from the response.
 | 
			
		||||
        try {
 | 
			
		||||
 | 
			
		||||
            final JsonObject githubStableObject = JsonParser.object()
 | 
			
		||||
                    .from(response.responseBody()).getObject("flavors")
 | 
			
		||||
                    .getObject("github").getObject("stable");
 | 
			
		||||
 | 
			
		||||
            final String versionName = githubStableObject
 | 
			
		||||
                    .getString("version");
 | 
			
		||||
            final int versionCode = githubStableObject
 | 
			
		||||
                    .getInt("version_code");
 | 
			
		||||
            final String apkLocationUrl = githubStableObject
 | 
			
		||||
                    .getString("apk");
 | 
			
		||||
 | 
			
		||||
            compareAppVersionAndShowNotification(app, versionName,
 | 
			
		||||
                    apkLocationUrl, versionCode);
 | 
			
		||||
        } catch (final JsonParserException e) {
 | 
			
		||||
            // Most likely something is wrong in data received from NEWPIPE_API_URL.
 | 
			
		||||
            // Do not alarm user and fail silently.
 | 
			
		||||
            if (DEBUG) {
 | 
			
		||||
                Log.w(TAG, "Could not get NewPipe API: invalid json", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Start a new service which
 | 
			
		||||
     * checks if all conditions for performing a version check are met,
 | 
			
		||||
     * fetches the API endpoint {@link #NEWPIPE_API_URL} containing info
 | 
			
		||||
     * about the latest NewPipe version
 | 
			
		||||
     * and displays a notification about ana available update.
 | 
			
		||||
     * <br>
 | 
			
		||||
     * Following conditions need to be met, before data is request from the server:
 | 
			
		||||
     * <ul>
 | 
			
		||||
     * <li> The app is signed with the correct signing key (by TeamNewPipe / schabi).
 | 
			
		||||
     * If the signing key differs from the one used upstream, the update cannot be installed.</li>
 | 
			
		||||
     * <li>The user enabled searching for and notifying about updates in the settings.</li>
 | 
			
		||||
     * <li>The app did not recently check for updates.
 | 
			
		||||
     * We do not want to make unnecessary connections and DOS our servers.</li>
 | 
			
		||||
     * </ul>
 | 
			
		||||
     * <b>Must not be executed</b> when the app is in background.
 | 
			
		||||
     */
 | 
			
		||||
    public static void startNewVersionCheckService() {
 | 
			
		||||
        final Intent intent = new Intent(App.getApp().getApplicationContext(),
 | 
			
		||||
                CheckForNewAppVersion.class);
 | 
			
		||||
        App.getApp().startService(intent);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    protected void onHandleIntent(@Nullable final Intent intent) {
 | 
			
		||||
        try {
 | 
			
		||||
            checkNewVersion();
 | 
			
		||||
        } catch (final IOException e) {
 | 
			
		||||
            Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e);
 | 
			
		||||
        } catch (final ReCaptchaException e) {
 | 
			
		||||
            Log.e(TAG, "ReCaptchaException should never happen here.", e);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -20,7 +20,6 @@
 | 
			
		||||
 | 
			
		||||
package org.schabi.newpipe;
 | 
			
		||||
 | 
			
		||||
import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService;
 | 
			
		||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
 | 
			
		||||
 | 
			
		||||
import android.content.BroadcastReceiver;
 | 
			
		||||
@@ -174,10 +173,9 @@ public class MainActivity extends AppCompatActivity {
 | 
			
		||||
        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
 | 
			
		||||
 | 
			
		||||
        if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
 | 
			
		||||
            // Start the service which is checking all conditions
 | 
			
		||||
            // Start the worker which is checking all conditions
 | 
			
		||||
            // and eventually searching for a new version.
 | 
			
		||||
            // The service searching for a new NewPipe version must not be started in background.
 | 
			
		||||
            startNewVersionCheckService();
 | 
			
		||||
            NewVersionWorker.enqueueNewVersionCheckingWork(app);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
package org.schabi.newpipe
 | 
			
		||||
 | 
			
		||||
import java.time.Instant
 | 
			
		||||
import java.time.ZonedDateTime
 | 
			
		||||
import java.time.format.DateTimeFormatter
 | 
			
		||||
 | 
			
		||||
class NewVersionManager {
 | 
			
		||||
 | 
			
		||||
    fun isExpired(expiry: Long): Boolean {
 | 
			
		||||
        return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Coerce expiry date time in between 6 hours and 72 hours from now
 | 
			
		||||
     *
 | 
			
		||||
     * @return Epoch second of expiry date time
 | 
			
		||||
     */
 | 
			
		||||
    fun coerceExpiry(expiryString: String?): Long {
 | 
			
		||||
        val now = ZonedDateTime.now()
 | 
			
		||||
        return expiryString?.let {
 | 
			
		||||
 | 
			
		||||
            var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
 | 
			
		||||
            expiry = maxOf(expiry, now.plusHours(6))
 | 
			
		||||
            expiry = minOf(expiry, now.plusHours(72))
 | 
			
		||||
            expiry.toEpochSecond()
 | 
			
		||||
        } ?: now.plusHours(6).toEpochSecond()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										163
									
								
								app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,163 @@
 | 
			
		||||
package org.schabi.newpipe
 | 
			
		||||
 | 
			
		||||
import android.app.PendingIntent
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.content.Intent
 | 
			
		||||
import android.util.Log
 | 
			
		||||
import androidx.core.app.NotificationCompat
 | 
			
		||||
import androidx.core.app.NotificationManagerCompat
 | 
			
		||||
import androidx.core.content.edit
 | 
			
		||||
import androidx.core.net.toUri
 | 
			
		||||
import androidx.preference.PreferenceManager
 | 
			
		||||
import androidx.work.OneTimeWorkRequest
 | 
			
		||||
import androidx.work.WorkManager
 | 
			
		||||
import androidx.work.WorkRequest
 | 
			
		||||
import androidx.work.Worker
 | 
			
		||||
import androidx.work.WorkerParameters
 | 
			
		||||
import com.grack.nanojson.JsonParser
 | 
			
		||||
import com.grack.nanojson.JsonParserException
 | 
			
		||||
import org.schabi.newpipe.extractor.downloader.Response
 | 
			
		||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
 | 
			
		||||
import java.io.IOException
 | 
			
		||||
 | 
			
		||||
class NewVersionWorker(
 | 
			
		||||
    context: Context,
 | 
			
		||||
    workerParams: WorkerParameters
 | 
			
		||||
) : Worker(context, workerParams) {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Method to compare the current and latest available app version.
 | 
			
		||||
     * If a newer version is available, we show the update notification.
 | 
			
		||||
     *
 | 
			
		||||
     * @param versionName    Name of new version
 | 
			
		||||
     * @param apkLocationUrl Url with the new apk
 | 
			
		||||
     * @param versionCode    Code of new version
 | 
			
		||||
     */
 | 
			
		||||
    private fun compareAppVersionAndShowNotification(
 | 
			
		||||
        versionName: String,
 | 
			
		||||
        apkLocationUrl: String?,
 | 
			
		||||
        versionCode: Int
 | 
			
		||||
    ) {
 | 
			
		||||
        if (BuildConfig.VERSION_CODE >= versionCode) {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        val app = App.getApp()
 | 
			
		||||
 | 
			
		||||
        // A pending intent to open the apk location url in the browser.
 | 
			
		||||
        val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
 | 
			
		||||
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
 | 
			
		||||
        val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0)
 | 
			
		||||
        val channelId = app.getString(R.string.app_update_notification_channel_id)
 | 
			
		||||
        val notificationBuilder = NotificationCompat.Builder(app, channelId)
 | 
			
		||||
            .setSmallIcon(R.drawable.ic_newpipe_update)
 | 
			
		||||
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
 | 
			
		||||
            .setContentIntent(pendingIntent)
 | 
			
		||||
            .setAutoCancel(true)
 | 
			
		||||
            .setContentTitle(app.getString(R.string.app_update_notification_content_title))
 | 
			
		||||
            .setContentText(
 | 
			
		||||
                app.getString(R.string.app_update_notification_content_text) +
 | 
			
		||||
                    " " + versionName
 | 
			
		||||
            )
 | 
			
		||||
        val notificationManager = NotificationManagerCompat.from(app)
 | 
			
		||||
        notificationManager.notify(2000, notificationBuilder.build())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Throws(IOException::class, ReCaptchaException::class)
 | 
			
		||||
    private fun checkNewVersion() {
 | 
			
		||||
        // Check if the current apk is a github one or not.
 | 
			
		||||
        if (!isReleaseApk()) {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
 | 
			
		||||
        // Check if the last request has happened a certain time ago
 | 
			
		||||
        // to reduce the number of API requests.
 | 
			
		||||
        val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
 | 
			
		||||
        if (!isLastUpdateCheckExpired(expiry)) {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Make a network request to get latest NewPipe data.
 | 
			
		||||
        val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
 | 
			
		||||
        handleResponse(response)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun handleResponse(response: Response) {
 | 
			
		||||
        val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
 | 
			
		||||
        try {
 | 
			
		||||
            // Store a timestamp which needs to be exceeded,
 | 
			
		||||
            // before a new request to the API is made.
 | 
			
		||||
            val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
 | 
			
		||||
            prefs.edit {
 | 
			
		||||
                putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
 | 
			
		||||
            }
 | 
			
		||||
        } catch (e: Exception) {
 | 
			
		||||
            if (DEBUG) {
 | 
			
		||||
                Log.w(TAG, "Could not extract and save new expiry date", e)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Parse the json from the response.
 | 
			
		||||
        try {
 | 
			
		||||
            val githubStableObject = JsonParser.`object`()
 | 
			
		||||
                .from(response.responseBody()).getObject("flavors")
 | 
			
		||||
                .getObject("github").getObject("stable")
 | 
			
		||||
 | 
			
		||||
            val versionName = githubStableObject.getString("version")
 | 
			
		||||
            val versionCode = githubStableObject.getInt("version_code")
 | 
			
		||||
            val apkLocationUrl = githubStableObject.getString("apk")
 | 
			
		||||
            compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
 | 
			
		||||
        } catch (e: JsonParserException) {
 | 
			
		||||
            // Most likely something is wrong in data received from NEWPIPE_API_URL.
 | 
			
		||||
            // Do not alarm user and fail silently.
 | 
			
		||||
            if (DEBUG) {
 | 
			
		||||
                Log.w(TAG, "Could not get NewPipe API: invalid json", e)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun doWork(): Result {
 | 
			
		||||
        try {
 | 
			
		||||
            checkNewVersion()
 | 
			
		||||
        } catch (e: IOException) {
 | 
			
		||||
            Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
 | 
			
		||||
            return Result.failure()
 | 
			
		||||
        } catch (e: ReCaptchaException) {
 | 
			
		||||
            Log.e(TAG, "ReCaptchaException should never happen here.", e)
 | 
			
		||||
            return Result.failure()
 | 
			
		||||
        }
 | 
			
		||||
        return Result.success()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        private val DEBUG = MainActivity.DEBUG
 | 
			
		||||
        private val TAG = NewVersionWorker::class.java.simpleName
 | 
			
		||||
        private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Start a new worker which
 | 
			
		||||
         * checks if all conditions for performing a version check are met,
 | 
			
		||||
         * fetches the API endpoint [.NEWPIPE_API_URL] containing info
 | 
			
		||||
         * about the latest NewPipe version
 | 
			
		||||
         * and displays a notification about ana available update.
 | 
			
		||||
         * <br></br>
 | 
			
		||||
         * Following conditions need to be met, before data is request from the server:
 | 
			
		||||
         *
 | 
			
		||||
         *  *  The app is signed with the correct signing key (by TeamNewPipe / schabi).
 | 
			
		||||
         * If the signing key differs from the one used upstream, the update cannot be installed.
 | 
			
		||||
         *  * The user enabled searching for and notifying about updates in the settings.
 | 
			
		||||
         *  * The app did not recently check for updates.
 | 
			
		||||
         * We do not want to make unnecessary connections and DOS our servers.
 | 
			
		||||
         *
 | 
			
		||||
         */
 | 
			
		||||
        @JvmStatic
 | 
			
		||||
        fun enqueueNewVersionCheckingWork(context: Context) {
 | 
			
		||||
            val workRequest: WorkRequest =
 | 
			
		||||
                OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build()
 | 
			
		||||
            WorkManager.getInstance(context).enqueue(workRequest)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,10 +7,9 @@ import android.view.MenuItem;
 | 
			
		||||
 | 
			
		||||
import androidx.annotation.NonNull;
 | 
			
		||||
 | 
			
		||||
import org.schabi.newpipe.App;
 | 
			
		||||
import org.schabi.newpipe.CheckForNewAppVersion;
 | 
			
		||||
import org.schabi.newpipe.MainActivity;
 | 
			
		||||
import org.schabi.newpipe.R;
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil;
 | 
			
		||||
 | 
			
		||||
public class MainSettingsFragment extends BasePreferenceFragment {
 | 
			
		||||
    public static final boolean DEBUG = MainActivity.DEBUG;
 | 
			
		||||
@@ -24,7 +23,7 @@ public class MainSettingsFragment extends BasePreferenceFragment {
 | 
			
		||||
        setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called
 | 
			
		||||
 | 
			
		||||
        // Check if the app is updatable
 | 
			
		||||
        if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) {
 | 
			
		||||
        if (!ReleaseVersionUtil.isReleaseApk()) {
 | 
			
		||||
            getPreferenceScreen().removePreference(
 | 
			
		||||
                    findPreference(getString(R.string.update_pref_screen_key)));
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -23,8 +23,6 @@ import androidx.preference.PreferenceFragmentCompat;
 | 
			
		||||
 | 
			
		||||
import com.jakewharton.rxbinding4.widget.RxTextView;
 | 
			
		||||
 | 
			
		||||
import org.schabi.newpipe.App;
 | 
			
		||||
import org.schabi.newpipe.CheckForNewAppVersion;
 | 
			
		||||
import org.schabi.newpipe.MainActivity;
 | 
			
		||||
import org.schabi.newpipe.R;
 | 
			
		||||
import org.schabi.newpipe.databinding.SettingsLayoutBinding;
 | 
			
		||||
@@ -37,6 +35,7 @@ import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListen
 | 
			
		||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher;
 | 
			
		||||
import org.schabi.newpipe.util.DeviceUtils;
 | 
			
		||||
import org.schabi.newpipe.util.KeyboardUtil;
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil;
 | 
			
		||||
import org.schabi.newpipe.util.ThemeHelper;
 | 
			
		||||
import org.schabi.newpipe.views.FocusOverlayView;
 | 
			
		||||
 | 
			
		||||
@@ -267,7 +266,7 @@ public class SettingsActivity extends AppCompatActivity implements
 | 
			
		||||
     */
 | 
			
		||||
    private void ensureSearchRepresentsApplicationState() {
 | 
			
		||||
        // Check if the update settings are available
 | 
			
		||||
        if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) {
 | 
			
		||||
        if (!ReleaseVersionUtil.isReleaseApk()) {
 | 
			
		||||
            SettingsResourceRegistry.getInstance()
 | 
			
		||||
                    .getEntryByPreferencesResId(R.xml.update_settings)
 | 
			
		||||
                    .setSearchable(false);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,11 @@
 | 
			
		||||
package org.schabi.newpipe.settings;
 | 
			
		||||
 | 
			
		||||
import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService;
 | 
			
		||||
 | 
			
		||||
import android.os.Bundle;
 | 
			
		||||
import android.widget.Toast;
 | 
			
		||||
 | 
			
		||||
import androidx.preference.Preference;
 | 
			
		||||
 | 
			
		||||
import org.schabi.newpipe.NewVersionWorker;
 | 
			
		||||
import org.schabi.newpipe.R;
 | 
			
		||||
 | 
			
		||||
public class UpdateSettingsFragment extends BasePreferenceFragment {
 | 
			
		||||
@@ -33,7 +32,7 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
 | 
			
		||||
        // Reset the expire time. This is necessary to check for an update immediately.
 | 
			
		||||
        defaultPreferences.edit()
 | 
			
		||||
                .putLong(getString(R.string.update_expiry_key), 0).apply();
 | 
			
		||||
        startNewVersionCheckService();
 | 
			
		||||
        NewVersionWorker.enqueueNewVersionCheckingWork(getContext());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										116
									
								
								app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,116 @@
 | 
			
		||||
package org.schabi.newpipe.util
 | 
			
		||||
 | 
			
		||||
import android.content.pm.PackageManager
 | 
			
		||||
import android.content.pm.Signature
 | 
			
		||||
import androidx.core.content.pm.PackageInfoCompat
 | 
			
		||||
import org.schabi.newpipe.App
 | 
			
		||||
import org.schabi.newpipe.error.ErrorInfo
 | 
			
		||||
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
 | 
			
		||||
import org.schabi.newpipe.error.UserAction
 | 
			
		||||
import java.io.ByteArrayInputStream
 | 
			
		||||
import java.io.InputStream
 | 
			
		||||
import java.security.MessageDigest
 | 
			
		||||
import java.security.NoSuchAlgorithmException
 | 
			
		||||
import java.security.cert.CertificateEncodingException
 | 
			
		||||
import java.security.cert.CertificateException
 | 
			
		||||
import java.security.cert.CertificateFactory
 | 
			
		||||
import java.security.cert.X509Certificate
 | 
			
		||||
import java.time.Instant
 | 
			
		||||
import java.time.ZonedDateTime
 | 
			
		||||
import java.time.format.DateTimeFormatter
 | 
			
		||||
 | 
			
		||||
object ReleaseVersionUtil {
 | 
			
		||||
    // Public key of the certificate that is used in NewPipe release versions
 | 
			
		||||
    private const val RELEASE_CERT_PUBLIC_KEY_SHA1 =
 | 
			
		||||
        "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"
 | 
			
		||||
 | 
			
		||||
    @JvmStatic
 | 
			
		||||
    fun isReleaseApk(): Boolean {
 | 
			
		||||
        return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
 | 
			
		||||
     *
 | 
			
		||||
     * @return String with the APK's SHA1 fingerprint in hexadecimal
 | 
			
		||||
     */
 | 
			
		||||
    private val certificateSHA1Fingerprint: String
 | 
			
		||||
        get() {
 | 
			
		||||
            val app = App.getApp()
 | 
			
		||||
            val signatures: List<Signature> = try {
 | 
			
		||||
                PackageInfoCompat.getSignatures(app.packageManager, app.packageName)
 | 
			
		||||
            } catch (e: PackageManager.NameNotFoundException) {
 | 
			
		||||
                showRequestError(app, e, "Could not find package info")
 | 
			
		||||
                return ""
 | 
			
		||||
            }
 | 
			
		||||
            if (signatures.isEmpty()) {
 | 
			
		||||
                return ""
 | 
			
		||||
            }
 | 
			
		||||
            val x509cert = try {
 | 
			
		||||
                val cert = signatures[0].toByteArray()
 | 
			
		||||
                val input: InputStream = ByteArrayInputStream(cert)
 | 
			
		||||
                val cf = CertificateFactory.getInstance("X509")
 | 
			
		||||
                cf.generateCertificate(input) as X509Certificate
 | 
			
		||||
            } catch (e: CertificateException) {
 | 
			
		||||
                showRequestError(app, e, "Certificate error")
 | 
			
		||||
                return ""
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return try {
 | 
			
		||||
                val md = MessageDigest.getInstance("SHA1")
 | 
			
		||||
                val publicKey = md.digest(x509cert.encoded)
 | 
			
		||||
                byte2HexFormatted(publicKey)
 | 
			
		||||
            } catch (e: NoSuchAlgorithmException) {
 | 
			
		||||
                showRequestError(app, e, "Could not retrieve SHA1 key")
 | 
			
		||||
                ""
 | 
			
		||||
            } catch (e: CertificateEncodingException) {
 | 
			
		||||
                showRequestError(app, e, "Could not retrieve SHA1 key")
 | 
			
		||||
                ""
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    private fun byte2HexFormatted(arr: ByteArray): String {
 | 
			
		||||
        val str = StringBuilder(arr.size * 2)
 | 
			
		||||
        for (i in arr.indices) {
 | 
			
		||||
            var h = Integer.toHexString(arr[i].toInt())
 | 
			
		||||
            val l = h.length
 | 
			
		||||
            if (l == 1) {
 | 
			
		||||
                h = "0$h"
 | 
			
		||||
            }
 | 
			
		||||
            if (l > 2) {
 | 
			
		||||
                h = h.substring(l - 2, l)
 | 
			
		||||
            }
 | 
			
		||||
            str.append(h.uppercase())
 | 
			
		||||
            if (i < arr.size - 1) {
 | 
			
		||||
                str.append(':')
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return str.toString()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun showRequestError(app: App, e: Exception, request: String) {
 | 
			
		||||
        createNotification(
 | 
			
		||||
            app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request)
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fun isLastUpdateCheckExpired(expiry: Long): Boolean {
 | 
			
		||||
        return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Coerce expiry date time in between 6 hours and 72 hours from now
 | 
			
		||||
     *
 | 
			
		||||
     * @return Epoch second of expiry date time
 | 
			
		||||
     */
 | 
			
		||||
    fun coerceUpdateCheckExpiry(expiryString: String?): Long {
 | 
			
		||||
        val now = ZonedDateTime.now()
 | 
			
		||||
        return expiryString?.let {
 | 
			
		||||
            var expiry =
 | 
			
		||||
                ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
 | 
			
		||||
            expiry = maxOf(expiry, now.plusHours(6))
 | 
			
		||||
            expiry = minOf(expiry, now.plusHours(72))
 | 
			
		||||
            expiry.toEpochSecond()
 | 
			
		||||
        } ?: now.plusHours(6).toEpochSecond()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,8 +2,9 @@ package org.schabi.newpipe
 | 
			
		||||
 | 
			
		||||
import org.junit.Assert.assertFalse
 | 
			
		||||
import org.junit.Assert.assertTrue
 | 
			
		||||
import org.junit.Before
 | 
			
		||||
import org.junit.Test
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
 | 
			
		||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
 | 
			
		||||
import java.time.Instant
 | 
			
		||||
import java.time.ZoneId
 | 
			
		||||
import java.time.format.DateTimeFormatter
 | 
			
		||||
@@ -11,18 +12,11 @@ import kotlin.math.abs
 | 
			
		||||
 | 
			
		||||
class NewVersionManagerTest {
 | 
			
		||||
 | 
			
		||||
    private lateinit var manager: NewVersionManager
 | 
			
		||||
 | 
			
		||||
    @Before
 | 
			
		||||
    fun setup() {
 | 
			
		||||
        manager = NewVersionManager()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Test
 | 
			
		||||
    fun `Expiry is reached`() {
 | 
			
		||||
        val oneHourEarlier = Instant.now().atZone(ZoneId.of("GMT")).minusHours(1)
 | 
			
		||||
 | 
			
		||||
        val expired = manager.isExpired(oneHourEarlier.toEpochSecond())
 | 
			
		||||
        val expired = isLastUpdateCheckExpired(oneHourEarlier.toEpochSecond())
 | 
			
		||||
 | 
			
		||||
        assertTrue(expired)
 | 
			
		||||
    }
 | 
			
		||||
@@ -31,7 +25,7 @@ class NewVersionManagerTest {
 | 
			
		||||
    fun `Expiry is not reached`() {
 | 
			
		||||
        val oneHourLater = Instant.now().atZone(ZoneId.of("GMT")).plusHours(1)
 | 
			
		||||
 | 
			
		||||
        val expired = manager.isExpired(oneHourLater.toEpochSecond())
 | 
			
		||||
        val expired = isLastUpdateCheckExpired(oneHourLater.toEpochSecond())
 | 
			
		||||
 | 
			
		||||
        assertFalse(expired)
 | 
			
		||||
    }
 | 
			
		||||
@@ -47,7 +41,7 @@ class NewVersionManagerTest {
 | 
			
		||||
    fun `Expiry must be returned as is because it is inside the acceptable range of 6-72 hours`() {
 | 
			
		||||
        val sixHoursLater = Instant.now().atZone(ZoneId.of("GMT")).plusHours(6)
 | 
			
		||||
 | 
			
		||||
        val coerced = manager.coerceExpiry(DateTimeFormatter.RFC_1123_DATE_TIME.format(sixHoursLater))
 | 
			
		||||
        val coerced = coerceUpdateCheckExpiry(DateTimeFormatter.RFC_1123_DATE_TIME.format(sixHoursLater))
 | 
			
		||||
 | 
			
		||||
        assertNearlyEqual(sixHoursLater.toEpochSecond(), coerced)
 | 
			
		||||
    }
 | 
			
		||||
@@ -56,7 +50,7 @@ class NewVersionManagerTest {
 | 
			
		||||
    fun `Expiry must be increased to 6 hours if below`() {
 | 
			
		||||
        val tooLow = Instant.now().atZone(ZoneId.of("GMT")).plusHours(5)
 | 
			
		||||
 | 
			
		||||
        val coerced = manager.coerceExpiry(DateTimeFormatter.RFC_1123_DATE_TIME.format(tooLow))
 | 
			
		||||
        val coerced = coerceUpdateCheckExpiry(DateTimeFormatter.RFC_1123_DATE_TIME.format(tooLow))
 | 
			
		||||
 | 
			
		||||
        assertNearlyEqual(tooLow.plusHours(1).toEpochSecond(), coerced)
 | 
			
		||||
    }
 | 
			
		||||
@@ -65,7 +59,7 @@ class NewVersionManagerTest {
 | 
			
		||||
    fun `Expiry must be decreased to 72 hours if above`() {
 | 
			
		||||
        val tooHigh = Instant.now().atZone(ZoneId.of("GMT")).plusHours(73)
 | 
			
		||||
 | 
			
		||||
        val coerced = manager.coerceExpiry(DateTimeFormatter.RFC_1123_DATE_TIME.format(tooHigh))
 | 
			
		||||
        val coerced = coerceUpdateCheckExpiry(DateTimeFormatter.RFC_1123_DATE_TIME.format(tooHigh))
 | 
			
		||||
 | 
			
		||||
        assertNearlyEqual(tooHigh.minusHours(1).toEpochSecond(), coerced)
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user