Transfer Data UI

This commit is contained in:
Torsten Grote
2021-06-09 17:58:14 -03:00
parent ecba2a51d8
commit 928b951c25
28 changed files with 1155 additions and 231 deletions

View File

@@ -38,6 +38,9 @@ import org.briarproject.briar.android.attachment.media.MediaModule;
import org.briarproject.briar.android.conversation.glide.BriarModelLoader;
import org.briarproject.briar.android.logging.CachingLogHandler;
import org.briarproject.briar.android.login.SignInReminderReceiver;
import org.briarproject.briar.android.removabledrive.ChooserFragment;
import org.briarproject.briar.android.removabledrive.ReceiveFragment;
import org.briarproject.briar.android.removabledrive.SendFragment;
import org.briarproject.briar.android.settings.ConnectionsFragment;
import org.briarproject.briar.android.settings.NotificationsFragment;
import org.briarproject.briar.android.settings.SecurityFragment;
@@ -212,4 +215,10 @@ public interface AndroidComponent
void inject(SecurityFragment securityFragment);
void inject(NotificationsFragment notificationsFragment);
void inject(ChooserFragment chooserFragment);
void inject(SendFragment sendFragment);
void inject(ReceiveFragment receiveFragment);
}

View File

@@ -25,7 +25,6 @@ import org.briarproject.bramble.api.plugin.simplex.SimplexPluginFactory;
import org.briarproject.bramble.api.reporting.DevConfig;
import org.briarproject.bramble.plugin.bluetooth.AndroidBluetoothPluginFactory;
import org.briarproject.bramble.plugin.file.AndroidRemovableDrivePluginFactory;
import org.briarproject.bramble.plugin.file.RemovableDriveModule;
import org.briarproject.bramble.plugin.tcp.AndroidLanTcpPluginFactory;
import org.briarproject.bramble.plugin.tor.AndroidTorPluginFactory;
import org.briarproject.bramble.util.AndroidUtils;
@@ -43,6 +42,7 @@ import org.briarproject.briar.android.login.LoginModule;
import org.briarproject.briar.android.navdrawer.NavDrawerModule;
import org.briarproject.briar.android.privategroup.conversation.GroupConversationModule;
import org.briarproject.briar.android.privategroup.list.GroupListModule;
import org.briarproject.briar.android.removabledrive.TransferDataModule;
import org.briarproject.briar.android.reporting.DevReportModule;
import org.briarproject.briar.android.settings.SettingsModule;
import org.briarproject.briar.android.sharing.SharingModule;
@@ -93,7 +93,7 @@ import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD;
GroupListModule.class,
GroupConversationModule.class,
SharingModule.class,
RemovableDriveModule.class
TransferDataModule.class,
})
public class AppModule {

View File

@@ -14,7 +14,5 @@ public interface RequestCodes {
int REQUEST_ATTACH_IMAGE = 13;
int REQUEST_SAVE_ATTACHMENT = 14;
int REQUEST_AVATAR_IMAGE = 15;
int REQUEST_REMOVABLE_DRIVE_WRITE = 16;
int REQUEST_REMOVABLE_DRIVE_READ = 17;
}

View File

@@ -375,6 +375,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) { // 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);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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<String> 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<String> getContent =
registerForActivityResult(
new ActivityResultContracts.GetContent(),
uri -> read(contactId, uri));
readButton.setOnClickListener(
v -> getContent.launch("application/octet-stream"));
LiveData<State> 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> 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> 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);
}
}

View File

@@ -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<Consumer<State>, RemovableDriveTask>
observers = new ConcurrentHashMap<>();
private final MutableLiveEvent<Action> action = new MutableLiveEvent<>();
private final MutableLiveData<TransferDataState> 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<State> 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<State> write(ContactId contactId, Uri uri) {
TransportProperties p = new TransportProperties();
p.put(PROP_URI, uri.toString());
return observe(manager.startWriterTask(contactId, p));
}
LiveData<State> read(Uri uri) {
TransportProperties p = new TransportProperties();
p.put(PROP_URI, uri.toString());
return observe(manager.startReaderTask(p));
}
@Nullable
LiveData<State> ongoingWrite() {
RemovableDriveTask task = manager.getCurrentWriterTask();
if (task == null) {
return null;
}
return observe(task);
}
@Nullable
LiveData<State> ongoingRead() {
RemovableDriveTask task = manager.getCurrentReaderTask();
if (task == null) {
return null;
}
return observe(task);
}
private LiveData<State> observe(RemovableDriveTask task) {
MutableLiveData<State> state = new MutableLiveData<>();
Consumer<State> observer = state::postValue;
task.addObserver(observer);
observers.put(observer, task);
return state;
}
@Override
protected void onCleared() {
for (Map.Entry<Consumer<State>, 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<State> 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<Action> getActionEvent() {
return action;
}
LiveData<TransferDataState> getState() {
return state;
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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();