diff --git a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt index e94d47456..cbafe6728 100644 --- a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt +++ b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt @@ -60,11 +60,13 @@ object ComposeUtils { onClick: () -> Unit, modifier: Modifier = Modifier, imageVector: ImageVector? = null, + color: Color = MaterialTheme.colors.primary, ) { OutlinedButton( onClick = onClick, shape = RoundedCornerShape(32.dp), - modifier = modifier.heightIn(min = ButtonDefaults.MinHeight) + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight), + colors = ButtonDefaults.outlinedButtonColors(contentColor = color), ) { if (imageVector != null) { Icon( diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java index 7889d647c..4f3ce9393 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java @@ -187,7 +187,7 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte @Override public void onClicked(Repository repo) { - RepoDetailsActivity.launch(this, repo.getRepoId()); + RepoDetailsActivity.Companion.launch(this, repo.getRepoId()); } /** diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java deleted file mode 100644 index 9ec01862e..000000000 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java +++ /dev/null @@ -1,536 +0,0 @@ -package org.fdroid.fdroid.views.repos; - -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.nfc.NdefMessage; -import android.nfc.NfcAdapter; -import android.os.Bundle; -import android.os.Parcelable; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.CompoundButton; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.NavUtils; -import androidx.core.content.ContextCompat; -import androidx.lifecycle.ViewModelProvider; -import androidx.lifecycle.ViewModelStoreOwner; -import androidx.lifecycle.viewmodel.MutableCreationExtras; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.appbar.MaterialToolbar; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.materialswitch.MaterialSwitch; -import com.google.android.material.textfield.TextInputLayout; - -import org.fdroid.database.Repository; -import org.fdroid.download.Mirror; -import org.fdroid.fdroid.FDroidApp; -import org.fdroid.fdroid.R; -import org.fdroid.fdroid.Utils; -import org.fdroid.fdroid.compat.LocaleCompat; -import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.views.apps.AppListActivity; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; - -public class RepoDetailsActivity extends AppCompatActivity { - private static final String TAG = "RepoDetailsActivity"; - - static final String ARG_REPO_ID = "repo_id"; - - static void launch(Context context, long repoId) { - Intent intent = new Intent(context, RepoDetailsActivity.class); - intent.putExtra(ARG_REPO_ID, repoId); - context.startActivity(intent); - } - - /** - * If the repo has been updated at least once, then we will show - * all of this info, otherwise they will be hidden. - */ - private static final int[] SHOW_IF_EXISTS = { - R.id.label_repo_name, - R.id.text_repo_name, - R.id.label_description, - R.id.text_description, - R.id.label_num_apps, - R.id.text_num_apps, - R.id.button_view_apps, - R.id.label_last_update, - R.id.text_last_update, - R.id.label_last_update_downloaded, - R.id.text_last_update_downloaded, - R.id.label_username, - R.id.text_username, - R.id.button_edit_credentials, - R.id.label_repo_fingerprint, - R.id.text_repo_fingerprint, - R.id.text_repo_fingerprint_description, - }; - /** - * If the repo has not been updated yet, then we only show - * these, otherwise they are hidden. - */ - private static final int[] HIDE_IF_EXISTS = { - R.id.text_not_yet_updated, - }; - private Repository repo; - private long repoId; - private View repoView; - - private MirrorAdapter adapterToNotify; - - private RepoDetailsViewModel model; - - /** - * Help function to make switching between two view states easier. - * Perhaps there is a better way to do this. I recall that using Adobe - * Flex, there was a thing called "ViewStates" for exactly this. Wonder if - * that exists in Android? - */ - private static void setMultipleViewVisibility(View parent, int[] viewIds, int visibility) { - for (int viewId : viewIds) { - parent.findViewById(viewId).setVisibility(visibility); - } - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - FDroidApp fdroidApp = (FDroidApp) getApplication(); - fdroidApp.setSecureWindow(this); - fdroidApp.applyPureBlackBackgroundInDarkTheme(this); - - repoId = getIntent().getLongExtra(ARG_REPO_ID, 0); - repo = FDroidApp.getRepoManager(this).getRepository(repoId); - if (repo == null) { - // repo must have been deleted just now (maybe slow UI?) - finish(); - return; - } - - ViewModelStoreOwner owner = this; - ViewModelProvider.Factory factory = RepoDetailsViewModel.Companion.getFactory(); - MutableCreationExtras extras = new MutableCreationExtras(); - extras.set(RepoDetailsViewModel.Companion.getAPP_KEY(), getApplication()); - extras.set(RepoDetailsViewModel.Companion.getREPO_KEY(), repo); - model = ViewModelProvider.create(owner, factory, extras).get(RepoDetailsViewModel.class); - - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_repo_details); - - MaterialToolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - repoView = findViewById(R.id.repo_view); - - TextView inputUrl = findViewById(R.id.input_repo_url); - inputUrl.setText(repo.getAddress()); - - RecyclerView officialMirrorListView = findViewById(R.id.official_mirror_list); - officialMirrorListView.setLayoutManager(new LinearLayoutManager(this)); - adapterToNotify = new MirrorAdapter(repo, repo.getAllMirrors(false)); - officialMirrorListView.setAdapter(adapterToNotify); - - RecyclerView userMirrorListView = findViewById(R.id.user_mirror_list); - userMirrorListView.setLayoutManager(new LinearLayoutManager(this)); - MirrorAdapter userMirrorAdapter = new MirrorAdapter(repo, repo.getUserMirrors().size()); - userMirrorAdapter.setUserMirrors(repo.getUserMirrors()); - userMirrorListView.setAdapter(userMirrorAdapter); - - // update UI when repo in DB changes - model.getRepoLiveData().observe(this, repo -> { - if (repo == null) { - // repo was deleted, close repo details - finish(); - return; - } - this.repo = repo; - updateRepoView(); - }); - - TextView numApps = repoView.findViewById(R.id.text_num_apps); - model.getNumberOfAppsLiveData().observe(this, number -> { - String countStr = String.format(LocaleCompat.getDefault(), "%d", number); - numApps.setText(countStr); - }); - - MaterialSwitch archiveRepoSwitch = findViewById(R.id.archiveRepo); - model.getLiveData().observe(this, s -> { - switch (s.getArchiveState()) { - case ENABLED: - archiveRepoSwitch.setEnabled(true); - archiveRepoSwitch.setChecked(true); - break; - case DISABLED: - archiveRepoSwitch.setEnabled(true); - archiveRepoSwitch.setChecked(false); - break; - case UNKNOWN: - archiveRepoSwitch.setEnabled(false); - break; - } - }); - archiveRepoSwitch.setOnClickListener(v -> model.setArchiveRepoEnabled(archiveRepoSwitch.isChecked())); - - ImageView qrCode = findViewById(R.id.qr_code); - model.getQrCodeLiveData().observe(this, bitmap -> { - if (qrCode != null) { - qrCode.setImageBitmap(bitmap); - } - }); - model.generateQrCode(this); - } - - @Override - public void onResume() { - super.onResume(); - - /* - * After, for example, a repo update, the details will have changed in the - * database. However, or local reference to the Repo object will not - * have been updated. The safest way to deal with this is to reload the - * repo object directly from the database. - */ - repo = FDroidApp.getRepoManager(this).getRepository(repoId); - updateRepoView(); - - processIntent(getIntent()); - } - - @Override - public void onNewIntent(Intent i) { - super.onNewIntent(i); - // onResume gets called after this to handle the intent - setIntent(i); - } - - private void processIntent(Intent i) { - if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(i.getAction())) { - Parcelable[] rawMsgs = i.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES); - NdefMessage msg = (NdefMessage) rawMsgs[0]; - String url = new String(msg.getRecords()[0].getPayload()); - Utils.debugLog(TAG, "Got this URL: " + url); - Toast.makeText(this, "Got this URL: " + url, Toast.LENGTH_LONG).show(); - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - intent.setClass(this, ManageReposActivity.class); - startActivity(intent); - finish(); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.repo_details_activity, menu); - return true; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - Intent intent; - int itemId = item.getItemId(); - if (itemId == android.R.id.home) { - NavUtils.navigateUpFromSameTask(this); - return true; - } else if (itemId == R.id.menu_delete) { - promptForDelete(); - return true; - } else if (itemId == R.id.action_share) { - intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_TEXT, repo.getShareUri()); - startActivity(Intent.createChooser(intent, - getResources().getString(R.string.share_repository))); - } - - return super.onOptionsItemSelected(item); - } - - private void setupDescription(View parent, Repository repo) { - - TextView descriptionLabel = parent.findViewById(R.id.label_description); - TextView description = parent.findViewById(R.id.text_description); - - String desc = repo.getDescription(App.getLocales()); - if (desc == null || TextUtils.isEmpty(desc)) { - descriptionLabel.setVisibility(View.GONE); - description.setVisibility(View.GONE); - description.setText(""); - } else { - descriptionLabel.setVisibility(View.VISIBLE); - description.setVisibility(View.VISIBLE); - description.setText(desc.replaceAll("\n", " ")); - } - } - - private void setupRepoFingerprint(View parent, Repository repo) { - TextView repoFingerprintView = parent.findViewById(R.id.text_repo_fingerprint); - TextView repoFingerprintDescView = parent.findViewById(R.id.text_repo_fingerprint_description); - - String repoFingerprint; - - // TODO show the current state of the signature check, not just whether there is a key or not - if (TextUtils.isEmpty(repo.getCertificate())) { - repoFingerprint = getResources().getString(R.string.unsigned); - repoFingerprintView.setTextColor(ContextCompat.getColor(this, R.color.unsigned)); - repoFingerprintDescView.setVisibility(View.VISIBLE); - repoFingerprintDescView.setText(getResources().getString(R.string.unsigned_description)); - } else { - // this is based on repo.fingerprint always existing, which it should - repoFingerprint = Utils.formatFingerprint(this, repo.getFingerprint()); - repoFingerprintDescView.setVisibility(View.GONE); - } - - repoFingerprintView.setText(repoFingerprint); - } - - private void setupCredentials(View parent, Repository repo) { - - TextView usernameLabel = parent.findViewById(R.id.label_username); - TextView username = parent.findViewById(R.id.text_username); - Button changePassword = parent.findViewById(R.id.button_edit_credentials); - changePassword.setOnClickListener(this::showChangePasswordDialog); - - if (TextUtils.isEmpty(repo.getUsername())) { - usernameLabel.setVisibility(View.GONE); - username.setVisibility(View.GONE); - username.setText(""); - changePassword.setVisibility(View.GONE); - } else { - usernameLabel.setVisibility(View.VISIBLE); - username.setVisibility(View.VISIBLE); - username.setText(repo.getUsername()); - changePassword.setVisibility(View.VISIBLE); - } - } - - private void updateRepoView() { - TextView officialMirrorsLabel = repoView.findViewById(R.id.label_official_mirrors); - RecyclerView officialMirrorList = repoView.findViewById(R.id.official_mirror_list); - if (repo.getAllMirrors().size() > 1) { - // don't show this if there is only the canonical URL available, and no other mirrors - officialMirrorsLabel.setVisibility(View.VISIBLE); - officialMirrorList.setVisibility(View.VISIBLE); - } else { - officialMirrorsLabel.setVisibility(View.GONE); - officialMirrorList.setVisibility(View.GONE); - } - - TextView userMirrorsLabel = repoView.findViewById(R.id.label_user_mirrors); - RecyclerView userMirrorList = repoView.findViewById(R.id.user_mirror_list); - if (repo.getUserMirrors().size() > 0) { - userMirrorsLabel.setVisibility(View.VISIBLE); - userMirrorList.setVisibility(View.VISIBLE); - } else { - userMirrorsLabel.setVisibility(View.GONE); - userMirrorList.setVisibility(View.GONE); - } - - if (repo.getLastUpdated() != null) { - updateViewForExistingRepo(repoView); - } else { - setMultipleViewVisibility(repoView, HIDE_IF_EXISTS, View.VISIBLE); - setMultipleViewVisibility(repoView, SHOW_IF_EXISTS, View.GONE); - } - } - - private void updateViewForExistingRepo(View repoView) { - setMultipleViewVisibility(repoView, SHOW_IF_EXISTS, View.VISIBLE); - setMultipleViewVisibility(repoView, HIDE_IF_EXISTS, View.GONE); - - TextView name = repoView.findViewById(R.id.text_repo_name); - TextView numAppsButton = repoView.findViewById(R.id.button_view_apps); - TextView lastUpdated = repoView.findViewById(R.id.text_last_update); - TextView lastDownloaded = repoView.findViewById(R.id.text_last_update_downloaded); - - name.setText(repo.getName(App.getLocales())); - if (repo.getEnabled()) { - numAppsButton.setOnClickListener(view -> { - Intent i = new Intent(this, AppListActivity.class); - i.putExtra(AppListActivity.EXTRA_REPO_ID, repo.getRepoId()); - startActivity(i); - }); - numAppsButton.setVisibility(View.VISIBLE); - } else { - numAppsButton.setVisibility(View.GONE); - } - - setupDescription(repoView, repo); - setupRepoFingerprint(repoView, repo); - setupCredentials(repoView, repo); - - if (repo.getTimestamp() == -1) { - lastUpdated.setText(R.string.unknown); - } else { - int format = DateUtils.isToday(repo.getTimestamp()) ? - DateUtils.FORMAT_SHOW_TIME : - DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE; - lastUpdated.setText(DateUtils.formatDateTime(this, repo.getTimestamp(), format)); - } - if (repo.getLastUpdated() == null) { - lastDownloaded.setText(R.string.unknown); - } else { - int format = DateUtils.isToday(repo.getLastUpdated()) ? - DateUtils.FORMAT_SHOW_TIME : - DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE; - lastDownloaded.setText(DateUtils.formatDateTime(this, repo.getLastUpdated(), format)); - } - } - - private void promptForDelete() { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.repo_confirm_delete_title) - .setMessage(R.string.repo_confirm_delete_body) - .setPositiveButton(R.string.delete, (dialog, which) -> { - model.deleteRepository(); - finish(); - }).setNegativeButton(android.R.string.cancel, (dialog, which) -> { - // Do nothing... - } - ).show(); - } - - private void showChangePasswordDialog(final View parentView) { - final View view = getLayoutInflater().inflate(R.layout.login, (ViewGroup) parentView, false); - final AlertDialog credentialsDialog = new MaterialAlertDialogBuilder(this).setView(view).create(); - final TextInputLayout nameInputLayout = view.findViewById(R.id.edit_name); - final TextInputLayout passwordInputLayout = view.findViewById(R.id.edit_password); - final EditText nameInput = nameInputLayout.getEditText(); - final EditText passwordInput = passwordInputLayout.getEditText(); - - nameInput.setText(repo.getUsername()); - passwordInput.requestFocus(); - - credentialsDialog.setTitle(R.string.repo_edit_credentials); - credentialsDialog.setButton(DialogInterface.BUTTON_NEGATIVE, - getString(R.string.cancel), (dialog, which) -> dialog.dismiss()); - - credentialsDialog.setButton(DialogInterface.BUTTON_POSITIVE, - getString(R.string.ok), (dialog, which) -> { - - final String name = nameInput.getText().toString(); - final String password = passwordInput.getText().toString(); - - if (!TextUtils.isEmpty(name)) { - model.updateUsernameAndPassword(name, password); - updateRepoView(); - dialog.dismiss(); - } else { - Toast.makeText(RepoDetailsActivity.this, R.string.repo_error_empty_username, - Toast.LENGTH_LONG).show(); - } - }); - - credentialsDialog.show(); - } - - private class MirrorAdapter extends RecyclerView.Adapter { - private final Repository repo; - private final List mirrors; - private final HashSet disabledMirrors; - - class MirrorViewHolder extends RecyclerView.ViewHolder { - View view; - - MirrorViewHolder(View view) { - super(view); - this.view = view; - } - } - - MirrorAdapter(Repository repo, List mirrors) { - this.repo = repo; - this.mirrors = mirrors; - disabledMirrors = new HashSet<>(repo.getDisabledMirrors()); - } - - MirrorAdapter(Repository repo, int userMirrorSize) { - this.repo = repo; - this.mirrors = new ArrayList<>(userMirrorSize); - disabledMirrors = new HashSet<>(repo.getDisabledMirrors()); - } - - void setUserMirrors(List userMirrors) { - for (String url : userMirrors) { - this.mirrors.add(new Mirror(url)); - } - } - - @NonNull - @Override - public MirrorAdapter.MirrorViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.repo_item, parent, false); - return new MirrorViewHolder(itemView); - } - - @Override - public void onBindViewHolder(@NonNull MirrorViewHolder holder, final int position) { - TextView repoNameTextView = holder.view.findViewById(R.id.repo_name); - Mirror mirror = mirrors.get(position); - repoNameTextView.setText(mirror.getBaseUrl()); - - final String itemMirror = mirror.getBaseUrl(); - boolean enabled = true; - for (String disabled : disabledMirrors) { - if (TextUtils.equals(itemMirror, disabled)) { - enabled = false; - break; - } - } - CompoundButton switchView = holder.view.findViewById(R.id.repo_switch); - // reset recycled CheckedChangeListener before checking to avoid bugs - switchView.setOnCheckedChangeListener(null); - switchView.setChecked(enabled); - switchView.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (isChecked) { - disabledMirrors.remove(itemMirror); - } else { - disabledMirrors.add(itemMirror); - } - - List mirrors = repo.getAllMirrors(true); - int totalMirrors = mirrors.size(); - if (disabledMirrors.size() == totalMirrors) { - // if all mirrors are disabled, re-enable canonical repo as mirror - disabledMirrors.remove(repo.getAddress()); - adapterToNotify.notifyDataSetChanged(); - } - ArrayList toDisableMirrors = new ArrayList<>(disabledMirrors); - model.updateDisabledMirrors(toDisableMirrors); - }); - - View repoUnverified = holder.view.findViewById(R.id.repo_unverified); - repoUnverified.setVisibility(View.GONE); - - View repoUnsigned = holder.view.findViewById(R.id.repo_unsigned); - repoUnsigned.setVisibility(View.GONE); - } - - @Override - public int getItemCount() { - if (mirrors == null) { - return 0; - } - return mirrors.size(); - } - } -} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.kt new file mode 100644 index 000000000..155a29d11 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.kt @@ -0,0 +1,203 @@ +package org.fdroid.fdroid.views.repos + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.ImageView +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.MutableCreationExtras +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputLayout +import org.fdroid.download.Mirror +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.views.apps.AppListActivity + +class RepoDetailsActivity : AppCompatActivity() { + + companion object { + private const val TAG = "RepoDetailsActivity" + const val ARG_REPO_ID = "repo_id" + + fun launch(context: Context, repoId: Long) { + val intent = Intent(context, RepoDetailsActivity::class.java).apply { + putExtra(ARG_REPO_ID, repoId) + } + context.startActivity(intent) + } + } + + private lateinit var viewModel: RepoDetailsViewModel + + // Only call this once in onCreate() + private fun initViewModel() { + val repoId = intent.getLongExtra(ARG_REPO_ID, 0) + val repo = FDroidApp.getRepoManager(this).getRepository(repoId) + if (repo == null) { + // repo must have been deleted just now (maybe slow UI?) + finish() + return + } + + val factory = RepoDetailsViewModel.Factory + val extras = MutableCreationExtras().apply { + set(RepoDetailsViewModel.APP_KEY, application) + set(RepoDetailsViewModel.Companion.REPO_KEY, repo) + } + viewModel = + ViewModelProvider.create(this, factory, extras)[RepoDetailsViewModel::class.java] + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + (application as FDroidApp).setSecureWindow(this) + (application as FDroidApp).applyPureBlackBackgroundInDarkTheme(this) + + initViewModel() + + setContent { + val state by viewModel.state.collectAsState() + val numberOfApps by viewModel.numberAppsFlow.collectAsState(0) + + FDroidContent { + RepoDetailsScreen( + state = state, + numberOfApps = numberOfApps, + // app bar + onBackClicked = { onBackPressedDispatcher.onBackPressed() }, + onShareClicked = this::onShareClicked, + onShowQrCodeClicked = this::onShowQrCodeClicked, + onDeleteClicked = this::onDeleteClicked, + onInfoClicked = this::onInfoClicked, + // other buttons + onShowAppsClicked = this::onShowAppsClicked, + onToggleArchiveClicked = { enabled -> + viewModel.setArchiveRepoEnabled(enabled) + }, + onEditCredentialsClicked = this::onEditCredentialsClicked, + // mirrors + setMirrorEnabled = { m, enabled -> viewModel.setMirrorEnabled(m, enabled) }, + onShareMirror = this::onShareMirror, + onDeleteMirror = this::onDeleteMirror, + ) + } + } + } + + private fun onShareClicked() { + val uri = viewModel.state.value.repo.getShareUri() + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, uri) + } + startActivity( + Intent.createChooser(intent, getResources().getString(R.string.share_repository)) + ) + } + + private fun onShowQrCodeClicked() { + viewModel.generateQrCode(this) + + val imageView = ImageView(this) + viewModel.qrCodeLiveData.observe(this) { + imageView.setImageBitmap(it) + } + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.share_repository) + .setView(imageView) + .setPositiveButton(R.string.ok, null) + .show() + } + + private fun onDeleteClicked() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.repo_confirm_delete_title) + .setMessage(R.string.repo_confirm_delete_body) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteRepository() + finish() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun onInfoClicked() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.repo_details) + .setMessage(R.string.repo_details_info_text) + .setPositiveButton(R.string.ok, null) + .show() + } + + private fun onShowAppsClicked() { + val repo = viewModel.state.value.repo + if (!repo.enabled) { + return + } + val intent = Intent(this, AppListActivity::class.java).apply { + putExtra(AppListActivity.EXTRA_REPO_ID, repo.repoId) + } + startActivity(intent) + } + + private fun onEditCredentialsClicked() { + val repo = viewModel.state.value.repo + + val view = layoutInflater.inflate(R.layout.login, null, false) + val usernameInput = view.findViewById(R.id.edit_name) + val passwordInput = view.findViewById(R.id.edit_password) + + usernameInput.editText?.setText(repo.username ?: "") + passwordInput.requestFocus() + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.repo_basic_auth_title) + .setView(view) + .setPositiveButton(R.string.ok) { dialog, _ -> + val username = usernameInput.editText?.text.toString() + val password = passwordInput.editText?.text.toString() + + if (username.isNotBlank()) { + viewModel.updateUsernameAndPassword(username, password) + } else { + Toast.makeText(this, R.string.repo_error_empty_username, Toast.LENGTH_LONG) + .show() + } + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun onShareMirror(mirror: Mirror) { + val fingerprint = viewModel.state.value.repo.fingerprint + val uri = mirror.getFDroidLinkUrl(fingerprint) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, uri) + } + startActivity( + Intent.createChooser(intent, getResources().getString(R.string.share_mirror)) + ) + } + + private fun onDeleteMirror(mirror: Mirror) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.repo_confirm_delete_mirror_title) + .setMessage(R.string.repo_confirm_delete_mirror_body) + .setPositiveButton(R.string.delete) { dialog, _ -> + viewModel.deleteUserMirror(mirror) + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsScreen.kt new file mode 100644 index 000000000..b12bf19d8 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsScreen.kt @@ -0,0 +1,477 @@ +package org.fdroid.fdroid.views.repos + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.primarySurface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.Bottom +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import org.fdroid.database.Repository +import org.fdroid.download.Mirror +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.Utils +import org.fdroid.fdroid.compose.ComposeUtils.FDroidButton +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.compose.ComposeUtils.FDroidOutlineButton +import org.fdroid.fdroid.compose.FDroidExpandableRow +import org.fdroid.fdroid.compose.FDroidSwitchRow + +@Composable +fun RepoDetailsScreen( + state: RepoDetailsState, + numberOfApps: Int, + // app bar functions + onBackClicked: () -> Unit, + onShareClicked: () -> Unit, + onShowQrCodeClicked: () -> Unit, + onDeleteClicked: () -> Unit, + onInfoClicked: () -> Unit, + // other buttons + onShowAppsClicked: () -> Unit, + onToggleArchiveClicked: (Boolean) -> Unit, + onEditCredentialsClicked: () -> Unit, + // mirror actions + setMirrorEnabled: (Mirror, Boolean) -> Unit, + onShareMirror: (Mirror) -> Unit, + onDeleteMirror: (Mirror) -> Unit, +) { + val officialMirrors = state.repo.getAllOfficialMirrors() + val userMirrors = state.repo.getAllUserMirrors() + val disabledMirrors = state.repo.disabledMirrors.toHashSet() + + Scaffold(topBar = { + TopAppBar(elevation = 4.dp, + backgroundColor = MaterialTheme.colors.primarySurface, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + title = { + Text( + text = stringResource(R.string.repo_details), + modifier = Modifier.alpha(ContentAlpha.high), + ) + }, + actions = { + IconButton(onClick = onShareClicked) { + Icon(Icons.Default.Share, stringResource(R.string.share_repository)) + } + IconButton(onClick = onShowQrCodeClicked) { + Icon(Icons.Default.QrCode, stringResource(R.string.show_repository_qr)) + } + IconButton(onClick = onDeleteClicked) { + Icon(Icons.Default.Delete, stringResource(R.string.delete)) + } + IconButton(onClick = onInfoClicked) { + Icon(Icons.Default.Info, stringResource(R.string.repo_details)) + } + }) + }) { paddingContent -> + Box( + modifier = Modifier.padding(paddingContent) + ) { + Column( + verticalArrangement = spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier) // spacedBy will provide the padding + GeneralInfoCard( + state.repo, + state.archiveState, + numberOfApps, + onShowAppsClicked, + onToggleArchiveClicked, + ) + BasicAuthCard(state.repo, onEditCredentialsClicked) + if (state.repo.certificate.isEmpty()) { + UnsignedCard() + } else { + FingerprintCard(state.repo) + } + // The repo's address is currently also an official mirror. + // So if there is only one mirror, this is the address => don't show this section. + // If there are 2 or more official mirrors, it makes sense to allow users + // to disable the canonical address. + if (officialMirrors.size > 2) { + OfficialMirrors( + mirrors = officialMirrors, + disabledMirrors = disabledMirrors, + setMirrorEnabled = setMirrorEnabled, + ) + } + if (userMirrors.isNotEmpty()) { + UserMirrors( + mirrors = userMirrors, + disabledMirrors = disabledMirrors, + setMirrorEnabled = setMirrorEnabled, + onShareMirror = onShareMirror, + onDeleteMirror = onDeleteMirror, + ) + } + // TODO: Add button to add user mirror? + Spacer(modifier = Modifier) // spacedBy will provide the padding + } + } + } +} + +@Composable +private fun GeneralInfoCard( + repo: Repository, + archiveState: ArchiveState, + numberOfApps: Int, + onShowAppsClicked: () -> Unit, + onToggleArchiveClicked: (Boolean) -> Unit, +) { + val localeList = LocaleListCompat.getDefault() + val isDevPreview = LocalInspectionMode.current + val description = if (isDevPreview) { + LoremIpsum(42).values.joinToString(" ") + } else { + repo.getDescription(localeList) + }?.replace("\n", " ") + + val lastIndexUpdateTime = DateUtils.getRelativeTimeSpanString( + repo.timestamp, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL + ) + val lastIndexUpdate = stringResource(R.string.repo_last_update_index, lastIndexUpdateTime) + + val lastUpdatedTime = repo.lastUpdated?.let { + DateUtils.getRelativeTimeSpanString( + it, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_ALL + ) + } ?: stringResource(R.string.repositories_last_update_never) + val lastUpdated = stringResource(R.string.repo_last_update_check, lastUpdatedTime) + + Card { + Column( + verticalArrangement = spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + horizontalArrangement = spacedBy(16.dp), + ) { + RepoIcon(repo, Modifier.size(48.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = repo.getName(localeList) ?: "Unknown Repository", + maxLines = 1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.h6, + ) + Text( + text = repo.address.replaceFirst("https://", ""), + style = MaterialTheme.typography.body2, + ) + Text( + text = pluralStringResource( + R.plurals.repo_num_apps_text, + numberOfApps, + numberOfApps, + ), + style = MaterialTheme.typography.caption, + ) + Text( + text = lastIndexUpdate, + style = MaterialTheme.typography.caption, + ) + Text( + text = lastUpdated, + style = MaterialTheme.typography.caption, + ) + } + } + + if (repo.enabled) FDroidButton( + stringResource(R.string.repo_num_apps_button), + modifier = Modifier.align(End), + onClick = onShowAppsClicked, + ) + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.body2, + ) + } + if (description != null && archiveState != ArchiveState.UNKNOWN) { + Divider() + } + if (archiveState != ArchiveState.UNKNOWN) { + FDroidSwitchRow( + text = stringResource(R.string.repo_archive_toggle_description), + checked = archiveState == ArchiveState.ENABLED, + enabled = true, + onCheckedChange = onToggleArchiveClicked, + ) + } + } + } +} + +@Composable +private fun BasicAuthCard( + repo: Repository, + onEditCredentialsClicked: () -> Unit, +) { + val username: String = repo.username ?: return + if (username.isBlank()) { + return + } + + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.repo_basic_auth_title), + style = MaterialTheme.typography.caption, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.repo_basic_auth_username, username), + style = MaterialTheme.typography.body2, + ) + Text( + text = stringResource(R.string.repo_basic_auth_password), + style = MaterialTheme.typography.body2, + ) + } + FDroidOutlineButton( + text = stringResource(R.string.repo_basic_auth_edit), + onClick = onEditCredentialsClicked, + imageVector = Icons.Default.Edit, + modifier = Modifier.align(Bottom), + ) + } + } + } +} + +@Composable +private fun FingerprintExpandable(repo: Repository) { + val isDevPreview = LocalInspectionMode.current + val fingerprint: String = if (isDevPreview) { + Utils.formatFingerprint(LocalContext.current, "A".repeat(64)) + } else { + repo.fingerprint?.let { + Utils.formatFingerprint(LocalContext.current, it) + } ?: stringResource(R.string.unsigned) + } + + FDroidExpandableRow( + text = stringResource(R.string.repo_fingerprint), + imageVectorStart = Icons.Default.Fingerprint, + ) { + Text( + text = fingerprint, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } +} + +@Composable +private fun UnsignedCard() { + Card { + Text( + text = stringResource(R.string.repo_unsigned_description), + color = colorResource(R.color.unsigned), + style = MaterialTheme.typography.body2, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } +} + +@Composable +private fun OfficialMirrors( + mirrors: List, + disabledMirrors: HashSet, + setMirrorEnabled: (Mirror, Boolean) -> Unit, +) { + FDroidExpandableRow( + text = stringResource(R.string.repo_official_mirrors), + imageVectorStart = Icons.Default.Public, + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + mirrors.forEachIndexed { idx, m -> + FDroidSwitchRow( + text = m.baseUrl, + checked = !disabledMirrors.contains(m.baseUrl), + onCheckedChange = { checked -> setMirrorEnabled(m, checked) }, + modifier = Modifier.padding(vertical = 8.dp), + ) + if (idx < mirrors.size - 1) Divider() + } + } + } +} + +@Composable +private fun UserMirrors( + mirrors: List, + disabledMirrors: HashSet, + setMirrorEnabled: (Mirror, Boolean) -> Unit, + onShareMirror: (Mirror) -> Unit, + onDeleteMirror: (Mirror) -> Unit, +) { + FDroidExpandableRow( + text = stringResource(R.string.repo_user_mirrors), + imageVectorStart = Icons.Default.Dns, + ) { + mirrors.forEachIndexed { idx, m -> + Column( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + FDroidSwitchRow( + text = m.baseUrl, + checked = !disabledMirrors.contains(m.baseUrl), + onCheckedChange = { checked -> setMirrorEnabled(m, checked) }, + modifier = Modifier.padding(vertical = 8.dp), + ) + Row( + horizontalArrangement = spacedBy(16.dp), + ) { + FDroidOutlineButton( + text = stringResource(R.string.menu_share), + imageVector = Icons.Default.Share, + onClick = { onShareMirror(m) }, + ) + FDroidOutlineButton( + text = stringResource(R.string.delete), + imageVector = Icons.Default.Delete, + onClick = { onDeleteMirror(m) }, + color = Color.Red, + ) + } + if (idx < mirrors.size - 1) Divider() + } + } + } +} + +/* Previews */ + +@Composable +@Preview +fun RepoDetailsScreenPreview() { + val repo = FDroidApp.createSwapRepo("https://example.org/fdroid/repo", "foo bar") + FDroidContent { + RepoDetailsScreen( + RepoDetailsState(repo, ArchiveState.ENABLED), + numberOfApps = 42, + {}, {}, {}, {}, {}, // app bar + {}, {}, {}, // other buttons + { _, _ -> }, { _ -> }, { _ -> } // mirror + ) + } +} + +@Composable +@Preview +fun BasicAuthCardPreview() { + val repo = FDroidApp.createSwapRepo("https://example.org/fdroid/repo", "foo bar") + // TODO set RepositoryPreferences + FDroidContent { + BasicAuthCard(repo, { }) + } +} + +@Composable +@Preview +fun UnsignedCardPreview() { + FDroidContent { + UnsignedCard() + } +} + +@Composable +@Preview +fun OfficialMirrorsPreview() { + FDroidContent { + OfficialMirrors( + mirrors = listOf(Mirror("https://mirror.example.com/fdroid/repo")), + disabledMirrors = HashSet(), + setMirrorEnabled = { _, _ -> }, + ) + } +} + +@Composable +@Preview +fun UserMirrorsPreview() { + FDroidContent { + UserMirrors( + mirrors = listOf(Mirror("https://mirror.example.com/fdroid/repo")), + disabledMirrors = HashSet(), + setMirrorEnabled = { _, _ -> }, + onShareMirror = { _ -> }, + onDeleteMirror = { _ -> }, + ) + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt index 3daae527a..29a2ffaf4 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fdroid.database.Repository +import org.fdroid.download.Mirror import org.fdroid.fdroid.FDroidApp import org.fdroid.fdroid.R import org.fdroid.fdroid.data.DBHelper @@ -109,7 +110,7 @@ class RepoDetailsViewModel( fun deleteRepository() { val repoId = _state.value.repo.repoId viewModelScope.launch(Dispatchers.IO) { - repositoryDao.deleteRepository(repoId) + repoManager.deleteRepository(repoId) } } @@ -127,6 +128,20 @@ class RepoDetailsViewModel( } } + fun setMirrorEnabled(mirror: Mirror, enabled: Boolean) { + val repoId = _state.value.repo.repoId + viewModelScope.launch(Dispatchers.IO) { + repoManager.setMirrorEnabled(repoId, mirror, enabled) + } + } + + fun deleteUserMirror(mirror: Mirror) { + val repoId = _state.value.repo.repoId + viewModelScope.launch(Dispatchers.IO) { + repoManager.deleteUserMirror(repoId, mirror) + } + } + private fun Repository.archiveState(): ArchiveState { val isEnabled = repoManager.getRepositories().find { r -> r.isArchiveRepo && r.certificate == certificate diff --git a/app/src/main/res/layout/activity_repo_details.xml b/app/src/main/res/layout/activity_repo_details.xml deleted file mode 100644 index 3cdea4991..000000000 --- a/app/src/main/res/layout/activity_repo_details.xml +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -