From d21adc75b4e759557dec7e7fc53985ee9f8fd81c Mon Sep 17 00:00:00 2001
From: Torsten Grote
To integrate, create an instance of {@code IntentIntegrator} and call {@link #initiateScan()} and wait - * for the result in your app.
- * - *It does require that the Barcode Scanner (or work-alike) application is installed. The - * {@link #initiateScan()} method will prompt the user to download the application, if needed.
- * - *There are a few steps to using this integration. First, your {@link AppCompatActivity} must implement - * the method {@link AppCompatActivity#onActivityResult(int, int, Intent)} and include a line of code like this:
- * - *{@code
- * public void onActivityResult(int requestCode, int resultCode, Intent intent) {
- * IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent);
- * if (scanResult != null) {
- * // handle scan result
- * }
- * // else continue with any other code you need in the method
- * ...
- * }
- * }
- *
- * This is where you will handle a scan result.
- * - *Second, just call this in response to a user action somewhere to begin the scan process:
- * - *{@code
- * IntentIntegrator integrator = new IntentIntegrator(yourActivity);
- * integrator.initiateScan();
- * }
- *
- * Note that {@link #initiateScan()} returns an {@link AlertDialog} which is non-null if the - * user was prompted to download the application. This lets the calling app potentially manage the dialog. - * In particular, ideally, the app dismisses the dialog if it's still active in its - * {@link AppCompatActivity#onPause()} - * method.
- * - *You can use {@link #setTitle(String)} to customize the title of this download prompt dialog (or, use - * {@link #setTitleByID(int)} to set the title by string resource ID.) Likewise, the prompt message, and - * yes/no button labels can be changed.
- * - *Finally, you can use {@link #addExtra(String, Object)} to add more parameters to the Intent used - * to invoke the scanner. This can be used to set additional options not directly exposed by this - * simplified API.
- * - *By default, this will only allow applications that are known to respond to this intent correctly - * do so. The apps that are allowed to response can be set with {@link #setTargetApplications(List)}. - * For example, set to {@link #TARGET_BARCODE_SCANNER_ONLY} to only target the Barcode Scanner app itself.
- * - *To share text, encoded as a QR Code on-screen, similarly, see {@link #shareText(CharSequence)}.
- * - *Some code, particularly download integration, was contributed from the Anobiit application.
- * - *Some formats are not enabled by default even when scanning with {@link #ALL_CODE_TYPES}, such as - * PDF417. Use {@link #initiateScan(java.util.Collection)} with - * a collection containing the names of formats to scan for explicitly, like "PDF_417", to use such - * formats.
- * - * @author Sean Owen - * @author Fred Lin - * @author Isaac Potoczny-Jones - * @author Brad Drehmer - * @author gcstang - */ -@SuppressWarnings("LineLength") -public class IntentIntegrator { - - public static final int REQUEST_CODE = 0x0000c0de; // Only use bottom 16 bits - - public static final String DEFAULT_TITLE = "Install Barcode Scanner?"; - public static final String DEFAULT_MESSAGE = - "This application requires a Barcode Scanner. Would you like to install one?"; - public static final String DEFAULT_YES = "Yes"; - public static final String DEFAULT_NO = "No"; - - private static final String BS_PACKAGE = "com.google.zxing.client.android"; - - // supported barcode formats - public static final CollectionCall this from your {@link AppCompatActivity}'s - * {@link AppCompatActivity#onActivityResult(int, int, Intent)} method.
- * - * @param requestCode request code from {@code onActivityResult()} - * @param resultCode result code from {@code onActivityResult()} - * @param intent {@link Intent} from {@code onActivityResult()} - * @return null if the event handled here was not related to this class, or - * else an {@link IntentResult} containing the result of the scan. If the user cancelled scanning, - * the fields will be null. - */ - public static IntentResult parseActivityResult(int requestCode, int resultCode, Intent intent) { - if (requestCode == REQUEST_CODE) { - if (resultCode == AppCompatActivity.RESULT_OK) { - String contents = intent.getStringExtra("SCAN_RESULT"); - String formatName = intent.getStringExtra("SCAN_RESULT_FORMAT"); - byte[] rawBytes = intent.getByteArrayExtra("SCAN_RESULT_BYTES"); - int intentOrientation = intent.getIntExtra("SCAN_RESULT_ORIENTATION", Integer.MIN_VALUE); - Integer orientation = intentOrientation == Integer.MIN_VALUE ? null : intentOrientation; - String errorCorrectionLevel = intent.getStringExtra("SCAN_RESULT_ERROR_CORRECTION_LEVEL"); - return new IntentResult(contents, - formatName, - rawBytes, - orientation, - errorCorrectionLevel); - } - return new IntentResult(); - } - return null; - } - - /** - * Defaults to type "TEXT_TYPE". - * - * @param text the text string to encode as a barcode - * @return the {@link AlertDialog} that was shown to the user prompting them to download the app - * if a prompt was needed, or null otherwise - * @see #shareText(CharSequence, CharSequence) - */ - public final AlertDialog shareText(CharSequence text) { - return shareText(text, "TEXT_TYPE"); - } - - /** - * Shares the given text by encoding it as a barcode, such that another user can - * scan the text off the screen of the device. - * - * @param text the text string to encode as a barcode - * @param type type of data to encode. See {@code com.google.zxing.client.android.Contents.Type} constants. - * @return the {@link AlertDialog} that was shown to the user prompting them to download the app - * if a prompt was needed, or null otherwise - */ - public final AlertDialog shareText(CharSequence text, CharSequence type) { - Intent intent = new Intent(); - intent.addCategory(Intent.CATEGORY_DEFAULT); - intent.setAction(BS_PACKAGE + ".ENCODE"); - intent.putExtra("ENCODE_TYPE", type); - intent.putExtra("ENCODE_DATA", text); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); - attachMoreExtras(intent); - try { - if (fragment == null) { - activity.startActivity(intent); - } else { - fragment.startActivity(intent); - } - } catch (ActivityNotFoundException ex) { - return showDownloadDialog(); - } - return null; - } - - private static ListEncapsulates the result of a barcode scan invoked through {@link IntentIntegrator}.
- * - * @author Sean Owen - */ -public final class IntentResult { - - private final String contents; - private final String formatName; - private final byte[] rawBytes; - private final Integer orientation; - private final String errorCorrectionLevel; - - IntentResult() { - this(null, null, null, null, null); - } - - IntentResult(String contents, - String formatName, - byte[] rawBytes, - Integer orientation, - String errorCorrectionLevel) { - this.contents = contents; - this.formatName = formatName; - this.rawBytes = rawBytes; - this.orientation = orientation; - this.errorCorrectionLevel = errorCorrectionLevel; - } - - /** - * @return raw content of barcode - */ - public String getContents() { - return contents; - } - - /** - * @return name of format, like "QR_CODE", "UPC_A". See {@code BarcodeFormat} for more format names. - */ - public String getFormatName() { - return formatName; - } - - /** - * @return raw bytes of the barcode content, if applicable, or null otherwise - */ - public byte[] getRawBytes() { - return rawBytes; - } - - /** - * @return rotation of the image, in degrees, which resulted in a successful scan. May be null. - */ - public Integer getOrientation() { - return orientation; - } - - /** - * @return name of the error correction level used in the barcode, if applicable - */ - public String getErrorCorrectionLevel() { - return errorCorrectionLevel; - } - - @Override - public String toString() { - int rawBytesLength = rawBytes == null ? 0 : rawBytes.length; - return "Format: " + formatName + '\n' + "Contents: " + contents + '\n' + "Raw bytes: (" + rawBytesLength - + " bytes)\n" + "Orientation: " + orientation + '\n' + "EC level: " + errorCorrectionLevel + '\n'; - } -} diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index 131e015fa..72e026632 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -72,6 +72,7 @@ import org.fdroid.fdroid.nearby.PublicSourceDirProvider; import org.fdroid.fdroid.nearby.SDCardScannerService; import org.fdroid.fdroid.nearby.WifiStateChangeService; import org.fdroid.fdroid.net.ConnectivityMonitorService; +import org.fdroid.fdroid.net.DownloaderFactory; import org.fdroid.fdroid.panic.HidingManager; import org.fdroid.fdroid.receiver.DeviceStorageReceiver; import org.fdroid.fdroid.work.CleanCacheWorker; @@ -508,7 +509,8 @@ public class FDroidApp extends Application implements androidx.work.Configuratio public static Repository createSwapRepo(String address, String certificate) { long now = System.currentTimeMillis(); - return new Repository(42L, address, now, IndexFormatVersion.ONE, certificate, 20001L, 42, now); + return new Repository(42L, address, now, IndexFormatVersion.ONE, certificate, 20001L, 42, + now); } public static Context getInstance() { @@ -516,7 +518,10 @@ public class FDroidApp extends Application implements androidx.work.Configuratio } public static RepoManager getRepoManager(Context context) { - if (repoManager == null) repoManager = new RepoManager(DBHelper.getDb(context)); + if (repoManager == null) { + repoManager = new RepoManager(context, DBHelper.getDb(context), DownloaderFactory.INSTANCE, + DownloaderFactory.HTTP_MANAGER); + } return repoManager; } diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index f3d44445e..8880f8a9c 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -402,7 +402,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh return Theme.valueOf(preferences.getString(Preferences.PREF_THEME, null)); } - boolean isPureBlack() { + public boolean isPureBlack() { return preferences.getBoolean(Preferences.PREF_USE_PURE_BLACK_DARK_THEME, false); } diff --git a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt new file mode 100644 index 000000000..2df332a80 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt @@ -0,0 +1,102 @@ +package org.fdroid.fdroid.compose + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import com.google.accompanist.themeadapter.material.createMdcTheme +import org.fdroid.fdroid.Preferences +import java.util.Locale + +object ComposeUtils { + @Composable + fun FDroidContent(content: @Composable () -> Unit) { + val context = LocalContext.current + val layoutDirection = LocalLayoutDirection.current + val (colors, typography, shapes) = createMdcTheme( + context = context, + layoutDirection = layoutDirection, + ) + val newColors = (colors ?: MaterialTheme.colors).let { c -> + if (!c.isLight && Preferences.get().isPureBlack) c.copy(background = Color.Black) + else c + } + MaterialTheme( + colors = newColors, + typography = typography?.let { + // adapt letter-spacing to non-compose UI + it.copy( + body1 = it.body1.copy(letterSpacing = 0.em), + body2 = it.body2.copy(letterSpacing = 0.em), + ) + } ?: MaterialTheme.typography, + shapes = shapes ?: MaterialTheme.shapes + ) { + Surface(content = content) + } + } + + @Composable + fun FDroidButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, + ) { + Button( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight) + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = text, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + } + Text(text = text.uppercase(Locale.getDefault())) + } + } + + @Composable + fun FDroidOutlineButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector? = null, + ) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(32.dp), + modifier = modifier.heightIn(min = ButtonDefaults.MinHeight) + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = text, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + } + Text(text = text.uppercase(Locale.getDefault())) + } + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java index 85ee66ec3..e0bd67664 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java @@ -63,6 +63,7 @@ import org.fdroid.database.RepositoryDao; import org.fdroid.download.Mirror; import org.fdroid.fdroid.AddRepoIntentService; import org.fdroid.fdroid.AppUpdateStatusManager; +import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; @@ -71,6 +72,7 @@ import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.data.NewRepoConfig; +import org.fdroid.fdroid.views.repos.AddRepoActivity; import org.fdroid.index.RepoManager; import org.fdroid.index.v1.IndexV1UpdaterKt; @@ -134,7 +136,13 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte getSupportActionBar().setDisplayHomeAsUpEnabled(true); toolbar.setOnMenuItemClickListener(menuItem -> { if (menuItem.getItemId() == R.id.action_add_repo) { - showAddRepo(); + if (BuildConfig.DEBUG) { + // TODO enable this for all builds and remove dead code afterwards + Intent i = new Intent(this, AddRepoActivity.class); + startActivity(i); + } else { + showAddRepo(); + } return true; } return false; diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoActivity.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoActivity.kt new file mode 100644 index 000000000..5924761f8 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoActivity.kt @@ -0,0 +1,63 @@ +package org.fdroid.fdroid.views.repos + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.UpdateService +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.views.ManageReposActivity +import org.fdroid.repo.AddRepoError +import org.fdroid.repo.Added + +class AddRepoActivity : ComponentActivity() { + + private val repoManager = FDroidApp.getRepoManager(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(STARTED) { + repoManager.addRepoState.collect { state -> + if (state is Added) { + // update newly added repo + UpdateService.updateRepoNow(applicationContext, state.repo.address) + // show repo list and close this activity + val intent = Intent(applicationContext, ManageReposActivity::class.java) + startActivity(intent) + finish() + } + } + } + } + setContent { + FDroidContent { + val state = repoManager.addRepoState.collectAsState().value + BackHandler(state is AddRepoError) { + // reset state when going back on error screen + repoManager.abortAddingRepository() + } + AddRepoIntroScreen( + state = state, + onFetchRepo = { url -> + repoManager.fetchRepositoryPreview(url) + }, + onAddRepo = { repoManager.addFetchedRepository() }, + onBackClicked = { onBackPressedDispatcher.onBackPressed() }, + ) + } + } + } + + override fun onDestroy() { + super.onDestroy() + if (!isChangingConfigurations) repoManager.abortAddingRepository() + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt new file mode 100644 index 000000000..0de347af3 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt @@ -0,0 +1,108 @@ +package org.fdroid.fdroid.views.repos + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.compose.ComposeUtils +import org.fdroid.fdroid.views.ManageReposActivity.getDisallowInstallUnknownSourcesErrorMessage +import org.fdroid.repo.AddRepoError +import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT +import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX +import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR +import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED +import java.io.IOException + +@Composable +fun AddRepoErrorScreen(paddingValues: PaddingValues, state: AddRepoError) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, CenterVertically), + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .padding(16.dp) + .padding(paddingValues) + .fillMaxSize(), + ) { + Image( + imageVector = Icons.Default.Error, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colors.error), + modifier = Modifier.size(48.dp), + ) + val title = when (state.errorType) { + INVALID_FINGERPRINT -> stringResource(R.string.bad_fingerprint) + UNKNOWN_SOURCES_DISALLOWED -> { + val context = LocalContext.current + getDisallowInstallUnknownSourcesErrorMessage(context) + } + + INVALID_INDEX -> stringResource(R.string.repo_invalid) + IO_ERROR -> stringResource(R.string.repo_io_error) + } + Text( + text = title, + style = MaterialTheme.typography.h5, + textAlign = TextAlign.Center, + ) + if (state.exception != null) Text( + text = state.exception.toString(), + style = MaterialTheme.typography.body1, + modifier = Modifier.alpha(ContentAlpha.medium), + ) + } +} + +@Preview +@Composable +fun AddRepoErrorInvalidFingerprintPreview() { + ComposeUtils.FDroidContent { + AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(INVALID_FINGERPRINT)) + } +} + +@Preview +@Composable +fun AddRepoErrorIoErrorPreview() { + ComposeUtils.FDroidContent { + AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(IO_ERROR, IOException("foo bar"))) + } +} + +@Preview +@Composable +fun AddRepoErrorInvalidIndexPreview() { + ComposeUtils.FDroidContent { + AddRepoErrorScreen( + PaddingValues(0.dp), + AddRepoError(INVALID_INDEX, RuntimeException("foo bar")) + ) + } +} + +@Preview +@Composable +fun AddRepoErrorUnknownSourcesPreview() { + ComposeUtils.FDroidContent { + AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(UNKNOWN_SOURCES_DISALLOWED)) + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoIntroScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoIntroScreen.kt new file mode 100644 index 000000000..976976065 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoIntroScreen.kt @@ -0,0 +1,223 @@ +package org.fdroid.fdroid.views.repos + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ContentAlpha +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.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.primarySurface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.zxing.client.android.Intents.Scan.MIXED_SCAN +import com.google.zxing.client.android.Intents.Scan.SCAN_TYPE +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.journeyapps.barcodescanner.ScanOptions.QR_CODE +import org.fdroid.fdroid.R +import org.fdroid.fdroid.compose.ComposeUtils.FDroidButton +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.compose.ComposeUtils.FDroidOutlineButton +import org.fdroid.repo.AddRepoError +import org.fdroid.repo.AddRepoState +import org.fdroid.repo.Added +import org.fdroid.repo.Adding +import org.fdroid.repo.Fetching +import org.fdroid.repo.None + +@Composable +fun AddRepoIntroScreen( + state: AddRepoState, + onFetchRepo: (String) -> Unit, + onAddRepo: () -> Unit, + onBackClicked: () -> Unit, +) { + Scaffold(topBar = { + TopAppBar( + elevation = 4.dp, + backgroundColor = MaterialTheme.colors.primarySurface, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon(Icons.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + title = { + Text( + text = stringResource(R.string.repo_add_title), + modifier = Modifier.alpha(ContentAlpha.high), + ) + }, + ) + }) { paddingValues -> + when (state) { + None -> AddRepoIntroContent(paddingValues, onFetchRepo) + is Fetching -> { + if (state.repo == null) { + RepoProgressScreen(paddingValues, stringResource(R.string.repo_state_fetching)) + } else { + RepoPreviewScreen(paddingValues, state, onAddRepo) + } + } + + Adding -> RepoProgressScreen(paddingValues, stringResource(R.string.repo_state_adding)) + is Added -> Box(modifier = Modifier.padding(paddingValues)) // empty UI + is AddRepoError -> AddRepoErrorScreen(paddingValues, state) + } + } +} + +@Composable +fun AddRepoIntroContent(paddingValues: PaddingValues, onFetchRepo: (String) -> Unit) { + Column( + verticalArrangement = spacedBy(16.dp), + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(paddingValues), + ) { + Text( + text = stringResource(R.string.repo_intro), + style = MaterialTheme.typography.body1, + ) + val startForResult = rememberLauncherForActivityResult(ScanContract()) { result -> + if (result.contents != null) { + onFetchRepo(result.contents) + } + } + FDroidButton( + "Scan QR code", + imageVector = Icons.Filled.QrCode, + onClick = { + startForResult.launch(ScanOptions().apply { + setPrompt("") + setBeepEnabled(true) + setOrientationLocked(false) + setDesiredBarcodeFormats(QR_CODE) + addExtra(SCAN_TYPE, MIXED_SCAN) + }) + }, + ) + val isPreview = LocalInspectionMode.current + var manualExpanded by rememberSaveable { mutableStateOf(isPreview) } + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = ButtonDefaults.MinHeight) + .clickable { manualExpanded = !manualExpanded }, + ) { + Text( + text = stringResource(R.string.repo_enter_url) + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = if (manualExpanded) { + Icons.Default.ArrowDropUp + } else { + Icons.Default.ArrowDropDown + }, + contentDescription = null, + ) + } + val textState = remember { mutableStateOf(TextFieldValue()) } + val focusRequester = remember { FocusRequester() } + AnimatedVisibility(visible = manualExpanded) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = spacedBy(16.dp), + ) { + TextField( + value = textState.value, + minLines = 2, + onValueChange = { textState.value = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + }, + ) + Row( + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = CenterVertically, + ) { + val clipboardManager = LocalClipboardManager.current + FDroidOutlineButton( + "Paste", + imageVector = Icons.Default.ContentPaste, + onClick = { + if (clipboardManager.hasText()) { + textState.value = + TextFieldValue(clipboardManager.getText()?.text ?: "") + } + }, + ) + Spacer(modifier = Modifier.weight(1f)) + FDroidButton( + text = stringResource(R.string.repo_add_add), + onClick = { onFetchRepo(textState.value.text) }, + ) + } + } + } + } +} + +@Composable +@Preview +fun AddRepoIntroScreenPreview() { + FDroidContent { + AddRepoIntroScreen(None, {}, {}) {} + } +} + +@Composable +@Preview(uiMode = UI_MODE_NIGHT_YES, widthDp = 720, heightDp = 360) +fun AddRepoIntroScreenPreviewNight() { + FDroidContent { + AddRepoIntroScreen(None, {}, {}) {} + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt new file mode 100644 index 000000000..0fc7932e6 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt @@ -0,0 +1,262 @@ +package org.fdroid.fdroid.views.repos + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +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.content.res.ResourcesCompat.getDrawable +import androidx.core.os.LocaleListCompat +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import org.fdroid.database.MinimalApp +import org.fdroid.database.Repository +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.Utils +import org.fdroid.fdroid.Utils.getDownloadRequest +import org.fdroid.fdroid.compose.ComposeUtils.FDroidButton +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.index.v2.FileV2 +import org.fdroid.repo.FetchResult.IsExistingRepository +import org.fdroid.repo.FetchResult.IsNewMirror +import org.fdroid.repo.FetchResult.IsNewRepository +import org.fdroid.repo.Fetching + +@Composable +fun RepoPreviewScreen(paddingValues: PaddingValues, state: Fetching, onAddRepo: () -> Unit) { + val isPreview = LocalInspectionMode.current + val localeList = LocaleListCompat.getDefault() + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = spacedBy(8.dp), + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth(), + ) { + item { + RepoPreviewHeader(state, onAddRepo, localeList, isPreview) + } + if (state.fetchResult == null || state.fetchResult is IsNewRepository) { + item { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = spacedBy(8.dp) + ) { + Text( + text = "Included apps:", + style = MaterialTheme.typography.body1, + ) + Text( + text = state.apps.size.toString(), + style = MaterialTheme.typography.body1, + ) + if (!state.done) LinearProgressIndicator(modifier = Modifier.weight(1f)) + } + } + items(items = state.apps, key = { it.packageName }) { app -> + RepoPreviewApp(state.repo ?: error("no repo"), app, localeList, isPreview) + } + } + } +} + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +fun RepoPreviewHeader( + state: Fetching, + onAddRepo: () -> Unit, + localeList: LocaleListCompat, + isPreview: Boolean, +) { + Column(verticalArrangement = spacedBy(8.dp)) { + val repo = state.repo ?: error("repo was null") + val res = LocalContext.current.resources + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, + ) { + if (isPreview) Image( + painter = rememberDrawablePainter( + getDrawable(res, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) else GlideImage( + model = getDownloadRequest(repo, repo.getIcon(localeList)), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) { + it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) + } + Column(horizontalAlignment = Alignment.Start) { + Text( + text = repo.getName(localeList) ?: "Unknown Repository", + maxLines = 1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.body1, + ) + Text( + text = repo.address.replaceFirst("https://", ""), + style = MaterialTheme.typography.body2, + modifier = Modifier.alpha(ContentAlpha.medium), + ) + Text( + text = Utils.formatLastUpdated(res, repo.timestamp), + style = MaterialTheme.typography.body2, + ) + } + } + if (state.canAdd) FDroidButton( + text = when (state.fetchResult) { + IsNewRepository -> stringResource(R.string.repo_add_new_title) + is IsNewMirror -> stringResource(R.string.repo_add_new_mirror) + else -> error("Unexpected fetch state: ${state.fetchResult}") + }, + onClick = onAddRepo, + modifier = Modifier.align(End) + ) else if (state.fetchResult is IsExistingRepository) { + Text( + text = stringResource(R.string.repo_exists), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.error, + ) + } + val description = if (isPreview) { + LoremIpsum(42).values.joinToString(" ") + } else { + repo.getDescription(localeList) + } + if (description != null) Text( + text = description, + style = MaterialTheme.typography.body2, + ) + } +} + +@Composable +@OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class) +fun LazyItemScope.RepoPreviewApp( + repo: Repository, + app: MinimalApp, + localeList: LocaleListCompat, + isPreview: Boolean, +) { + Card( + modifier = Modifier + .animateItemPlacement() + .fillMaxWidth(), + ) { + Row( + horizontalArrangement = spacedBy(8.dp), + modifier = Modifier.padding(8.dp), + ) { + if (isPreview) Image( + painter = rememberDrawablePainter( + getDrawable(LocalContext.current.resources, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = Modifier.size(38.dp), + ) else GlideImage( + model = getDownloadRequest(repo, app.getIcon(localeList)), + contentDescription = null, + modifier = Modifier.size(38.dp), + ) { + it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) + } + Column { + Text( + app.name ?: "Unknown app", + style = MaterialTheme.typography.body1, + ) + Text( + app.summary ?: "", + style = MaterialTheme.typography.body2, + ) + } + } + } +} + +@Preview +@Composable +fun RepoPreviewScreenFetchingPreview() { + val repo = FDroidApp.createSwapRepo("https://example.org", "foo bar") + val app1 = object : MinimalApp { + override val repoId = 0L + override val packageName = "org.example" + override val name: String = "App 1 with a long name" + override val summary: String = "Summary of App1 which can also be a bit longer" + override fun getIcon(localeList: LocaleListCompat): FileV2? = null + } + val app2 = object : MinimalApp { + override val repoId = 0L + override val packageName = "com.example" + override val name: String = "App 2 with a name that is even longer than the first app" + override val summary: String = + "Summary of App2 which can also be a bit longer, even longer than other apps." + + override fun getIcon(localeList: LocaleListCompat): FileV2? = null + } + val app3 = object : MinimalApp { + override val repoId = 0L + override val packageName = "net.example" + override val name: String = "App 3" + override val summary: String = "short summary" + + override fun getIcon(localeList: LocaleListCompat): FileV2? = null + } + FDroidContent { + RepoPreviewScreen( + PaddingValues(0.dp), + Fetching(repo, listOf(app1, app2, app3), IsNewRepository) + ) {} + } +} + +@Preview +@Composable +fun RepoPreviewScreenNewMirrorPreview() { + val repo = FDroidApp.createSwapRepo("https://example.org", "foo bar") + FDroidContent { + RepoPreviewScreen( + PaddingValues(0.dp), + Fetching(repo, emptyList(), IsNewMirror(0L, "foo")) + ) {} + } +} + +@Preview +@Composable +fun RepoPreviewScreenExistingRepoPreview() { + val repo = FDroidApp.createSwapRepo("https://example.org", "foo bar") + FDroidContent { + RepoPreviewScreen(PaddingValues(0.dp), Fetching(repo, emptyList(), IsExistingRepository)) {} + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoProgressScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoProgressScreen.kt new file mode 100644 index 000000000..a5b256fe8 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoProgressScreen.kt @@ -0,0 +1,46 @@ +package org.fdroid.fdroid.views.repos + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent + +@Composable +fun RepoProgressScreen(paddingValues: PaddingValues, text: String) { + Column( + verticalArrangement = spacedBy(16.dp, CenterVertically), + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .padding(16.dp) + .padding(paddingValues) + .fillMaxSize(), + ) { + Text( + text = text, + style = MaterialTheme.typography.h5, + ) + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } +} + +@Preview +@Composable +fun FetchingRepoScreenPreview() { + FDroidContent { + RepoProgressScreen(PaddingValues(0.dp), stringResource(R.string.repo_state_fetching)) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbaed97b9..6ddc0a05c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -171,8 +171,10 @@ This often occurs with apps installed via Google Play or other sources, if they