/* * Copyright (C) 2017-2023 The LineageOS Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.lineageos.updater; import android.annotation.SuppressLint; import android.app.Activity; import android.app.UiModeManager; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.res.Configuration; import android.content.ServiceConnection; import android.content.SharedPreferences; import android.icu.text.DateFormat; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.os.SystemProperties; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.animation.Animation; import android.view.animation.LinearInterpolator; import android.view.animation.RotateAnimation; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SwitchCompat; import androidx.appcompat.widget.Toolbar; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SimpleItemAnimator; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.CollapsingToolbarLayout; import com.google.android.material.snackbar.Snackbar; import org.json.JSONException; import org.lineageos.updater.controller.UpdaterController; import org.lineageos.updater.controller.UpdaterService; import org.lineageos.updater.download.DownloadClient; import org.lineageos.updater.misc.BuildInfoUtils; import org.lineageos.updater.misc.Constants; import org.lineageos.updater.misc.StringGenerator; import org.lineageos.updater.misc.Utils; import org.lineageos.updater.model.UpdateInfo; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.UUID; public class UpdatesActivity extends UpdatesListActivity { private static final String TAG = "UpdatesActivity"; private UpdaterService mUpdaterService; private BroadcastReceiver mBroadcastReceiver; private UpdatesListAdapter mAdapter; private View mRefreshIconView; private RotateAnimation mRefreshAnimation; private boolean mIsTV; private UpdateInfo mToBeExported = null; private final ActivityResultLauncher mExportUpdate = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == Activity.RESULT_OK) { Intent intent = result.getData(); if (intent != null) { Uri uri = intent.getData(); exportUpdate(uri); } } }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_updates); UiModeManager uiModeManager = getSystemService(UiModeManager.class); mIsTV = uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; RecyclerView recyclerView = findViewById(R.id.recycler_view); mAdapter = new UpdatesListAdapter(this); recyclerView.setAdapter(mAdapter); RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(this); recyclerView.setLayoutManager(layoutManager); RecyclerView.ItemAnimator animator = recyclerView.getItemAnimator(); if (animator instanceof SimpleItemAnimator) { ((SimpleItemAnimator) animator).setSupportsChangeAnimations(false); } mBroadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (UpdaterController.ACTION_UPDATE_STATUS.equals(intent.getAction())) { String downloadId = intent.getStringExtra(UpdaterController.EXTRA_DOWNLOAD_ID); handleDownloadStatusChange(downloadId); mAdapter.notifyItemChanged(downloadId); } else if (UpdaterController.ACTION_DOWNLOAD_PROGRESS.equals(intent.getAction()) || UpdaterController.ACTION_INSTALL_PROGRESS.equals(intent.getAction())) { String downloadId = intent.getStringExtra(UpdaterController.EXTRA_DOWNLOAD_ID); mAdapter.notifyItemChanged(downloadId); } else if (UpdaterController.ACTION_UPDATE_REMOVED.equals(intent.getAction())) { String downloadId = intent.getStringExtra(UpdaterController.EXTRA_DOWNLOAD_ID); mAdapter.removeItem(downloadId); } } }; if (!mIsTV) { Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayShowTitleEnabled(false); actionBar.setDisplayHomeAsUpEnabled(true); } } TextView headerTitle = findViewById(R.id.header_title); headerTitle.setText(getString(R.string.header_title_text, BuildInfoUtils.getBuildVersion())); updateLastCheckedString(); TextView headerBuildVersion = findViewById(R.id.header_build_version); headerBuildVersion.setText( getString(R.string.header_android_version, Build.VERSION.RELEASE)); TextView headerBuildDate = findViewById(R.id.header_build_date); headerBuildDate.setText(StringGenerator.getDateLocalizedUTC(this, DateFormat.LONG, BuildInfoUtils.getBuildDateTimestamp())); if (!mIsTV) { // Switch between header title and appbar title minimizing overlaps final CollapsingToolbarLayout collapsingToolbar = findViewById(R.id.collapsing_toolbar); final AppBarLayout appBar = findViewById(R.id.app_bar); appBar.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() { boolean mIsShown = false; @Override public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) { int scrollRange = appBarLayout.getTotalScrollRange(); if (!mIsShown && scrollRange + verticalOffset < 10) { collapsingToolbar.setTitle(getString(R.string.display_name)); mIsShown = true; } else if (mIsShown && scrollRange + verticalOffset > 100) { collapsingToolbar.setTitle(null); mIsShown = false; } } }); mRefreshAnimation = new RotateAnimation(0, 360, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); mRefreshAnimation.setInterpolator(new LinearInterpolator()); mRefreshAnimation.setDuration(1000); if (!Utils.hasTouchscreen(this)) { // This can't be collapsed without a touchscreen appBar.setExpanded(false); } } else { findViewById(R.id.refresh).setOnClickListener(v -> downloadUpdatesList(true)); findViewById(R.id.preferences).setOnClickListener(v -> showPreferencesDialog()); } } @Override public void onStart() { super.onStart(); Intent intent = new Intent(this, UpdaterService.class); startService(intent); bindService(intent, mConnection, Context.BIND_AUTO_CREATE); IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(UpdaterController.ACTION_UPDATE_STATUS); intentFilter.addAction(UpdaterController.ACTION_DOWNLOAD_PROGRESS); intentFilter.addAction(UpdaterController.ACTION_INSTALL_PROGRESS); intentFilter.addAction(UpdaterController.ACTION_UPDATE_REMOVED); LocalBroadcastManager.getInstance(this).registerReceiver(mBroadcastReceiver, intentFilter); } @Override public void onStop() { LocalBroadcastManager.getInstance(this).unregisterReceiver(mBroadcastReceiver); if (mUpdaterService != null) { unbindService(mConnection); } super.onStop(); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_toolbar, menu); return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { int itemId = item.getItemId(); if (itemId == R.id.menu_refresh) { downloadUpdatesList(true); return true; } else if (itemId == R.id.menu_preferences) { showPreferencesDialog(); return true; } else if (itemId == R.id.menu_show_changelog) { Intent openUrl = new Intent(Intent.ACTION_VIEW, Uri.parse(Utils.getChangelogURL(this))); startActivity(openUrl); return true; } return super.onOptionsItemSelected(item); } @Override public boolean onSupportNavigateUp() { onBackPressed(); return true; } private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName className, IBinder service) { UpdaterService.LocalBinder binder = (UpdaterService.LocalBinder) service; mUpdaterService = binder.getService(); mAdapter.setUpdaterController(mUpdaterService.getUpdaterController()); getUpdatesList(); } @Override public void onServiceDisconnected(ComponentName componentName) { mAdapter.setUpdaterController(null); mUpdaterService = null; mAdapter.notifyDataSetChanged(); } }; private void loadUpdatesList(File jsonFile, boolean manualRefresh) throws IOException, JSONException { Log.d(TAG, "Adding remote updates"); UpdaterController controller = mUpdaterService.getUpdaterController(); boolean newUpdates = false; List updates = Utils.parseJson(jsonFile, true); List updatesOnline = new ArrayList<>(); for (UpdateInfo update : updates) { newUpdates |= controller.addUpdate(update); updatesOnline.add(update.getDownloadId()); } controller.setUpdatesAvailableOnline(updatesOnline, true); if (manualRefresh) { showSnackbar( newUpdates ? R.string.snack_updates_found : R.string.snack_no_updates_found, Snackbar.LENGTH_SHORT); } List updateIds = new ArrayList<>(); List sortedUpdates = controller.getUpdates(); if (sortedUpdates.isEmpty()) { findViewById(R.id.no_new_updates_view).setVisibility(View.VISIBLE); findViewById(R.id.recycler_view).setVisibility(View.GONE); } else { findViewById(R.id.no_new_updates_view).setVisibility(View.GONE); findViewById(R.id.recycler_view).setVisibility(View.VISIBLE); sortedUpdates.sort((u1, u2) -> Long.compare(u2.getTimestamp(), u1.getTimestamp())); for (UpdateInfo update : sortedUpdates) { updateIds.add(update.getDownloadId()); } mAdapter.setData(updateIds); mAdapter.notifyDataSetChanged(); } } private void getUpdatesList() { File jsonFile = Utils.getCachedUpdateList(this); if (jsonFile.exists()) { try { loadUpdatesList(jsonFile, false); Log.d(TAG, "Cached list parsed"); } catch (IOException | JSONException e) { Log.e(TAG, "Error while parsing json list", e); } } else { downloadUpdatesList(false); } } private void processNewJson(File json, File jsonNew, boolean manualRefresh) { try { loadUpdatesList(jsonNew, manualRefresh); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); long millis = System.currentTimeMillis(); preferences.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, millis).apply(); updateLastCheckedString(); if (json.exists() && Utils.isUpdateCheckEnabled(this) && Utils.checkForNewUpdates(json, jsonNew)) { UpdatesCheckReceiver.updateRepeatingUpdatesCheck(this); } // In case we set a one-shot check because of a previous failure UpdatesCheckReceiver.cancelUpdatesCheck(this); //noinspection ResultOfMethodCallIgnored jsonNew.renameTo(json); } catch (IOException | JSONException e) { Log.e(TAG, "Could not read json", e); showSnackbar(R.string.snack_updates_check_failed, Snackbar.LENGTH_LONG); } } private void downloadUpdatesList(final boolean manualRefresh) { final File jsonFile = Utils.getCachedUpdateList(this); final File jsonFileTmp = new File(jsonFile.getAbsolutePath() + UUID.randomUUID()); String url = Utils.getServerURL(this); Log.d(TAG, "Checking " + url); DownloadClient.DownloadCallback callback = new DownloadClient.DownloadCallback() { @Override public void onFailure(final boolean cancelled) { Log.e(TAG, "Could not download updates list"); runOnUiThread(() -> { if (!cancelled) { showSnackbar(R.string.snack_updates_check_failed, Snackbar.LENGTH_LONG); } refreshAnimationStop(); }); } @Override public void onResponse(DownloadClient.Headers headers) { } @Override public void onSuccess() { runOnUiThread(() -> { Log.d(TAG, "List downloaded"); processNewJson(jsonFile, jsonFileTmp, manualRefresh); refreshAnimationStop(); }); } }; final DownloadClient downloadClient; try { downloadClient = new DownloadClient.Builder() .setUrl(url) .setDestination(jsonFileTmp) .setDownloadCallback(callback) .build(); } catch (IOException exception) { Log.e(TAG, "Could not build download client"); showSnackbar(R.string.snack_updates_check_failed, Snackbar.LENGTH_LONG); return; } refreshAnimationStart(); downloadClient.start(); } private void updateLastCheckedString() { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); long lastCheck = preferences.getLong(Constants.PREF_LAST_UPDATE_CHECK, -1) / 1000; String lastCheckString = getString(R.string.header_last_updates_check, StringGenerator.getDateLocalized(this, DateFormat.LONG, lastCheck), StringGenerator.getTimeLocalized(this, lastCheck)); TextView headerLastCheck = findViewById(R.id.header_last_check); headerLastCheck.setText(lastCheckString); } private void handleDownloadStatusChange(String downloadId) { UpdateInfo update = mUpdaterService.getUpdaterController().getUpdate(downloadId); switch (update.getStatus()) { case PAUSED_ERROR: showSnackbar(R.string.snack_download_failed, Snackbar.LENGTH_LONG); break; case VERIFICATION_FAILED: showSnackbar(R.string.snack_download_verification_failed, Snackbar.LENGTH_LONG); break; case VERIFIED: showSnackbar(R.string.snack_download_verified, Snackbar.LENGTH_LONG); break; } } @Override public void exportUpdate(UpdateInfo update) { mToBeExported = update; Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/zip"); intent.putExtra(Intent.EXTRA_TITLE, update.getName()); mExportUpdate.launch(intent); } private void exportUpdate(Uri uri) { Intent intent = new Intent(this, ExportUpdateService.class); intent.setAction(ExportUpdateService.ACTION_START_EXPORTING); intent.putExtra(ExportUpdateService.EXTRA_SOURCE_FILE, mToBeExported.getFile()); intent.putExtra(ExportUpdateService.EXTRA_DEST_URI, uri); startService(intent); } @Override public void showSnackbar(int stringId, int duration) { Snackbar.make(findViewById(R.id.main_container), stringId, duration).show(); } private void refreshAnimationStart() { if (!mIsTV) { if (mRefreshIconView == null) { mRefreshIconView = findViewById(R.id.menu_refresh); } if (mRefreshIconView != null) { mRefreshAnimation.setRepeatCount(Animation.INFINITE); mRefreshIconView.startAnimation(mRefreshAnimation); mRefreshIconView.setEnabled(false); } } else { findViewById(R.id.recycler_view).setVisibility(View.GONE); findViewById(R.id.no_new_updates_view).setVisibility(View.GONE); findViewById(R.id.refresh_progress).setVisibility(View.VISIBLE); } } private void refreshAnimationStop() { if (!mIsTV) { if (mRefreshIconView != null) { mRefreshAnimation.setRepeatCount(0); mRefreshIconView.setEnabled(true); } } else { findViewById(R.id.refresh_progress).setVisibility(View.GONE); if (mAdapter.getItemCount() > 0) { findViewById(R.id.recycler_view).setVisibility(View.VISIBLE); } else { findViewById(R.id.no_new_updates_view).setVisibility(View.VISIBLE); } } } @SuppressLint("ClickableViewAccessibility") private void showPreferencesDialog() { View view = LayoutInflater.from(this).inflate(R.layout.preferences_dialog, null); Spinner autoCheckInterval = view.findViewById(R.id.preferences_auto_updates_check_interval); SwitchCompat autoDelete = view.findViewById(R.id.preferences_auto_delete_updates); SwitchCompat meteredNetworkWarning = view.findViewById( R.id.preferences_metered_network_warning); SwitchCompat abPerfMode = view.findViewById(R.id.preferences_ab_perf_mode); SwitchCompat updateRecovery = view.findViewById(R.id.preferences_update_recovery); if (!Utils.isABDevice()) { abPerfMode.setVisibility(View.GONE); } SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); autoCheckInterval.setSelection(Utils.getUpdateCheckSetting(this)); autoDelete.setChecked(prefs.getBoolean(Constants.PREF_AUTO_DELETE_UPDATES, false)); meteredNetworkWarning.setChecked(prefs.getBoolean(Constants.PREF_METERED_NETWORK_WARNING, prefs.getBoolean(Constants.PREF_MOBILE_DATA_WARNING, true))); abPerfMode.setChecked(prefs.getBoolean(Constants.PREF_AB_PERF_MODE, false)); if (getResources().getBoolean(R.bool.config_hideRecoveryUpdate)) { // Hide the update feature if explicitly requested. // Might be the case of A-only devices using prebuilt vendor images. updateRecovery.setVisibility(View.GONE); } else if (Utils.isRecoveryUpdateExecPresent()) { updateRecovery.setChecked( SystemProperties.getBoolean(Constants.UPDATE_RECOVERY_PROPERTY, false)); } else { // There is no recovery updater script in the device, so the feature is considered // forcefully enabled, just to avoid users to be confused and complain that // recovery gets overwritten. That's the case of A/B and recovery-in-boot devices. updateRecovery.setChecked(true); updateRecovery.setOnTouchListener(new View.OnTouchListener() { private Toast forcedUpdateToast = null; @Override public boolean onTouch(View v, MotionEvent event) { if (forcedUpdateToast != null) { forcedUpdateToast.cancel(); } forcedUpdateToast = Toast.makeText(getApplicationContext(), getString(R.string.toast_forced_update_recovery), Toast.LENGTH_SHORT); forcedUpdateToast.show(); return true; } }); } new AlertDialog.Builder(this) .setTitle(R.string.menu_preferences) .setView(view) .setOnDismissListener(dialogInterface -> { prefs.edit() .putInt(Constants.PREF_AUTO_UPDATES_CHECK_INTERVAL, autoCheckInterval.getSelectedItemPosition()) .putBoolean(Constants.PREF_AUTO_DELETE_UPDATES, autoDelete.isChecked()) .putBoolean(Constants.PREF_METERED_NETWORK_WARNING, meteredNetworkWarning.isChecked()) .putBoolean(Constants.PREF_AB_PERF_MODE, abPerfMode.isChecked()) .apply(); if (Utils.isUpdateCheckEnabled(this)) { UpdatesCheckReceiver.scheduleRepeatingUpdatesCheck(this); } else { UpdatesCheckReceiver.cancelRepeatingUpdatesCheck(this); UpdatesCheckReceiver.cancelUpdatesCheck(this); } if (Utils.isABDevice()) { boolean enableABPerfMode = abPerfMode.isChecked(); mUpdaterService.getUpdaterController().setPerformanceMode(enableABPerfMode); } if (Utils.isRecoveryUpdateExecPresent()) { boolean enableRecoveryUpdate = updateRecovery.isChecked(); SystemProperties.set(Constants.UPDATE_RECOVERY_PROPERTY, String.valueOf(enableRecoveryUpdate)); } }) .show(); } }