Merge branch 'repo-details-compose' into 'master'

Refactor RepoDetailsActivity, move logic into ViewModel

See merge request fdroid/fdroidclient!1489
This commit is contained in:
Torsten Grote
2025-01-29 14:22:08 +00:00
4 changed files with 219 additions and 152 deletions

View File

@@ -0,0 +1,42 @@
package org.fdroid.fdroid
import android.graphics.Bitmap
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.DisplayCompat
import com.google.zxing.BarcodeFormat
import com.google.zxing.WriterException
import com.google.zxing.encode.Contents
import com.google.zxing.encode.QRCodeEncoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.fdroid.fdroid.Utils.debugLog
import kotlin.math.min
private const val TAG = "Utils"
/**
* Same as the Java function Utils.generateQrBitmap, but using coroutines instead of Single and Disposable.
*/
suspend fun generateQrBitmapKt(
activity: AppCompatActivity,
qrData: String,
): Bitmap = withContext(Dispatchers.Default) {
val displayMode = DisplayCompat.getMode(activity, activity.windowManager.getDefaultDisplay())
val qrCodeDimension = min(displayMode.physicalWidth, displayMode.physicalHeight)
debugLog(TAG, "generating QRCode Bitmap of " + qrCodeDimension + "x" + qrCodeDimension)
val encoder = QRCodeEncoder(
qrData,
null,
Contents.Type.TEXT,
BarcodeFormat.QR_CODE.toString(),
qrCodeDimension,
)
return@withContext try {
encoder.encodeAsBitmap()
} catch (e: WriterException) {
Log.e(TAG, "Could not encode QR as bitmap", e)
Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
}
}

View File

@@ -10,7 +10,6 @@ import android.os.Bundle;
import android.os.Parcelable;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
@@ -24,12 +23,13 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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;
@@ -38,27 +38,18 @@ 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.AppDao;
import org.fdroid.database.Repository;
import org.fdroid.database.RepositoryDao;
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.data.DBHelper;
import org.fdroid.fdroid.views.apps.AppListActivity;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.Callable;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class RepoDetailsActivity extends AppCompatActivity {
private static final String TAG = "RepoDetailsActivity";
@@ -104,17 +95,10 @@ public class RepoDetailsActivity extends AppCompatActivity {
private Repository repo;
private long repoId;
private View repoView;
private String shareUrl;
private MirrorAdapter adapterToNotify;
private RepoDetailsViewModel model;
// FIXME access to this could be moved into ViewModel
private RepositoryDao repositoryDao;
// FIXME access to this could be moved into ViewModel
private AppDao appDao;
@Nullable
private Disposable disposable;
/**
* Help function to make switching between two view states easier.
@@ -132,11 +116,22 @@ public class RepoDetailsActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
FDroidApp fdroidApp = (FDroidApp) getApplication();
fdroidApp.setSecureWindow(this);
fdroidApp.applyPureBlackBackgroundInDarkTheme(this);
model = new ViewModelProvider(this).get(RepoDetailsViewModel.class);
repositoryDao = DBHelper.getDb(this).getRepositoryDao();
appDao = DBHelper.getDb(this).getAppDao();
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);
@@ -148,15 +143,6 @@ public class RepoDetailsActivity extends AppCompatActivity {
repoView = findViewById(R.id.repo_view);
repoId = getIntent().getLongExtra(ARG_REPO_ID, 0);
model.initRepo(repoId);
repo = FDroidApp.getRepoManager(this).getRepository(repoId);
if (repo == null) {
// repo must have been deleted just now (maybe slow UI?)
finish();
return;
}
TextView inputUrl = findViewById(R.id.input_repo_url);
inputUrl.setText(repo.getAddress());
@@ -171,28 +157,6 @@ public class RepoDetailsActivity extends AppCompatActivity {
userMirrorAdapter.setUserMirrors(repo.getUserMirrors());
userMirrorListView.setAdapter(userMirrorAdapter);
if (repo.getAddress().startsWith("content://") || repo.getAddress().startsWith("file://")) {
// no need to show a QR Code, it is not shareable
return;
}
Uri uri = Uri.parse(repo.getAddress());
try {
if (repo.getFingerprint() != null) {
uri = uri.buildUpon().appendQueryParameter("fingerprint", repo.getFingerprint()).build();
}
} catch (Exception e) {
Log.e(TAG, "Invalid repo fingerprint: " + repo.getAddress());
}
String qrUriString = uri.toString();
disposable = Utils.generateQrBitmap(this, qrUriString)
.subscribe(bitmap -> {
final ImageView qrCode = findViewById(R.id.qr_code);
if (qrCode != null) {
qrCode.setImageBitmap(bitmap);
}
});
// update UI when repo in DB changes
model.getRepoLiveData().observe(this, repo -> {
if (repo == null) {
@@ -204,23 +168,37 @@ public class RepoDetailsActivity extends AppCompatActivity {
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 -> {
Boolean enabled = s.getArchiveEnabled();
if (enabled == null) {
archiveRepoSwitch.setEnabled(false);
} else {
archiveRepoSwitch.setEnabled(true);
archiveRepoSwitch.setChecked(enabled);
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(repo, archiveRepoSwitch.isChecked()));
}
archiveRepoSwitch.setOnClickListener(v -> model.setArchiveRepoEnabled(archiveRepoSwitch.isChecked()));
@Override
protected void onDestroy() {
if (disposable != null) disposable.dispose();
super.onDestroy();
ImageView qrCode = findViewById(R.id.qr_code);
model.getQrCodeLiveData().observe(this, bitmap -> {
if (qrCode != null) {
qrCode.setImageBitmap(bitmap);
}
});
model.generateQrCode(this);
}
@Override
@@ -279,7 +257,7 @@ public class RepoDetailsActivity extends AppCompatActivity {
} else if (itemId == R.id.action_share) {
intent = new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, shareUrl);
intent.putExtra(Intent.EXTRA_TEXT, repo.getShareUri());
startActivity(Intent.createChooser(intent,
getResources().getString(R.string.share_repository)));
}
@@ -287,31 +265,6 @@ public class RepoDetailsActivity extends AppCompatActivity {
return super.onOptionsItemSelected(item);
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
prepareShareMenuItems(menu);
return true;
}
private void prepareShareMenuItems(Menu menu) {
if (!TextUtils.isEmpty(repo.getAddress())) {
if (!TextUtils.isEmpty(repo.getCertificate())) {
try {
shareUrl = Uri.parse(repo.getAddress()).buildUpon()
.appendQueryParameter("fingerprint", repo.getFingerprint()).toString();
} catch (Exception e) {
Log.e(TAG, "Invalid repo fingerprint: " + repo.getAddress());
shareUrl = repo.getAddress();
}
} else {
shareUrl = repo.getAddress();
}
menu.findItem(R.id.action_share).setVisible(true);
} else {
menu.findItem(R.id.action_share).setVisible(false);
}
}
private void setupDescription(View parent, Repository repo) {
TextView descriptionLabel = parent.findViewById(R.id.label_description);
@@ -405,20 +358,11 @@ public class RepoDetailsActivity extends AppCompatActivity {
setMultipleViewVisibility(repoView, HIDE_IF_EXISTS, View.GONE);
TextView name = repoView.findViewById(R.id.text_repo_name);
TextView numApps = repoView.findViewById(R.id.text_num_apps);
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()));
// load number of apps in repo
disposable = Single.fromCallable(() -> appDao.getNumberOfAppsInRepository(repoId))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(appCount -> {
String countStr = String.format(LocaleCompat.getDefault(), "%d", appCount);
numApps.setText(countStr);
});
if (repo.getEnabled()) {
numAppsButton.setOnClickListener(view -> {
Intent i = new Intent(this, AppListActivity.class);
@@ -457,10 +401,7 @@ public class RepoDetailsActivity extends AppCompatActivity {
.setTitle(R.string.repo_confirm_delete_title)
.setMessage(R.string.repo_confirm_delete_body)
.setPositiveButton(R.string.delete, (dialog, which) -> {
runOffUiThread(() -> {
repositoryDao.deleteRepository(repoId);
return true;
});
model.deleteRepository();
finish();
}).setNegativeButton(android.R.string.cancel, (dialog, which) -> {
// Do nothing...
@@ -490,10 +431,7 @@ public class RepoDetailsActivity extends AppCompatActivity {
final String password = passwordInput.getText().toString();
if (!TextUtils.isEmpty(name)) {
runOffUiThread(() -> {
repositoryDao.updateUsernameAndPassword(repo.getRepoId(), name, password);
return true;
});
model.updateUsernameAndPassword(name, password);
updateRepoView();
dialog.dismiss();
} else {
@@ -577,10 +515,7 @@ public class RepoDetailsActivity extends AppCompatActivity {
adapterToNotify.notifyDataSetChanged();
}
ArrayList<String> toDisableMirrors = new ArrayList<>(disabledMirrors);
runOffUiThread(() -> {
repositoryDao.updateDisabledMirrors(repo.getRepoId(), toDisableMirrors);
return true;
});
model.updateDisabledMirrors(toDisableMirrors);
});
View repoUnverified = holder.view.findViewById(R.id.repo_unverified);
@@ -598,11 +533,4 @@ public class RepoDetailsActivity extends AppCompatActivity {
return mirrors.size();
}
}
private void runOffUiThread(Callable<?> r) {
disposable = Single.fromCallable(r)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
}

View File

@@ -1,70 +1,103 @@
package org.fdroid.fdroid.views.repos
import android.app.Application
import android.graphics.Bitmap
import android.util.Log
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import info.guardianproject.netcipher.NetCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fdroid.database.Repository
import org.fdroid.fdroid.FDroidApp
import org.fdroid.fdroid.R
import org.fdroid.fdroid.data.DBHelper
import org.fdroid.fdroid.generateQrBitmapKt
import org.fdroid.fdroid.work.RepoUpdateWorker
data class RepoDetailsState(
val repo: Repository?,
val archiveEnabled: Boolean? = null,
val repo: Repository,
val archiveState: ArchiveState,
)
class RepoDetailsViewModel(app: Application) : AndroidViewModel(app) {
enum class ArchiveState {
ENABLED,
DISABLED,
UNKNOWN,
}
private val repoManager = FDroidApp.getRepoManager(app)
private val _state = MutableStateFlow<RepoDetailsState?>(null)
val state = _state.asStateFlow()
val liveData = _state.asLiveData()
class RepoDetailsViewModel(
app: Application,
initialRepo: Repository,
) : AndroidViewModel(app) {
val repoLiveData = combine(_state, repoManager.repositoriesState) { s, reposState ->
if (s?.repo == null) {
null
} else {
reposState.find { repo -> repo.repoId == s.repo.repoId }
}
}.distinctUntilChanged().asLiveData()
fun initRepo(repoId: Long) {
val repo = repoManager.getRepository(repoId)
if (repo == null) {
_state.value = RepoDetailsState(null)
} else {
_state.value = RepoDetailsState(
repo = repo,
archiveEnabled = repo.isArchiveEnabled(),
)
companion object {
// TODO: Use androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
// That seems to require setting up dependency injection.
val APP_KEY = object : CreationExtras.Key<Application> {}
val REPO_KEY = object : CreationExtras.Key<Repository> {}
val Factory = viewModelFactory {
initializer {
val app = this[APP_KEY] as Application
val repo = this[REPO_KEY] as Repository
RepoDetailsViewModel(app, repo)
}
}
}
fun setArchiveRepoEnabled(repo: Repository, enabled: Boolean) {
// archiveEnabled = null means we don't know current state, it's in progress
_state.value = _state.value?.copy(archiveEnabled = null)
private val repoManager = FDroidApp.getRepoManager(app)
private val repositoryDao = DBHelper.getDb(app).getRepositoryDao()
private val appDao = DBHelper.getDb(app).getAppDao()
private val _state = MutableStateFlow(
RepoDetailsState(initialRepo, initialRepo.archiveState())
)
val state = _state.asStateFlow()
val liveData = _state.asLiveData()
val repoFlow = combine(_state, repoManager.repositoriesState) { s, reposState ->
reposState.find { repo -> repo.repoId == s.repo.repoId }
}.distinctUntilChanged()
val repoLiveData = repoFlow.asLiveData()
val numberAppsFlow: Flow<Int> = repoFlow.map { repo ->
if (repo != null) {
appDao.getNumberOfAppsInRepository(repo.repoId)
} else 0
}.flowOn(Dispatchers.IO).distinctUntilChanged()
val numberOfAppsLiveData = numberAppsFlow.asLiveData()
val qrCodeLiveData = MutableLiveData<Bitmap?>(null)
fun setArchiveRepoEnabled(enabled: Boolean) {
val repo = _state.value.repo
_state.value = _state.value.copy(archiveState = ArchiveState.UNKNOWN)
viewModelScope.launch(Dispatchers.IO) {
try {
val repoId = repoManager.setArchiveRepoEnabled(repo, enabled, NetCipher.getProxy())
_state.value = _state.value?.copy(archiveEnabled = enabled)
_state.value = _state.value.copy(archiveState = enabled.toArchiveState())
if (enabled && repoId != null) withContext(Dispatchers.Main) {
RepoUpdateWorker.updateNow(getApplication(), repoId)
}
} catch (e: Exception) {
Log.e(this.javaClass.simpleName, "Error toggling archive repo: ", e)
_state.value = _state.value?.copy(archiveEnabled = repo.isArchiveEnabled())
_state.value = _state.value.copy(archiveState = repo.archiveState())
withContext(Dispatchers.Main) {
Toast.makeText(getApplication(), R.string.repo_archive_failed, LENGTH_SHORT)
.show()
@@ -73,10 +106,55 @@ class RepoDetailsViewModel(app: Application) : AndroidViewModel(app) {
}
}
private fun Repository.isArchiveEnabled(): Boolean {
return repoManager.getRepositories().find { r ->
r.isArchiveRepo && r.certificate == certificate
}?.enabled ?: false
fun deleteRepository() {
val repoId = _state.value.repo.repoId
viewModelScope.launch(Dispatchers.IO) {
repositoryDao.deleteRepository(repoId)
}
}
fun updateUsernameAndPassword(username: String, password: String) {
val repoId = _state.value.repo.repoId
viewModelScope.launch(Dispatchers.IO) {
repositoryDao.updateUsernameAndPassword(repoId, username, password)
}
}
fun updateDisabledMirrors(toDisable: List<String>) {
val repoId = _state.value.repo.repoId
viewModelScope.launch(Dispatchers.IO) {
repositoryDao.updateDisabledMirrors(repoId, toDisable)
}
}
private fun Repository.archiveState(): ArchiveState {
val isEnabled = repoManager.getRepositories().find { r ->
r.isArchiveRepo && r.certificate == certificate
}?.enabled
return when (isEnabled) {
true -> ArchiveState.ENABLED
false -> ArchiveState.DISABLED
null -> ArchiveState.UNKNOWN
}
}
private fun Boolean.toArchiveState(): ArchiveState {
return if (this) ArchiveState.ENABLED else ArchiveState.DISABLED
}
// TODO: initialise this once on ViewModel creation, and don't take an Activity, do fixed size
fun generateQrCode(activity: AppCompatActivity) {
val repo = _state.value.repo
if (repo.address.startsWith("content://") || repo.address.startsWith("file://")) {
// no need to show a QR Code, it is not shareable
qrCodeLiveData.value = null
return
}
viewModelScope.launch(Dispatchers.Default) {
val bitmap = generateQrBitmapKt(activity, repo.shareUri)
withContext(Dispatchers.Main) {
qrCodeLiveData.value = bitmap
}
}
}
}

View File

@@ -1,5 +1,8 @@
package org.fdroid.database
import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.core.os.LocaleListCompat
import androidx.room.Embedded
import androidx.room.Entity
@@ -19,6 +22,8 @@ import org.fdroid.index.v2.MirrorV2
import org.fdroid.index.v2.ReleaseChannelV2
import org.fdroid.index.v2.RepoV2
private const val TAG = "Repository"
@Entity(tableName = CoreRepository.TABLE)
internal data class CoreRepository(
@PrimaryKey(autoGenerate = true) val repoId: Long = 0,
@@ -214,6 +219,20 @@ public data class Repository internal constructor(
add(0, org.fdroid.download.Mirror(address))
}
}
val shareUri: String
@WorkerThread
get() {
var uri = Uri.parse(address)
fingerprint?.let {
try {
uri = uri.buildUpon().appendQueryParameter("fingerprint", it).build()
} catch (e: UnsupportedOperationException) {
Log.e(TAG, "Failed to append fingerprint to URI: $e")
}
}
return uri.toString()
}
}
/**