From 928b951c2551e098e26e5ecb3db30550898ef7ab Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 9 Jun 2021 17:58:14 -0300 Subject: [PATCH] Transfer Data UI --- briar-android/src/main/AndroidManifest.xml | 2 +- .../briar/android/AndroidComponent.java | 9 + .../briarproject/briar/android/AppModule.java | 4 +- .../briar/android/activity/RequestCodes.java | 2 - .../conversation/ConversationActivity.java | 14 +- .../briar/android/fragment/FinalFragment.java | 108 +++++++++++ .../removabledrive/ChooserFragment.java | 70 +++++++ .../android/removabledrive/ErrorFragment.java | 54 ++++++ .../removabledrive/ReceiveFragment.java | 106 +++++++++++ .../RemovableDriveActivity.java | 179 +++++++----------- .../RemovableDriveViewModel.java | 171 ++++++++++++----- .../android/removabledrive/SendFragment.java | 114 +++++++++++ ...iveModule.java => TransferDataModule.java} | 5 +- .../removabledrive/TransferDataState.java | 39 ++++ .../briar/android/util/UiUtils.java | 13 ++ .../src/main/res/drawable/ic_arrow_back.xml | 11 ++ .../main/res/drawable/ic_arrow_forward.xml | 11 ++ .../res/drawable/ic_check_circle_outline.xml | 10 + .../src/main/res/drawable/ic_flash_drive.xml | 10 + .../main/res/drawable/ic_phone_android.xml | 10 + .../main/res/drawable/ic_transfer_data.xml | 21 ++ .../res/layout/activity_removable_drive.xml | 52 ----- .../src/main/res/layout/fragment_final.xml | 66 +++++++ .../layout/fragment_transfer_data_chooser.xml | 57 ++++++ .../layout/fragment_transfer_data_receive.xml | 105 ++++++++++ .../layout/fragment_transfer_data_send.xml | 108 +++++++++++ .../main/res/menu/conversation_actions.xml | 11 +- briar-android/src/main/res/values/strings.xml | 24 +++ 28 files changed, 1155 insertions(+), 231 deletions(-) create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/fragment/FinalFragment.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ChooserFragment.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ErrorFragment.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ReceiveFragment.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/removabledrive/SendFragment.java rename briar-android/src/main/java/org/briarproject/briar/android/removabledrive/{RemovableDriveModule.java => TransferDataModule.java} (70%) create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataState.java create mode 100644 briar-android/src/main/res/drawable/ic_arrow_back.xml create mode 100644 briar-android/src/main/res/drawable/ic_arrow_forward.xml create mode 100644 briar-android/src/main/res/drawable/ic_check_circle_outline.xml create mode 100644 briar-android/src/main/res/drawable/ic_flash_drive.xml create mode 100644 briar-android/src/main/res/drawable/ic_phone_android.xml create mode 100644 briar-android/src/main/res/drawable/ic_transfer_data.xml delete mode 100644 briar-android/src/main/res/layout/activity_removable_drive.xml create mode 100644 briar-android/src/main/res/layout/fragment_final.xml create mode 100644 briar-android/src/main/res/layout/fragment_transfer_data_chooser.xml create mode 100644 briar-android/src/main/res/layout/fragment_transfer_data_receive.xml create mode 100644 briar-android/src/main/res/layout/fragment_transfer_data_send.xml diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml index e554f25bb..ffac6071b 100644 --- a/briar-android/src/main/AndroidManifest.xml +++ b/briar-android/src/main/AndroidManifest.xml @@ -438,7 +438,7 @@ = 19) { // TODO also hide behind feature flag + 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,17 +419,17 @@ 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; } else if (itemId == R.id.action_social_remove_person) { askToRemoveContact(); return true; - } else if (itemId == R.id.action_removable_drive_write) { - Intent intent = new Intent(this, RemovableDriveActivity.class); - intent.putExtra(CONTACT_ID, contactId.getInt()); - startActivity(intent); - return true; } return super.onOptionsItemSelected(item); } 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..d95bc7a46 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/fragment/FinalFragment.java @@ -0,0 +1,108 @@ +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.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; + +/** + * 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; + } + + 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); + + 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; + } + + /** + * 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..767f79b65 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ChooserFragment.java @@ -0,0 +1,70 @@ +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 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 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; + + @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); + + 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); + if (viewModel.getState().getValue() != null) { + // we can't come back here now to start another action + // as we only support one per ViewModel instance + requireActivity().supportFinishAfterTransition(); + } + } + +} 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..c0a41b699 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/ReceiveFragment.java @@ -0,0 +1,106 @@ +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.TextView; +import android.widget.Toast; + +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.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.GetContent; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +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(); + + // TODO we can pass an extra named DocumentsContract.EXTRA_INITIAL_URI + // to have the file-picker start on the usb-stick -- if get hold of URI + // of the same. USB manager API? + // Overall, passing this extra requires extending the ready-made + // contracts and overriding createIntent. + private final ActivityResultLauncher launcher = + registerForActivityResult(new GetContent(), this::onDocumentChosen); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + RemovableDriveViewModel viewModel; + TextView introTextView; + Button button; + 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); + + introTextView = v.findViewById(R.id.introTextView); + progressBar = v.findViewById(R.id.progressBar); + button = v.findViewById(R.id.fileButton); + button.setOnClickListener(view -> + launcher.launch("*/*") + ); + viewModel.getState() + .observe(getViewLifecycleOwner(), this::onStateChanged); + return v; + } + + @Override + public void onStart() { + super.onStart(); + requireActivity().setTitle(R.string.removable_drive_title_receive); + } + + private void onStateChanged(TransferDataState state) { + if (state instanceof TransferDataState.NoDataToSend) { + throw new IllegalStateException(); + } else if (state instanceof TransferDataState.Ready) { + button.setVisibility(VISIBLE); + } else if (state instanceof TransferDataState.TaskAvailable) { + button.setEnabled(false); + if (((TransferDataState.TaskAvailable) state).isOldTask) { + Toast.makeText(requireContext(), + R.string.removable_drive_ongoing, LENGTH_LONG).show(); + } + 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 index da5429887..4f67bce32 100644 --- 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 @@ -1,51 +1,42 @@ package org.briarproject.briar.android.removabledrive; import android.content.Intent; -import android.net.Uri; import android.os.Bundle; import android.view.MenuItem; -import android.widget.Button; -import android.widget.TextView; 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.State; +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.BaseFragment.BaseFragmentListener; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; +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.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; -import androidx.lifecycle.LiveData; +import androidx.annotation.StringRes; +import androidx.fragment.app.Fragment; 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; -// TODO 19 will be our requirement for sneakernet support, right. The file apis -// used require this. -@RequiresApi(api = 19) +@RequiresApi(19) @MethodsNotNullByDefault @ParametersNotNullByDefault -public class RemovableDriveActivity extends BriarActivity - implements BaseFragmentListener { +public class RemovableDriveActivity extends BriarActivity { @Inject ViewModelProvider.Factory viewModelFactory; + private RemovableDriveViewModel viewModel; - private TextView text; - private Button writeButton; - private Button readButton; @Override public void injectActivity(ActivityComponent component) { @@ -58,57 +49,23 @@ public class RemovableDriveActivity extends BriarActivity @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_removable_drive); - text = findViewById(R.id.sneaker_text); - writeButton = findViewById(R.id.sneaker_write); - readButton = findViewById(R.id.sneaker_read); - Intent intent = getIntent(); + Intent intent = requireNonNull(getIntent()); int contactId = intent.getIntExtra(CONTACT_ID, -1); - if (contactId == -1) { - writeButton.setEnabled(false); - readButton.setEnabled(false); - return; - } + if (contactId == -1) throw new IllegalArgumentException("ContactId"); + viewModel.setContactId(new ContactId(contactId)); - // TODO we can pass an extra named DocumentsContract.EXTRA_INITIAL_URI - // to have the filepicker start on the usb-stick -- if get hold of URI - // of the same. USB manager API? - // Overall, passing this extra requires extending the ready-made - // contracts and overriding createIntent. + setContentView(R.layout.activity_fragment_container); - writeButton.setText("Write for contactId " + contactId); - ActivityResultLauncher createDocument = - registerForActivityResult( - new ActivityResultContracts.CreateDocument(), - uri -> write(contactId, uri)); - writeButton.setOnClickListener( - v -> createDocument.launch(viewModel.getFileName())); + viewModel.getActionEvent().observeEvent(this, this::onActionReceived); + viewModel.getState().observe(this, this::onStateChanged); - readButton.setText("Read for contactId " + contactId); - ActivityResultLauncher getContent = - registerForActivityResult( - new ActivityResultContracts.GetContent(), - uri -> read(contactId, uri)); - readButton.setOnClickListener( - v -> getContent.launch("application/octet-stream")); - - LiveData state; - state = viewModel.ongoingWrite(new ContactId(contactId)); - if (state == null) { - writeButton.setEnabled(true); - } else { - say("\nOngoing write:"); - writeButton.setEnabled(false); - state.observe(this, (taskState) -> handleState("write", taskState)); - } - state = viewModel.ongoingRead(new ContactId(contactId)); - if (state == null) { - readButton.setEnabled(true); - } else { - say("\nOngoing read:"); - readButton.setEnabled(false); - state.observe(this, (taskState) -> handleState("read", taskState)); + if (savedInstanceState == null) { + Fragment f = new ChooserFragment(); + String tag = ChooserFragment.TAG; + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragmentContainer, f, tag) + .commit(); } } @@ -121,54 +78,56 @@ public class RemovableDriveActivity extends BriarActivity return super.onOptionsItemSelected(item); } - private void write(int contactId, @Nullable Uri uri) { - if (contactId == -1) { - throw new IllegalStateException(); - } - if (uri == null) { - say("no URI picked for write"); - return; - } - say("\nWriting to URI: " + uri); - writeButton.setEnabled(false); - LiveData state = viewModel.write(new ContactId(contactId), uri); - state.observe(this, (taskState) -> handleState("write", taskState)); + 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 read(int contactId, @Nullable Uri uri) { - if (contactId == -1) { - throw new IllegalStateException(); - } - if (uri == null) { - say("no URI picked for read"); - return; - } - say("\nReading from URI: " + uri); - readButton.setEnabled(false); - LiveData state = viewModel.read(new ContactId(contactId), uri); - state.observe(this, (taskState) -> handleState("read", taskState)); - } - - private void handleState(String action, State taskState) { - say(String.format(Locale.getDefault(), - "%s: bytes done: %d of %d. %s. %s.", - action, taskState.getDone(), taskState.getTotal(), - taskState.isFinished() ? "Finished" : "Ongoing", - taskState.isFinished() ? - (taskState.isSuccess() ? "Success" : "Failed") : "..")); - if (taskState.isFinished()) { - if (action.equals("write")) { - writeButton.setEnabled(true); - } else if (action.equals("read")) { - readButton.setEnabled(true); - } + private void onStateChanged(TransferDataState state) { + if (!(state instanceof TaskAvailable)) return; + RemovableDriveTask.State s = ((TaskAvailable) state).state; + if (s.isFinished()) { + Action action = + requireNonNull(viewModel.getActionEvent().getLastValue()); + Fragment f; + if (s.isSuccess()) f = getSuccessFragment(action); + else f = getErrorFragment(action); + showFragment(getSupportFragmentManager(), f, FinalFragment.TAG); } } - private void say(String txt) { - String time = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - .format(new Date()); - txt = String.format("%s %s\n", time, txt); - text.setText(text.getText().toString().concat(txt)); + 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..eb450fd0c 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 @@ -10,92 +10,159 @@ 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.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 javax.annotation.Nullable; import javax.inject.Inject; +import androidx.annotation.UiThread; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import static java.util.Locale.US; +import static java.util.Objects.requireNonNull; import static java.util.logging.Logger.getLogger; import static org.briarproject.bramble.api.plugin.file.RemovableDriveConstants.PROP_URI; +@UiThread @NotNullByDefault class RemovableDriveViewModel extends AndroidViewModel { 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 MutableLiveData state = + new MutableLiveData<>(); + private final State initialState = new State(0, 0, false, false); + @Nullable + private ContactId contactId = null; + @Nullable + private RemovableDriveTask task = null; + @Nullable + private Consumer taskObserver = null; @Inject RemovableDriveViewModel(Application app, RemovableDriveManager removableDriveManager) { super(app); - 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) { + // TODO check if there is data to export now + // and only allow to continue if there is. + state.setValue(new TransferDataState.Ready()); + } else { + // observe old task and start with initial state + taskObserver = s -> observeTask(s, true); + taskObserver.accept(initialState); + task.addObserver(taskObserver); + } + } + + @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 and start with initial state + taskObserver = s -> observeTask(s, true); + taskObserver.accept(initialState); + task.addObserver(taskObserver); + } + } + + @UiThread + private void observeTask(RemovableDriveTask.State s, boolean isOldTask) { + state.setValue(new TransferDataState.TaskAvailable(s, isOldTask)); + } + + String getFileName() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", US); + return sdf.format(new Date()) + ".zip"; + } + + /** + * 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 + taskObserver = s -> observeTask(s, false); + taskObserver.accept(initialState); + + // 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 + taskObserver = s -> observeTask(s, false); + taskObserver.accept(initialState); + + TransportProperties p = new TransportProperties(); + p.put(PROP_URI, uri.toString()); + task = manager.startReaderTask(p); + task.addObserver(taskObserver); + } + + LiveEvent getActionEvent() { + return action; + } + + 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..33ec15095 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/SendFragment.java @@ -0,0 +1,114 @@ +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.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 javax.inject.Inject; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static android.view.View.GONE; +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 CreateDocument(), + this::onDocumentCreated); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + RemovableDriveViewModel viewModel; + TextView introTextView; + Button button; + 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); + + 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.getState() + .observe(getViewLifecycleOwner(), this::onStateChanged); + + return v; + } + + @Override + public void onStart() { + super.onStart(); + requireActivity().setTitle(R.string.removable_drive_title_send); + } + + private void onStateChanged(TransferDataState state) { + if (state instanceof TransferDataState.NoDataToSend) { + introTextView.setText(R.string.removable_drive_send_no_data); + button.setVisibility(GONE); + } else if (state instanceof TransferDataState.Ready) { + button.setVisibility(VISIBLE); + } else if (state instanceof TransferDataState.TaskAvailable) { + button.setEnabled(false); + if (((TransferDataState.TaskAvailable) state).isOldTask) { + Toast.makeText(requireContext(), + R.string.removable_drive_ongoing, LENGTH_LONG).show(); + } + RemovableDriveTask.State s = + ((TransferDataState.TaskAvailable) state).state; + if (s.getTotal() > 0L && progressBar.getVisibility() != VISIBLE) { + progressBar.setVisibility(VISIBLE); + // FIXME if we ever export more than 2 GB, this won't work + progressBar.setMax((int) s.getTotal()); + } + progressBar.setProgress((int) s.getDone()); + } + } + + 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..e59eb07ed --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/removabledrive/TransferDataState.java @@ -0,0 +1,39 @@ +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 { + } + + /** + * 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; + /** + * This is an old task that is still ongoing. The user needs to wait for + * it to complete before starting a new task. + */ + final boolean isOldTask; + + TaskAvailable(RemovableDriveTask.State state, boolean isOldTask) { + this.state = state; + this.isOldTask = isOldTask; + } + } + +} 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 aa77231d7..ab03767f3 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 @@ -58,7 +58,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; @@ -139,6 +141,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(); 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_arrow_forward.xml b/briar-android/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 000000000..bfd99a659 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_arrow_forward.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_flash_drive.xml b/briar-android/src/main/res/drawable/ic_flash_drive.xml new file mode 100644 index 000000000..78bb75e71 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_flash_drive.xml @@ -0,0 +1,10 @@ + + + diff --git a/briar-android/src/main/res/drawable/ic_phone_android.xml b/briar-android/src/main/res/drawable/ic_phone_android.xml new file mode 100644 index 000000000..d22dd9941 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_phone_android.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/layout/activity_removable_drive.xml b/briar-android/src/main/res/layout/activity_removable_drive.xml deleted file mode 100644 index 3b91a49f2..000000000 --- a/briar-android/src/main/res/layout/activity_removable_drive.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - -