diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java index 407e9717f..0a3aefde4 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java @@ -12,4 +12,6 @@ public interface FeatureFlags { boolean shouldEnableDisappearingMessages(); boolean shouldEnableConnectViaBluetooth(); + + boolean shouldEnableTransferData(); } diff --git a/bramble-core/src/main/java/org/briarproject/bramble/plugin/file/RemovableDriveTaskImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/plugin/file/RemovableDriveTaskImpl.java index cae0546b0..c17cc48ac 100644 --- a/bramble-core/src/main/java/org/briarproject/bramble/plugin/file/RemovableDriveTaskImpl.java +++ b/bramble-core/src/main/java/org/briarproject/bramble/plugin/file/RemovableDriveTaskImpl.java @@ -63,8 +63,6 @@ abstract class RemovableDriveTaskImpl implements RemovableDriveTask { synchronized (lock) { observers.add(o); state = this.state; - } - if (state.isFinished()) { eventExecutor.execute(() -> o.accept(state)); } } diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java b/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java index 3bac40b0f..59ec15e23 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java @@ -29,6 +29,11 @@ public class TestFeatureFlagModule { public boolean shouldEnableConnectViaBluetooth() { return true; } + + @Override + public boolean shouldEnableTransferData() { + return true; + } }; } } diff --git a/briar-android/artwork/transfer_data.svg b/briar-android/artwork/transfer_data.svg new file mode 100644 index 000000000..af653ac7e --- /dev/null +++ b/briar-android/artwork/transfer_data.svg @@ -0,0 +1,35 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/briar-android/artwork/transfer_data_receive.svg b/briar-android/artwork/transfer_data_receive.svg new file mode 100644 index 000000000..2280b7453 --- /dev/null +++ b/briar-android/artwork/transfer_data_receive.svg @@ -0,0 +1,36 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/briar-android/artwork/transfer_data_send.svg b/briar-android/artwork/transfer_data_send.svg new file mode 100644 index 000000000..4cbdb88f9 --- /dev/null +++ b/briar-android/artwork/transfer_data_send.svg @@ -0,0 +1,37 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml index feeec1655..66c8f5268 100644 --- a/briar-android/src/main/AndroidManifest.xml +++ b/briar-android/src/main/AndroidManifest.xml @@ -437,6 +437,15 @@ android:value="org.briarproject.briar.android.navdrawer.NavDrawerActivity" /> + + + + launcher = SDK_INT >= 18 ? + registerForActivityResult(new GetMultipleImagesAdvanced(), + this::onImagesChosen) : + registerForActivityResult(new GetImageAdvanced(), uri -> { + if (uri != null) onImagesChosen(singletonList(uri)); + }); private AttachmentRetriever attachmentRetriever; private ConversationViewModel viewModel; @@ -314,9 +324,6 @@ public class ConversationActivity extends BriarActivity .make(list, R.string.introduction_sent, Snackbar.LENGTH_SHORT) .show(); - } else if (request == REQUEST_ATTACH_IMAGE && result == RESULT_OK) { - // TODO: remove cast when removing feature flag - ((TextAttachmentController) sendController).onImageReceived(data); } } @@ -375,6 +382,10 @@ public class ConversationActivity extends BriarActivity if (!featureFlags.shouldEnableConnectViaBluetooth()) { menu.findItem(R.id.action_connect_via_bluetooth).setVisible(false); } + // Transfer Data feature only supported on API 19+ + if (SDK_INT >= 19 && featureFlags.shouldEnableTransferData()) { + menu.findItem(R.id.action_transfer_data).setVisible(true); + } // enable alias and bluetooth action once available observeOnce(viewModel.getContactItem(), this, contact -> { menu.findItem(R.id.action_set_alias).setEnabled(true); @@ -415,6 +426,11 @@ public class ConversationActivity extends BriarActivity new BluetoothConnecterDialogFragment().show(fm, BluetoothConnecterDialogFragment.TAG); return true; + } else if (itemId == R.id.action_transfer_data) { + Intent intent = new Intent(this, RemovableDriveActivity.class); + intent.putExtra(CONTACT_ID, contactId.getInt()); + startActivity(intent); + return true; } else if (itemId == R.id.action_delete_all_messages) { askToDeleteAllMessages(); return true; @@ -760,8 +776,13 @@ public class ConversationActivity extends BriarActivity } @Override - public void onAttachImage(Intent intent) { - startActivityForResult(intent, REQUEST_ATTACH_IMAGE); + public void onAttachImageClicked() { + launcher.launch("image/*"); + } + + private void onImagesChosen(@Nullable List uris) { + // TODO: remove cast when removing feature flag + ((TextAttachmentController) sendController).onImageReceived(uris); } @Override diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java index 4bfeedcef..7ee1f0448 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageActivity.java @@ -2,6 +2,7 @@ package org.briarproject.briar.android.conversation; import android.content.DialogInterface.OnClickListener; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.transition.Fade; import android.transition.Transition; @@ -21,6 +22,7 @@ import org.briarproject.briar.R; import org.briarproject.briar.android.activity.ActivityComponent; import org.briarproject.briar.android.activity.BriarActivity; import org.briarproject.briar.android.attachment.AttachmentItem; +import org.briarproject.briar.android.util.ActivityLaunchers.CreateDocumentAdvanced; import org.briarproject.briar.android.util.BriarSnackbarBuilder; import org.briarproject.briar.android.view.PullDownLayout; @@ -28,6 +30,7 @@ import java.util.List; import javax.inject.Inject; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog.Builder; @@ -38,9 +41,6 @@ import androidx.fragment.app.FragmentStatePagerAdapter; import androidx.lifecycle.ViewModelProvider; import androidx.viewpager.widget.ViewPager; -import static android.content.Intent.ACTION_CREATE_DOCUMENT; -import static android.content.Intent.CATEGORY_OPENABLE; -import static android.content.Intent.EXTRA_TITLE; import static android.graphics.Color.TRANSPARENT; import static android.os.Build.VERSION.SDK_INT; import static android.view.View.GONE; @@ -51,7 +51,6 @@ import static android.view.View.VISIBLE; import static android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS; import static com.google.android.material.snackbar.Snackbar.LENGTH_LONG; import static java.util.Objects.requireNonNull; -import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_SAVE_ATTACHMENT; import static org.briarproject.briar.android.util.UiUtils.formatDateAbsolute; import static org.briarproject.briar.android.util.UiUtils.getDialogIcon; @@ -80,6 +79,10 @@ public class ImageActivity extends BriarActivity private List attachments; private MessageId conversationMessageId; + private final ActivityResultLauncher launcher = + registerForActivityResult(new CreateDocumentAdvanced(), + this::onImageUriSelected); + @Override public void injectActivity(ActivityComponent component) { component.inject(this); @@ -177,16 +180,6 @@ public class ImageActivity extends BriarActivity layout.getViewTreeObserver().removeOnGlobalLayoutListener(this); } - @Override - protected void onActivityResult(int request, int result, - @Nullable Intent data) { - super.onActivityResult(request, result, data); - if (request == REQUEST_SAVE_ATTACHMENT && result == RESULT_OK && - data != null) { - viewModel.saveImage(getVisibleAttachment(), data.getData()); - } - } - @Override public void onPullStart() { appBarLayout.animate() @@ -270,8 +263,9 @@ public class ImageActivity extends BriarActivity private void showSaveImageDialog() { OnClickListener okListener = (dialog, which) -> { if (SDK_INT >= 19) { - Intent intent = getCreationIntent(); - startActivityForResult(intent, REQUEST_SAVE_ATTACHMENT); + String name = viewModel.getFileName() + "." + + getVisibleAttachment().getExtension(); + launcher.launch(name); } else { viewModel.saveImage(getVisibleAttachment()); } @@ -285,13 +279,9 @@ public class ImageActivity extends BriarActivity builder.show(); } - @RequiresApi(api = 19) - private Intent getCreationIntent() { - Intent intent = new Intent(ACTION_CREATE_DOCUMENT); - intent.addCategory(CATEGORY_OPENABLE); - intent.setType(getVisibleAttachment().getMimeType()); - intent.putExtra(EXTRA_TITLE, viewModel.getFileName()); - return intent; + private void onImageUriSelected(@Nullable Uri uri) { + if (uri == null) return; + viewModel.saveImage(getVisibleAttachment(), uri); } private void onImageSaveStateChanged(@Nullable Boolean error) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java index 80ab00150..84e9af5d8 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ImageViewModel.java @@ -33,7 +33,6 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.concurrent.Executor; import java.util.logging.Logger; @@ -45,6 +44,7 @@ import androidx.annotation.UiThread; import static android.media.MediaScannerConnection.scanFile; import static android.os.Environment.DIRECTORY_PICTURES; import static android.os.Environment.getExternalStoragePublicDirectory; +import static java.util.Locale.US; import static java.util.Objects.requireNonNull; import static java.util.logging.Level.WARNING; import static java.util.logging.Logger.getLogger; @@ -111,7 +111,7 @@ public class ImageViewModel extends DbViewModel implements EventListener { } @UiThread - public void expectAttachments(List attachments) { + void expectAttachments(List attachments) { for (AttachmentItem item : attachments) { // no need to track items that are in a final state already if (item.getState().isFinal()) continue; @@ -226,8 +226,7 @@ public class ImageViewModel extends DbViewModel implements EventListener { } String getFileName() { - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", - Locale.getDefault()); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd_HHmmss", US); return sdf.format(new Date()); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/fragment/FinalFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/fragment/FinalFragment.java new file mode 100644 index 000000000..0cb4b4a8a --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/fragment/FinalFragment.java @@ -0,0 +1,120 @@ +package org.briarproject.briar.android.fragment; + +import android.content.res.ColorStateList; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ScrollView; +import android.widget.TextView; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.widget.ImageViewCompat; +import androidx.fragment.app.Fragment; + +import static android.view.View.FOCUS_DOWN; + +/** + * A fragment to be used at the end of a user flow + * where the user should not have the option to go back. + * Here, we only show final information + * before finishing the related activity. + */ +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class FinalFragment extends Fragment { + + public static final String TAG = FinalFragment.class.getName(); + + public static final String ARG_TITLE = "title"; + public static final String ARG_ICON = "icon"; + public static final String ARG_ICON_TINT = "iconTint"; + public static final String ARG_TEXT = "text"; + + public static FinalFragment newInstance( + @StringRes int title, + @DrawableRes int icon, + @ColorRes int iconTint, + @StringRes int text) { + FinalFragment f = new FinalFragment(); + Bundle args = new Bundle(); + args.putInt(ARG_TITLE, title); + args.putInt(ARG_ICON, icon); + args.putInt(ARG_ICON_TINT, iconTint); + args.putInt(ARG_TEXT, text); + f.setArguments(args); + return f; + } + + private ScrollView scrollView; + protected Button buttonView; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater + .inflate(R.layout.fragment_final, container, false); + + scrollView = (ScrollView) v; + ImageView iconView = v.findViewById(R.id.iconView); + TextView titleView = v.findViewById(R.id.titleView); + TextView textView = v.findViewById(R.id.textView); + buttonView = v.findViewById(R.id.button); + + Bundle args = requireArguments(); + titleView.setText(args.getInt(ARG_TITLE)); + iconView.setImageResource(args.getInt(ARG_ICON)); + int color = getResources().getColor(args.getInt(ARG_ICON_TINT)); + ColorStateList tint = ColorStateList.valueOf(color); + ImageViewCompat.setImageTintList(iconView, tint); + textView.setText(args.getInt(ARG_TEXT)); + + buttonView.setOnClickListener(view -> onBackButtonPressed()); + + AppCompatActivity a = (AppCompatActivity) requireActivity(); + a.setTitle(args.getInt(ARG_TITLE)); + ActionBar actionBar = a.getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setHomeButtonEnabled(false); + } + a.getOnBackPressedDispatcher().addCallback( + getViewLifecycleOwner(), new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + onBackButtonPressed(); + } + }); + return v; + } + + @Override + public void onStart() { + super.onStart(); + // Scroll down in case the screen is small, so the button is visible + scrollView.post(() -> scrollView.fullScroll(FOCUS_DOWN)); + } + + /** + * This is the action that the system back button + * and the button at the bottom will perform. + */ + protected void onBackButtonPressed() { + requireActivity().supportFinishAfterTransition(); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ChooserFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ChooserFragment.java new file mode 100644 index 000000000..d4aa38173 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ChooserFragment.java @@ -0,0 +1,78 @@ +package org.briarproject.briar.android.removabledrive; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ScrollView; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static android.view.View.FOCUS_DOWN; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ChooserFragment extends Fragment { + + public final static String TAG = ChooserFragment.class.getName(); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private RemovableDriveViewModel viewModel; + private ScrollView scrollView; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentActivity activity = requireActivity(); + getAndroidComponent(activity).inject(this); + viewModel = new ViewModelProvider(activity, viewModelFactory) + .get(RemovableDriveViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_transfer_data_chooser, + container, false); + + scrollView = (ScrollView) v; + Button sendButton = v.findViewById(R.id.sendButton); + sendButton.setOnClickListener(i -> viewModel.startSendData()); + + Button receiveButton = v.findViewById(R.id.receiveButton); + receiveButton.setOnClickListener(i -> viewModel.startReceiveData()); + + return v; + } + + @Override + public void onStart() { + super.onStart(); + requireActivity().setTitle(R.string.removable_drive_menu_title); + TransferDataState state = viewModel.getState().getValue(); + if (state instanceof TransferDataState.TaskAvailable) { + // we can't come back here now to start another task + // as we only support one per ViewModel instance + requireActivity().supportFinishAfterTransition(); + } else { + // Scroll down in case the screen is small, so the button is visible + scrollView.post(() -> scrollView.fullScroll(FOCUS_DOWN)); + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ErrorFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ErrorFragment.java new file mode 100644 index 000000000..c1bf9441c --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ErrorFragment.java @@ -0,0 +1,54 @@ +package org.briarproject.briar.android.removabledrive; + +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.fragment.FinalFragment; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ErrorFragment extends FinalFragment { + + public static ErrorFragment newInstance(@StringRes int title, + @StringRes int text) { + ErrorFragment f = new ErrorFragment(); + Bundle args = new Bundle(); + args.putInt(ARG_TITLE, title); + args.putInt(ARG_ICON, R.drawable.alerts_and_states_error); + args.putInt(ARG_ICON_TINT, R.color.briar_red_500); + args.putInt(ARG_TEXT, text); + f.setArguments(args); + return f; + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = super.onCreateView(inflater, container, savedInstanceState); + buttonView.setText(R.string.try_again_button); + return v; + } + + @Override + protected void onBackButtonPressed() { + // Re-create this activity when going back in failed state. + // This will also re-create the ViewModel, so we start fresh. + Intent i = requireActivity().getIntent(); + i.setFlags(FLAG_ACTIVITY_CLEAR_TOP); + startActivity(i); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ReceiveFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ReceiveFragment.java new file mode 100644 index 000000000..76db99280 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ReceiveFragment.java @@ -0,0 +1,110 @@ +package org.briarproject.briar.android.removabledrive; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.ScrollView; +import android.widget.Toast; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.util.ActivityLaunchers.GetContentAdvanced; + +import javax.inject.Inject; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static android.view.View.FOCUS_DOWN; +import static android.view.View.VISIBLE; +import static android.widget.Toast.LENGTH_LONG; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ReceiveFragment extends Fragment { + + final static String TAG = ReceiveFragment.class.getName(); + + private final ActivityResultLauncher launcher = + registerForActivityResult(new GetContentAdvanced(), + this::onDocumentChosen); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private RemovableDriveViewModel viewModel; + private ScrollView scrollView; + private Button button; + private ProgressBar progressBar; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentActivity activity = requireActivity(); + getAndroidComponent(activity).inject(this); + viewModel = new ViewModelProvider(activity, viewModelFactory) + .get(RemovableDriveViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_transfer_data_receive, + container, false); + + scrollView = (ScrollView) v; + progressBar = v.findViewById(R.id.progressBar); + button = v.findViewById(R.id.fileButton); + button.setOnClickListener(view -> + launcher.launch("*/*") + ); + viewModel.getOldTaskResumedEvent() + .observeEvent(getViewLifecycleOwner(), this::onOldTaskResumed); + viewModel.getState() + .observe(getViewLifecycleOwner(), this::onStateChanged); + return v; + } + + @Override + public void onStart() { + super.onStart(); + requireActivity().setTitle(R.string.removable_drive_title_receive); + // Scroll down in case the screen is small, so the button is visible + scrollView.post(() -> scrollView.fullScroll(FOCUS_DOWN)); + } + + private void onOldTaskResumed(boolean resumed) { + if (resumed) { + Toast.makeText(requireContext(), + R.string.removable_drive_ongoing, LENGTH_LONG).show(); + } + } + + private void onStateChanged(TransferDataState state) { + if (state instanceof TransferDataState.NoDataToSend) { + throw new IllegalStateException(); + } else if (state instanceof TransferDataState.Ready) { + button.setEnabled(true); + } else if (state instanceof TransferDataState.TaskAvailable) { + button.setEnabled(false); + progressBar.setVisibility(VISIBLE); + } + } + + private void onDocumentChosen(@Nullable Uri uri) { + if (uri == null) return; + viewModel.importData(uri); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveActivity.java new file mode 100644 index 000000000..501f458fb --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveActivity.java @@ -0,0 +1,144 @@ +package org.briarproject.briar.android.removabledrive; + +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; + +import org.briarproject.bramble.api.contact.ContactId; +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.bramble.api.plugin.file.RemovableDriveTask; +import org.briarproject.briar.R; +import org.briarproject.briar.android.activity.ActivityComponent; +import org.briarproject.briar.android.activity.BriarActivity; +import org.briarproject.briar.android.fragment.FinalFragment; +import org.briarproject.briar.android.removabledrive.RemovableDriveViewModel.Action; +import org.briarproject.briar.android.removabledrive.TransferDataState.TaskAvailable; + +import javax.inject.Inject; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.StringRes; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; + +import static java.util.Objects.requireNonNull; +import static org.briarproject.briar.android.conversation.ConversationActivity.CONTACT_ID; +import static org.briarproject.briar.android.util.UiUtils.showFragment; + +@RequiresApi(19) +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class RemovableDriveActivity extends BriarActivity { + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private RemovableDriveViewModel viewModel; + + @Override + public void injectActivity(ActivityComponent component) { + component.inject(this); + + viewModel = new ViewModelProvider(this, viewModelFactory) + .get(RemovableDriveViewModel.class); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = requireNonNull(getIntent()); + int contactId = intent.getIntExtra(CONTACT_ID, -1); + if (contactId == -1) throw new IllegalArgumentException("ContactId"); + viewModel.setContactId(new ContactId(contactId)); + + setContentView(R.layout.activity_fragment_container); + + viewModel.getActionEvent().observeEvent(this, this::onActionReceived); + viewModel.getState().observe(this, this::onStateChanged); + + if (savedInstanceState == null) { + Fragment f = new ChooserFragment(); + String tag = ChooserFragment.TAG; + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragmentContainer, f, tag) + .commit(); + } + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void onActionReceived(Action action) { + Fragment f; + String tag; + if (action == Action.SEND) { + f = new SendFragment(); + tag = SendFragment.TAG; + } else if (action == Action.RECEIVE) { + f = new ReceiveFragment(); + tag = ReceiveFragment.TAG; + } else throw new AssertionError(); + showFragment(getSupportFragmentManager(), f, tag); + } + + private void onStateChanged(TransferDataState state) { + if (!(state instanceof TaskAvailable)) return; + RemovableDriveTask.State s = ((TaskAvailable) state).state; + if (s.isFinished()) { + FragmentManager fm = getSupportFragmentManager(); + Action action; + // We can't simply rely on viewModel.getActionEvent() + // as that might have been destroyed in the meantime. + if (fm.findFragmentByTag(SendFragment.TAG) != null) { + action = Action.SEND; + } else if (fm.findFragmentByTag(ReceiveFragment.TAG) != null) { + action = Action.RECEIVE; + } else { + action = requireNonNull( + viewModel.getActionEvent().getLastValue()); + } + Fragment f; + if (s.isSuccess()) f = getSuccessFragment(action); + else f = getErrorFragment(action); + showFragment(getSupportFragmentManager(), f, FinalFragment.TAG); + } + } + + private Fragment getSuccessFragment(Action action) { + @StringRes int title, text; + if (action == Action.SEND) { + title = R.string.removable_drive_success_send_title; + text = R.string.removable_drive_success_send_text; + } else if (action == Action.RECEIVE) { + title = R.string.removable_drive_success_receive_title; + text = R.string.removable_drive_success_receive_text; + } else throw new AssertionError(); + return FinalFragment.newInstance(title, + R.drawable.ic_check_circle_outline, R.color.briar_brand_green, + text); + } + + private Fragment getErrorFragment(Action action) { + @StringRes int title, text; + if (action == Action.SEND) { + title = R.string.removable_drive_error_send_title; + text = R.string.removable_drive_error_send_text; + } else if (action == Action.RECEIVE) { + title = R.string.removable_drive_error_receive_title; + text = R.string.removable_drive_error_receive_text; + } else throw new AssertionError(); + return ErrorFragment.newInstance(title, text); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveViewModel.java index 20765cf51..cd64932fb 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveViewModel.java @@ -5,97 +5,186 @@ import android.net.Uri; import org.briarproject.bramble.api.Consumer; import org.briarproject.bramble.api.contact.ContactId; +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.db.TransactionManager; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.plugin.file.RemovableDriveManager; import org.briarproject.bramble.api.plugin.file.RemovableDriveTask; import org.briarproject.bramble.api.plugin.file.RemovableDriveTask.State; import org.briarproject.bramble.api.properties.TransportProperties; +import org.briarproject.bramble.api.system.AndroidExecutor; +import org.briarproject.briar.android.viewmodel.DbViewModel; +import org.briarproject.briar.android.viewmodel.LiveEvent; +import org.briarproject.briar.android.viewmodel.MutableLiveEvent; import java.text.SimpleDateFormat; import java.util.Date; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Logger; +import java.util.concurrent.Executor; import javax.annotation.Nullable; import javax.inject.Inject; -import androidx.lifecycle.AndroidViewModel; +import androidx.annotation.UiThread; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import static java.util.Locale.US; -import static java.util.logging.Logger.getLogger; +import static java.util.Objects.requireNonNull; import static org.briarproject.bramble.api.plugin.file.RemovableDriveConstants.PROP_URI; +@UiThread @NotNullByDefault -class RemovableDriveViewModel extends AndroidViewModel { +class RemovableDriveViewModel extends DbViewModel { - private static final Logger LOG = - getLogger(RemovableDriveViewModel.class.getName()); + enum Action {SEND, RECEIVE} private final RemovableDriveManager manager; - private final ConcurrentHashMap, RemovableDriveTask> - observers = new ConcurrentHashMap<>(); + private final MutableLiveEvent action = new MutableLiveEvent<>(); + private final MutableLiveEvent oldTaskResumed = + new MutableLiveEvent<>(); + private final MutableLiveData state = + new MutableLiveData<>(); + @Nullable + private ContactId contactId = null; + @Nullable + private RemovableDriveTask task = null; + @Nullable + private Consumer taskObserver = null; @Inject - RemovableDriveViewModel(Application app, + RemovableDriveViewModel( + Application app, + @DatabaseExecutor Executor dbExecutor, + LifecycleManager lifecycleManager, + TransactionManager db, + AndroidExecutor androidExecutor, RemovableDriveManager removableDriveManager) { - super(app); - + super(app, dbExecutor, lifecycleManager, db, androidExecutor); this.manager = removableDriveManager; } - String getFileName() { - SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS", US); - return sdf.format(new Date()); - } - - - LiveData write(ContactId contactId, Uri uri) { - TransportProperties p = new TransportProperties(); - p.put(PROP_URI, uri.toString()); - return observe(manager.startWriterTask(contactId, p)); - } - - LiveData read(Uri uri) { - TransportProperties p = new TransportProperties(); - p.put(PROP_URI, uri.toString()); - return observe(manager.startReaderTask(p)); - } - - @Nullable - LiveData ongoingWrite() { - RemovableDriveTask task = manager.getCurrentWriterTask(); - if (task == null) { - return null; - } - return observe(task); - } - - @Nullable - LiveData ongoingRead() { - RemovableDriveTask task = manager.getCurrentReaderTask(); - if (task == null) { - return null; - } - return observe(task); - } - - private LiveData observe(RemovableDriveTask task) { - MutableLiveData state = new MutableLiveData<>(); - Consumer observer = state::postValue; - task.addObserver(observer); - observers.put(observer, task); - return state; - } - @Override protected void onCleared() { - for (Map.Entry, RemovableDriveTask> entry - : observers.entrySet()) { - entry.getValue().removeObserver(entry.getKey()); + if (task != null) { + // when we have a task, we must have an observer for it + Consumer observer = requireNonNull(taskObserver); + task.removeObserver(observer); } } + + /** + * Set this as soon as it becomes available. + */ + void setContactId(ContactId contactId) { + this.contactId = contactId; + } + + @UiThread + void startSendData() { + action.setEvent(Action.SEND); + + // check if there is already a send/write task + task = manager.getCurrentWriterTask(); + if (task == null) { + // check if there's even something to send + ContactId c = requireNonNull(contactId); + runOnDbThread(() -> { + try { + if (!manager.isTransportSupportedByContact(c)) { + state.postValue(new TransferDataState.NotSupported()); + } else if (manager.isWriterTaskNeeded(c)) { + state.postValue(new TransferDataState.Ready()); + } else { + state.postValue(new TransferDataState.NoDataToSend()); + } + } catch (DbException e) { + handleException(e); + } + }); + } else { + // observe old task + taskObserver = + s -> state.setValue(new TransferDataState.TaskAvailable(s)); + task.addObserver(taskObserver); + oldTaskResumed.setEvent(true); + } + } + + @UiThread + void startReceiveData() { + action.setEvent(Action.RECEIVE); + + // check if there is already a receive/read task + task = manager.getCurrentReaderTask(); + if (task == null) { + state.setValue(new TransferDataState.Ready()); + } else { + // observe old task + taskObserver = + s -> state.setValue(new TransferDataState.TaskAvailable(s)); + task.addObserver(taskObserver); + oldTaskResumed.setEvent(true); + } + } + + String getFileName() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", US); + return sdf.format(new Date()); + } + + /** + * Call this only when in {@link TransferDataState.Ready}. + */ + @UiThread + void exportData(Uri uri) { + // starting an action more than once is not supported for simplicity + if (task != null) throw new IllegalStateException(); + + // from now on, we are not re-usable + // (because gets a state update right away on the UiThread) + taskObserver = + s -> state.setValue(new TransferDataState.TaskAvailable(s)); + + // start the writer task for this contact and observe it + TransportProperties p = new TransportProperties(); + p.put(PROP_URI, uri.toString()); + ContactId c = requireNonNull(contactId); + task = manager.startWriterTask(c, p); + task.addObserver(taskObserver); + } + + /** + * Call this only when in {@link TransferDataState.Ready}. + */ + @UiThread + void importData(Uri uri) { + // starting an action more than once is not supported for simplicity + if (task != null) throw new IllegalStateException(); + + // from now on, we are not re-usable + // (because gets a state update right away on the UiThread) + taskObserver = + s -> state.setValue(new TransferDataState.TaskAvailable(s)); + + TransportProperties p = new TransportProperties(); + p.put(PROP_URI, uri.toString()); + task = manager.startReaderTask(p); + task.addObserver(taskObserver); + } + + LiveEvent getActionEvent() { + return action; + } + + LiveEvent getOldTaskResumedEvent() { + return oldTaskResumed; + } + + LiveData getState() { + return state; + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/SendFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/SendFragment.java new file mode 100644 index 000000000..1d8b872cb --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/SendFragment.java @@ -0,0 +1,133 @@ +package org.briarproject.briar.android.removabledrive; + +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.bramble.api.plugin.file.RemovableDriveTask; +import org.briarproject.briar.R; +import org.briarproject.briar.android.util.ActivityLaunchers.CreateDocumentAdvanced; + +import javax.inject.Inject; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static android.os.Build.VERSION.SDK_INT; +import static android.view.View.FOCUS_DOWN; +import static android.view.View.VISIBLE; +import static android.widget.Toast.LENGTH_LONG; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class SendFragment extends Fragment { + + final static String TAG = SendFragment.class.getName(); + + private final ActivityResultLauncher launcher = + registerForActivityResult(new CreateDocumentAdvanced(), + this::onDocumentCreated); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private RemovableDriveViewModel viewModel; + private ScrollView scrollView; + private TextView introTextView; + private Button button; + private ProgressBar progressBar; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentActivity activity = requireActivity(); + getAndroidComponent(activity).inject(this); + viewModel = new ViewModelProvider(activity, viewModelFactory) + .get(RemovableDriveViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.fragment_transfer_data_send, + container, false); + + scrollView = (ScrollView) v; + introTextView = v.findViewById(R.id.introTextView); + progressBar = v.findViewById(R.id.progressBar); + button = v.findViewById(R.id.fileButton); + button.setOnClickListener(view -> + launcher.launch(viewModel.getFileName()) + ); + + viewModel.getOldTaskResumedEvent() + .observeEvent(getViewLifecycleOwner(), this::onOldTaskResumed); + viewModel.getState() + .observe(getViewLifecycleOwner(), this::onStateChanged); + + return v; + } + + @Override + public void onStart() { + super.onStart(); + requireActivity().setTitle(R.string.removable_drive_title_send); + // Scroll down in case the screen is small, so the button is visible + scrollView.post(() -> scrollView.fullScroll(FOCUS_DOWN)); + } + + private void onOldTaskResumed(boolean resumed) { + if (resumed) { + Toast.makeText(requireContext(), + R.string.removable_drive_ongoing, LENGTH_LONG).show(); + } + } + + private void onStateChanged(TransferDataState state) { + if (state instanceof TransferDataState.NoDataToSend) { + introTextView.setText(R.string.removable_drive_send_no_data); + button.setEnabled(false); + } else if (state instanceof TransferDataState.NotSupported) { + introTextView.setText(R.string.removable_drive_send_not_supported); + button.setEnabled(false); + } else if (state instanceof TransferDataState.Ready) { + button.setEnabled(true); + } else if (state instanceof TransferDataState.TaskAvailable) { + button.setEnabled(false); + RemovableDriveTask.State s = + ((TransferDataState.TaskAvailable) state).state; + if (s.getTotal() > 0L && progressBar.getVisibility() != VISIBLE) { + progressBar.setVisibility(VISIBLE); + progressBar.setMax(100); + } + int progress = s.getTotal() == 0 ? 0 : // no div by null + (int) ((double) s.getDone() / s.getTotal() * 100); + if (SDK_INT >= 24) { + progressBar.setProgress(progress, true); + } else { + progressBar.setProgress(progress); + } + } + } + + private void onDocumentCreated(@Nullable Uri uri) { + if (uri == null) return; + viewModel.exportData(uri); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveModule.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataModule.java similarity index 70% rename from briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveModule.java rename to briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataModule.java index 44dd6e69a..5e2d03c2b 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/RemovableDriveModule.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataModule.java @@ -8,11 +8,12 @@ import dagger.Module; import dagger.multibindings.IntoMap; @Module -public interface RemovableDriveModule { +public interface TransferDataModule { @Binds @IntoMap @ViewModelKey(RemovableDriveViewModel.class) - ViewModel bindRemovableDriveViewModel(RemovableDriveViewModel removableDriveViewModel); + ViewModel bindRemovableDriveViewModel( + RemovableDriveViewModel removableDriveViewModel); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataState.java b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataState.java new file mode 100644 index 000000000..e8ac85058 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataState.java @@ -0,0 +1,40 @@ +package org.briarproject.briar.android.removabledrive; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.plugin.file.RemovableDriveTask; + +@NotNullByDefault +abstract class TransferDataState { + + /** + * There is nothing we can send to the chosen contact. + * This only applies to sending data, but not to receiving it. + */ + static class NoDataToSend extends TransferDataState { + } + + /** + * The chosen contact does not support the transport, yet. + * So we can't send them data this way. + */ + static class NotSupported extends TransferDataState { + } + + /** + * We are ready to let the user select a file for sending or receiving data. + */ + static class Ready extends TransferDataState { + } + + /** + * A task with state information is available and should be shown in the UI. + */ + static class TaskAvailable extends TransferDataState { + final RemovableDriveTask.State state; + + TaskAvailable(RemovableDriveTask.State state) { + this.state = state; + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java index e3769e9e2..df8543c1a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java @@ -1,7 +1,6 @@ package org.briarproject.briar.android.settings; import android.content.Context; -import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.view.View; @@ -9,9 +8,11 @@ import android.view.View; import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; import org.briarproject.briar.R; +import org.briarproject.briar.android.util.ActivityLaunchers.GetImageAdvanced; import javax.inject.Inject; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; @@ -20,12 +21,9 @@ import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceGroup; -import static android.app.Activity.RESULT_OK; import static java.util.Objects.requireNonNull; import static org.briarproject.briar.android.AppModule.getAndroidComponent; import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD; -import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_AVATAR_IMAGE; -import static org.briarproject.briar.android.util.UiUtils.createSelectImageIntent; import static org.briarproject.briar.android.util.UiUtils.triggerFeedback; @MethodsNotNullByDefault @@ -45,6 +43,10 @@ public class SettingsFragment extends PreferenceFragmentCompat { private SettingsViewModel viewModel; private AvatarPreference prefAvatar; + private final ActivityResultLauncher launcher = + registerForActivityResult(new GetImageAdvanced(), + this::onImageSelected); + @Override public void onAttach(@NonNull Context context) { super.onAttach(context); @@ -60,8 +62,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { prefAvatar = requireNonNull(findPreference(PREF_KEY_AVATAR)); if (viewModel.shouldEnableProfilePictures()) { prefAvatar.setOnPreferenceClickListener(preference -> { - Intent intent = createSelectImageIntent(false); - startActivityForResult(intent, REQUEST_AVATAR_IMAGE); + launcher.launch("image/*"); return true; }); } else { @@ -102,20 +103,11 @@ public class SettingsFragment extends PreferenceFragmentCompat { requireActivity().setTitle(R.string.settings_button); } - @Override - public void onActivityResult(int request, int result, - @Nullable Intent data) { - super.onActivityResult(request, result, data); - if (request == REQUEST_AVATAR_IMAGE && result == RESULT_OK) { - if (data == null) return; - Uri uri = data.getData(); - if (uri == null) return; - - DialogFragment dialog = - ConfirmAvatarDialogFragment.newInstance(uri); - dialog.show(getParentFragmentManager(), - ConfirmAvatarDialogFragment.TAG); - } + private void onImageSelected(@Nullable Uri uri) { + if (uri == null) return; + DialogFragment dialog = ConfirmAvatarDialogFragment.newInstance(uri); + dialog.show(getParentFragmentManager(), + ConfirmAvatarDialogFragment.TAG); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/ActivityLaunchers.java b/briar-android/src/main/java/org/briarproject/briar/android/util/ActivityLaunchers.java new file mode 100644 index 000000000..cd7456c89 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/ActivityLaunchers.java @@ -0,0 +1,88 @@ +package org.briarproject.briar.android.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; + +import androidx.activity.result.contract.ActivityResultContract; +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument; +import androidx.activity.result.contract.ActivityResultContracts.GetContent; +import androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents; +import androidx.annotation.Nullable; + +import static android.app.Activity.RESULT_CANCELED; +import static android.bluetooth.BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE; +import static android.bluetooth.BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION; +import static android.content.Intent.EXTRA_MIME_TYPES; +import static android.os.Build.VERSION.SDK_INT; +import static org.briarproject.bramble.util.AndroidUtils.getSupportedImageContentTypes; + +@NotNullByDefault +public class ActivityLaunchers { + + public static class CreateDocumentAdvanced extends CreateDocument { + @Override + public Intent createIntent(Context context, String input) { + Intent i = super.createIntent(context, input); + putShowAdvancedExtra(i); + return i; + } + } + + public static class GetContentAdvanced extends GetContent { + @Override + public Intent createIntent(Context context, String input) { + Intent i = super.createIntent(context, input); + putShowAdvancedExtra(i); + return i; + } + } + + public static class GetImageAdvanced extends GetContent { + @Override + public Intent createIntent(Context context, String input) { + Intent i = super.createIntent(context, input); + putShowAdvancedExtra(i); + i.setType("image/*"); + if (SDK_INT >= 19) + i.putExtra(EXTRA_MIME_TYPES, getSupportedImageContentTypes()); + return i; + } + } + + @TargetApi(18) + public static class GetMultipleImagesAdvanced extends GetMultipleContents { + @Override + public Intent createIntent(Context context, String input) { + Intent i = super.createIntent(context, input); + putShowAdvancedExtra(i); + i.setType("image/*"); + if (SDK_INT >= 19) + i.putExtra(EXTRA_MIME_TYPES, getSupportedImageContentTypes()); + return i; + } + } + + public static class RequestBluetoothDiscoverable + extends ActivityResultContract { + @Override + public Intent createIntent(Context context, Integer duration) { + Intent i = new Intent(ACTION_REQUEST_DISCOVERABLE); + i.putExtra(EXTRA_DISCOVERABLE_DURATION, duration); + return i; + } + + @Override + public Boolean parseResult(int resultCode, @Nullable Intent intent) { + return resultCode != RESULT_CANCELED; + } + } + + private static void putShowAdvancedExtra(Intent i) { + i.putExtra(SDK_INT <= 28 ? "android.content.extra.SHOW_ADVANCED" : + "android.provider.extra.SHOW_ADVANCED", true); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/RequestBluetoothDiscoverable.java b/briar-android/src/main/java/org/briarproject/briar/android/util/RequestBluetoothDiscoverable.java deleted file mode 100644 index 8288aedac..000000000 --- a/briar-android/src/main/java/org/briarproject/briar/android/util/RequestBluetoothDiscoverable.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.briarproject.briar.android.util; - - -import android.content.Context; -import android.content.Intent; - -import org.briarproject.bramble.api.nullsafety.NotNullByDefault; - -import androidx.activity.result.contract.ActivityResultContract; -import androidx.annotation.Nullable; - -import static android.app.Activity.RESULT_CANCELED; -import static android.bluetooth.BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE; -import static android.bluetooth.BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION; - -@NotNullByDefault -public class RequestBluetoothDiscoverable - extends ActivityResultContract { - - @Override - public Intent createIntent(Context context, Integer duration) { - Intent i = new Intent(ACTION_REQUEST_DISCOVERABLE); - i.putExtra(EXTRA_DISCOVERABLE_DURATION, duration); - return i; - } - - @Override - public Boolean parseResult(int resultCode, @Nullable Intent intent) { - return resultCode != RESULT_CANCELED; - } -} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java index e89b07100..9661f1e01 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java @@ -57,7 +57,9 @@ import androidx.core.content.ContextCompat; import androidx.core.hardware.fingerprint.FingerprintManagerCompat; import androidx.core.text.HtmlCompat; import androidx.core.util.Consumer; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; @@ -65,12 +67,7 @@ import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; import static android.content.Context.KEYGUARD_SERVICE; import static android.content.Context.POWER_SERVICE; -import static android.content.Intent.ACTION_GET_CONTENT; -import static android.content.Intent.ACTION_OPEN_DOCUMENT; import static android.content.Intent.CATEGORY_DEFAULT; -import static android.content.Intent.CATEGORY_OPENABLE; -import static android.content.Intent.EXTRA_ALLOW_MULTIPLE; -import static android.content.Intent.EXTRA_MIME_TYPES; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.os.Build.MANUFACTURER; import static android.os.Build.VERSION.SDK_INT; @@ -105,7 +102,6 @@ import static androidx.core.view.ViewCompat.LAYOUT_DIRECTION_RTL; import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.DAYS; import static java.util.logging.Level.WARNING; -import static org.briarproject.bramble.util.AndroidUtils.getSupportedImageContentTypes; import static org.briarproject.bramble.util.LogUtils.logException; import static org.briarproject.briar.BuildConfig.APPLICATION_ID; import static org.briarproject.briar.android.TestingConstants.EXPIRY_DATE; @@ -135,6 +131,17 @@ public class UiUtils { imm.hideSoftInputFromWindow(view.getWindowToken(), 0); } + public static void showFragment(FragmentManager fm, Fragment f, + @Nullable String tag) { + fm.beginTransaction() + .setCustomAnimations(R.anim.step_next_in, + R.anim.step_previous_out, R.anim.step_previous_in, + R.anim.step_next_out) + .replace(R.id.fragmentContainer, f, tag) + .addToBackStack(tag) + .commit(); + } + public static String getContactDisplayName(Author author, @Nullable String alias) { String name = author.getName(); @@ -297,18 +304,6 @@ public class UiUtils { }; } - public static Intent createSelectImageIntent(boolean allowMultiple) { - Intent intent = new Intent(SDK_INT >= 19 ? - ACTION_OPEN_DOCUMENT : ACTION_GET_CONTENT); - intent.setType("image/*"); - intent.addCategory(CATEGORY_OPENABLE); - if (SDK_INT >= 19) - intent.putExtra(EXTRA_MIME_TYPES, getSupportedImageContentTypes()); - if (allowMultiple && SDK_INT >= 18) - intent.putExtra(EXTRA_ALLOW_MULTIPLE, true); - return intent; - } - public static void showOnboardingDialog(Context ctx, String text) { new AlertDialog.Builder(ctx, R.style.OnboardingDialogTheme) .setMessage(text) @@ -334,6 +329,11 @@ public class UiUtils { return i; } + public static void putShowAdvancedExtra(Intent i) { + i.putExtra(SDK_INT <= 28 ? "android.content.extra.SHOW_ADVANCED" : + "android.provider.extra.SHOW_ADVANCED", true); + } + /** * @return true if location is enabled, * or it isn't required due to this being a SDK < 28 device. diff --git a/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java b/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java index 9cb3210f3..f6b751412 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/view/TextAttachmentController.java @@ -1,7 +1,6 @@ package org.briarproject.briar.android.view; import android.app.Activity; -import android.content.ClipData; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; @@ -15,13 +14,13 @@ import org.briarproject.briar.R; import org.briarproject.briar.android.attachment.AttachmentItemResult; import org.briarproject.briar.android.attachment.AttachmentManager; import org.briarproject.briar.android.attachment.AttachmentResult; -import org.briarproject.briar.android.util.UiUtils; import org.briarproject.briar.android.view.ImagePreview.ImagePreviewListener; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.appcompat.app.AlertDialog.Builder; @@ -31,7 +30,6 @@ import androidx.lifecycle.Observer; import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; import uk.co.samuelwall.materialtaptargetprompt.MaterialTapTargetPrompt; -import static android.os.Build.VERSION.SDK_INT; import static android.view.View.GONE; import static android.widget.Toast.LENGTH_LONG; import static androidx.core.content.ContextCompat.getColor; @@ -143,38 +141,23 @@ public class TextAttachmentController extends TextSendController builder.show(); return; } - Intent intent = UiUtils.createSelectImageIntent(true); if (attachmentListener.getLifecycle().getCurrentState() != DESTROYED) { - attachmentListener.onAttachImage(intent); + attachmentListener.onAttachImageClicked(); } } /** - * This is called with the result Intent returned by the Activity started - * with {@link UiUtils#createSelectImageIntent(boolean)}. - *

* This method must be called at most once per call to - * {@link AttachmentListener#onAttachImage(Intent)}. - * Normally, this is true if called from + * {@link AttachmentListener#onAttachImageClicked()}. + * Normally, this is true if called from the launcher equivalent of * {@link Activity#onActivityResult(int, int, Intent)} since this is called - * at most once per call to - * {@link Activity#startActivityForResult(Intent, int)}. + * at most once per call to {@link ActivityResultLauncher#launch(Object)}. */ @SuppressWarnings("JavadocReference") - public void onImageReceived(@Nullable Intent resultData) { - if (resultData == null) return; + public void onImageReceived(@Nullable List newUris) { + if (newUris == null) return; if (loadingUris || !imageUris.isEmpty()) throw new AssertionError(); - List newUris = new ArrayList<>(); - if (resultData.getData() != null) { - newUris.add(resultData.getData()); - onNewUris(false, newUris); - } else if (SDK_INT >= 18 && resultData.getClipData() != null) { - ClipData clipData = resultData.getClipData(); - for (int i = 0; i < clipData.getItemCount(); i++) { - newUris.add(clipData.getItemAt(i).getUri()); - } - onNewUris(false, newUris); - } + onNewUris(false, newUris); } private void onNewUris(boolean restart, List newUris) { @@ -329,7 +312,7 @@ public class TextAttachmentController extends TextSendController @UiThread public interface AttachmentListener extends SendListener { - void onAttachImage(Intent intent); + void onAttachImageClicked(); void onTooManyAttachments(); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/DbViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/DbViewModel.java index 087e95f64..6e39b347c 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/DbViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/viewmodel/DbViewModel.java @@ -23,7 +23,6 @@ import java.util.logging.Logger; import javax.annotation.concurrent.Immutable; import androidx.annotation.AnyThread; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.arch.core.util.Function; @@ -50,7 +49,7 @@ public abstract class DbViewModel extends AndroidViewModel { protected final AndroidExecutor androidExecutor; public DbViewModel( - @NonNull Application application, + Application application, @DatabaseExecutor Executor dbExecutor, LifecycleManager lifecycleManager, TransactionManager db, diff --git a/briar-android/src/main/res/drawable/ic_arrow_back.xml b/briar-android/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..3d25eefc0 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/briar-android/src/main/res/drawable/ic_check_circle_outline.xml b/briar-android/src/main/res/drawable/ic_check_circle_outline.xml new file mode 100644 index 000000000..bfac35d68 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_check_circle_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/briar-android/src/main/res/drawable/ic_transfer_data.xml b/briar-android/src/main/res/drawable/ic_transfer_data.xml new file mode 100644 index 000000000..68d313e52 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_transfer_data.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/briar-android/src/main/res/drawable/ic_transfer_data_receive.xml b/briar-android/src/main/res/drawable/ic_transfer_data_receive.xml new file mode 100644 index 000000000..87d43457f --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_transfer_data_receive.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/briar-android/src/main/res/drawable/ic_transfer_data_send.xml b/briar-android/src/main/res/drawable/ic_transfer_data_send.xml new file mode 100644 index 000000000..59b6f35ba --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_transfer_data_send.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/briar-android/src/main/res/layout/fragment_final.xml b/briar-android/src/main/res/layout/fragment_final.xml new file mode 100644 index 000000000..7bf37a536 --- /dev/null +++ b/briar-android/src/main/res/layout/fragment_final.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + +