Files
bachotbakwan/app/src/main/java/backone/cloud/call/MainActivity.java
T

610 lines
22 KiB
Java

package backone.cloud.call;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.content.res.Resources;
import android.media.AudioManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import androidx.annotation.NonNull;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import androidx.appcompat.app.AlertDialog;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import android.os.Looper;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.animation.AlphaAnimation;
import android.view.animation.AnimationSet;
import android.view.animation.TranslateAnimation;
import android.widget.AdapterView;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.json.JSONException;
import android.net.VpnService;
import cloud.backone.backoneconnect.BackOneConnectApplication;
import cloud.backone.backoneconnect.events.NodeStatusEvent;
import cloud.backone.backoneconnect.events.RequestNodeStatusEvent;
import cloud.backone.backoneconnect.model.Network;
import cloud.backone.backoneconnect.model.NetworkDao;
import cloud.backone.backoneconnect.service.ZeroTierOneService;
import com.zerotier.sdk.NodeStatus;
import java.util.List;
// the main view with tabs
public class MainActivity extends MeshengerActivity implements ServiceConnection, AdapterView.OnItemClickListener {
private static final int MIC_PERMISSION_REQUEST_CODE = 2;
private static final long STATUS_REFRESH_INTERVAL_MS = 30_000L;
/** Ignore brief ZeroTier EVENT_OFFLINE flaps while the TUN interface is still up. */
private static final long VPN_OFFLINE_DEBOUNCE_MS = 8_000L;
public MainService.MainBinder binder;
private ListView contactListView;
private TextView ownStatusText;
private boolean fabExpanded = false;
private FloatingActionButton fabScan;
private FloatingActionButton fabGen;
private FloatingActionButton fab;
SwipeRefreshLayout swipeRefreshLayout;
private boolean serviceBound = false;
private boolean activityResumed = false;
private boolean vpnOnline = false;
private final EventBus eventBus = EventBus.getDefault();
private final Handler statusRefreshHandler = new Handler(Looper.getMainLooper());
private final Runnable vpnOfflineRunnable = () -> {
// Only show offline after debounce if the TUN interface is really gone.
if (Utils.getFirstTunIpv4() == null) {
vpnOnline = false;
updateOwnStatusText();
} else {
vpnOnline = true;
updateOwnStatusText();
}
};
private final Runnable statusRefreshRunnable = new Runnable() {
@Override
public void run() {
refreshContactStatuses();
refreshVpnFromTunnel();
statusRefreshHandler.postDelayed(this, STATUS_REFRESH_INTERVAL_MS);
}
};
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
log("onItemClick");
if (binder == null) {
return;
}
List<Contact> contacts = binder.getContactsCopy();
if (i < 0 || i >= contacts.size()) {
return;
}
Contact contact = contacts.get(i);
if (!Utils.hasMicPermission(this)) {
Utils.requestMicPermission(this, MIC_PERMISSION_REQUEST_CODE);
return;
}
Intent intent = new Intent(this, CallActivity.class);
intent.setAction("ACTION_OUTGOING_CALL");
intent.putExtra("EXTRA_CONTACT", contact);
startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
log("onCreate");
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
fab = findViewById(R.id.fab);
fabScan = findViewById(R.id.fabScan);
fabGen = findViewById(R.id.fabGenerate);
contactListView = findViewById(R.id.contactList);
ownStatusText = findViewById(R.id.ownStatusText);
swipeRefreshLayout = findViewById(R.id.swipe_refresh);
swipeRefreshLayout.setOnRefreshListener(() -> refreshContactStatuses());
fabScan.setOnClickListener(v -> startActivity(new Intent(this, QRScanActivity.class)));
fabGen.setOnClickListener(v -> startActivity(new Intent(this, QRShowActivity.class)));
fab.setOnClickListener(this::runFabAnimation);
// ask for audio recording permissions
if (!Utils.hasMicPermission(this)) {
Utils.requestMicPermission(this, MIC_PERMISSION_REQUEST_CODE);
}
LocalBroadcastManager.getInstance(this).registerReceiver(refreshContactListReceiver, new IntentFilter("refresh_contact_list"));
LocalBroadcastManager.getInstance(this).registerReceiver(ownStatusReceiver, new IntentFilter(MainService.ACTION_OWN_STATUS_CHANGED));
}
private void refreshContactStatuses() {
if (!activityResumed || binder == null) {
stopSwipeRefresh();
return;
}
binder.checkOwnConnectivity();
binder.pingContacts();
eventBus.post(new RequestNodeStatusEvent());
statusRefreshHandler.removeCallbacks(refreshTimeoutRunnable);
statusRefreshHandler.postDelayed(refreshTimeoutRunnable, 15_000L);
}
private final Runnable refreshTimeoutRunnable = () -> stopSwipeRefresh();
private void stopSwipeRefresh() {
if (swipeRefreshLayout != null) {
swipeRefreshLayout.setRefreshing(false);
}
}
private void updateOwnStatusText() {
if (ownStatusText == null || !activityResumed) {
return;
}
String signaling;
if (binder == null) {
signaling = getString(R.string.own_status_checking);
} else {
switch (binder.getOwnStatus()) {
case LISTENING:
signaling = getString(R.string.own_status_ready);
break;
case IN_CALL:
signaling = getString(R.string.own_status_in_call);
break;
case NOT_LISTENING:
signaling = getString(R.string.own_status_not_listening);
break;
default:
signaling = getString(R.string.own_status_checking);
break;
}
}
String vpn = vpnOnline
? getString(R.string.own_status_vpn_online)
: getString(R.string.own_status_vpn_offline);
ownStatusText.setText(signaling + " · " + vpn);
}
void refreshContactList() {
log("refreshContactList");
if (binder == null) {
return;
}
new Handler(getMainLooper()).post(() -> {
if (binder == null || contactListView == null) {
return;
}
List<Contact> contacts = binder.getContactsCopy();
contactListView.setAdapter(new ContactListAdapter(this, R.layout.item_contact, contacts));
contactListView.setOnItemClickListener(this);
contactListView.setOnItemLongClickListener((AdapterView<?> adapterView, View view, int i, long l) -> {
if (i < 0 || i >= contacts.size()) {
return true;
}
Contact contact = contacts.get(i);
PopupMenu menu = new PopupMenu(this, view);
Resources res = getResources();
String delete = res.getString(R.string.delete);
String rename = res.getString(R.string.rename);
String block = res.getString(R.string.block);
String unblock = res.getString(R.string.unblock);
String share = res.getString(R.string.share);
String qr = "QR-ify";
menu.getMenu().add(delete);
menu.getMenu().add(rename);
menu.getMenu().add(share);
if (contact.getBlocked()) {
menu.getMenu().add(unblock);
} else {
menu.getMenu().add(block);
}
menu.getMenu().add(qr);
menu.setOnMenuItemClickListener((MenuItem menuItem) -> {
String title = menuItem.getTitle().toString();
byte[] publicKey = contact.getPublicKey();
if (title.equals(delete)) {
showDeleteDialog(publicKey, contact.getName());
} else if (title.equals(rename)) {
showContactEditDialog(publicKey, contact.getName());
} else if (title.equals(share)) {
shareContact(contact);
} else if (title.equals(block)) {
setBlocked(publicKey, true);
} else if (title.equals(unblock)) {
setBlocked(publicKey, false);
} else if (title.equals(qr)) {
Intent intent = new Intent(this, QRShowActivity.class);
intent.putExtra("EXTRA_CONTACT", contact);
startActivity(intent);
}
return false;
});
menu.show();
return true;
});
stopSwipeRefresh();
});
}
private void showDeleteDialog(byte[] publicKey, String name) {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(R.string.confirm);
builder.setMessage(getResources().getString(R.string.contact_remove) + " " + name);
builder.setCancelable(false); // prevent key shortcut to cancel dialog
builder.setPositiveButton(R.string.yes, (DialogInterface dialog, int id) -> {
binder.deleteContact(publicKey);
dialog.cancel();
});
builder.setNegativeButton(R.string.no, (DialogInterface dialog, int id) -> {
dialog.cancel();
});
// create dialog box
AlertDialog alert = builder.create();
alert.show();
}
private void setBlocked(byte[] publicKey, boolean blocked) {
Contact contact = binder.getContactByPublicKey(publicKey);
if (contact != null) {
contact.setBlocked(blocked);
binder.addContact(contact);
}
}
private void shareContact(Contact c) {
log("shareContact");
try {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, Contact.exportJSON(c, false).toString());
startActivity(intent);
} catch (JSONException e) {
e.printStackTrace();
}
}
private void showContactEditDialog(byte[] publicKey, String name) {
log("showContactEditDialog");
EditText et = new EditText(this);
et.setText(name);
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle(R.string.contact_edit)
.setView(et)
.setNegativeButton(getResources().getString(R.string.cancel), null)
.setPositiveButton(R.string.ok, (DialogInterface dialogInterface, int i) -> {
String newName = et.getText().toString();
if (newName.length() > 0) {
Contact contact = binder.getContactByPublicKey(publicKey);
if (contact != null) {
contact.setName(newName);
binder.addContact(contact);
}
}
}).show();
}
private void runFabAnimation(View fab) {
log("runFabAnimation");
AnimationSet scanSet = new AnimationSet(this, null);
AnimationSet generateSet = new AnimationSet(this, null);
final int distance = 200;
final int duration = 300;
TranslateAnimation scanAnimation;
TranslateAnimation generateAnimnation;
AlphaAnimation alphaAnimation;
if (fabExpanded) {
scanAnimation = new TranslateAnimation(0, 0, -distance, 0);
generateAnimnation = new TranslateAnimation(0, 0, -distance * 2, 0);
alphaAnimation = new AlphaAnimation(1.0f, 0.0f);
((FloatingActionButton) fab).setImageResource(R.drawable.qr_glass);
fabScan.setY(fabScan.getY() + distance);
fabGen.setY(fabGen.getY() + distance * 2);
} else {
scanAnimation = new TranslateAnimation(0, 0, distance, 0);
generateAnimnation = new TranslateAnimation(0, 0, distance * 2, 0);
alphaAnimation = new AlphaAnimation(0.0f, 1.0f);
((FloatingActionButton) fab).setImageResource(android.R.drawable.ic_menu_close_clear_cancel);
fabScan.setY(fabScan.getY() - distance);
fabGen.setY(fabGen.getY() - distance * 2);
}
scanSet.addAnimation(scanAnimation);
scanSet.addAnimation(alphaAnimation);
scanSet.setFillAfter(true);
generateSet.addAnimation(generateAnimnation);
generateSet.addAnimation(alphaAnimation);
generateSet.setFillAfter(true);
scanSet.setDuration(duration);
generateSet.setDuration(duration);
fabScan.show();
fabGen.show();
fabScan.startAnimation(scanSet);
fabGen.startAnimation(generateSet);
fabExpanded = !fabExpanded;
}
@Override
protected void onDestroy() {
log("onDestroy");
activityResumed = false;
statusRefreshHandler.removeCallbacks(statusRefreshRunnable);
statusRefreshHandler.removeCallbacks(refreshTimeoutRunnable);
if (serviceBound) {
try {
unbindService(this);
} catch (IllegalArgumentException ignored) {
}
serviceBound = false;
binder = null;
}
LocalBroadcastManager.getInstance(this).unregisterReceiver(refreshContactListReceiver);
try {
LocalBroadcastManager.getInstance(this).unregisterReceiver(ownStatusReceiver);
} catch (IllegalArgumentException ignored) {
}
((AudioManager)this.getSystemService(Context.AUDIO_SERVICE)).setSpeakerphoneOn(false);
super.onDestroy();
}
@Override
public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
log("OnServiceConnected");
this.binder = (MainService.MainBinder) iBinder;
this.serviceBound = true;
refreshContactList();
updateOwnStatusText();
refreshContactStatuses();
}
@Override
public void onServiceDisconnected(ComponentName componentName) {
log("OnServiceDisconnected");
this.serviceBound = false;
this.binder = null;
updateOwnStatusText();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
log("onOptionsItemSelected");
int id = item.getItemId();
switch (id) {
case R.id.action_settings: {
Intent intent = new Intent(this, SettingsActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(intent);
break;
}
case R.id.action_backup: {
//startActivity(new Intent(this, BackupActivity.class));
break;
}
case R.id.action_about: {
startActivity(new Intent(this, AboutActivity.class));
break;
}
}
return super.onOptionsItemSelected(item);
}
private final BroadcastReceiver refreshContactListReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (contactListView == null || binder == null) {
return;
}
refreshContactList();
stopSwipeRefresh();
}
};
private final BroadcastReceiver ownStatusReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (activityResumed) {
updateOwnStatusText();
}
stopSwipeRefresh();
}
};
@Subscribe(threadMode = ThreadMode.MAIN)
public void onNodeStatus(NodeStatusEvent event) {
NodeStatus status = event.getStatus();
if (status != null && status.isOnline()) {
statusRefreshHandler.removeCallbacks(vpnOfflineRunnable);
vpnOnline = true;
// Keep own ID/QR address aligned with current TUN IPv4.
if (binder != null && binder.getSettings() != null
&& Utils.syncIdentityFromTun(binder.getSettings())) {
try {
binder.saveDatabase();
} catch (Exception e) {
e.printStackTrace();
}
}
} else if (status != null && !status.isOnline()) {
// NodeStatus.isOnline() is planet reachability, not VpnService/TUN state.
// During/after calls it can flap OFFLINE while the system VPN key stays up.
if (Utils.getFirstTunIpv4() != null) {
statusRefreshHandler.removeCallbacks(vpnOfflineRunnable);
vpnOnline = true;
} else if (binder != null && binder.getCurrentCall() != null) {
vpnOnline = true;
} else {
statusRefreshHandler.removeCallbacks(vpnOfflineRunnable);
statusRefreshHandler.postDelayed(vpnOfflineRunnable, VPN_OFFLINE_DEBOUNCE_MS);
}
}
updateOwnStatusText();
stopSwipeRefresh();
}
/** Prefer TUN presence over flaky NodeStatus for the VPN status line. */
private void refreshVpnFromTunnel() {
if (Utils.getFirstTunIpv4() != null) {
statusRefreshHandler.removeCallbacks(vpnOfflineRunnable);
if (!vpnOnline) {
vpnOnline = true;
updateOwnStatusText();
}
}
}
@Override
protected void onStart() {
super.onStart();
if (!eventBus.isRegistered(this)) {
eventBus.register(this);
}
}
@Override
protected void onStop() {
super.onStop();
statusRefreshHandler.removeCallbacks(statusRefreshRunnable);
if (eventBus.isRegistered(this)) {
eventBus.unregister(this);
}
}
@Override
protected void onResume() {
log("OnResume");
super.onResume();
activityResumed = true;
if (!serviceBound) {
bindService(new Intent(this, MainService.class), this, Service.BIND_AUTO_CREATE);
} else if (binder != null) {
refreshContactList();
refreshVpnFromTunnel();
ensureVpnServiceRunning();
updateOwnStatusText();
refreshContactStatuses();
}
eventBus.post(new RequestNodeStatusEvent());
statusRefreshHandler.removeCallbacks(statusRefreshRunnable);
statusRefreshHandler.postDelayed(statusRefreshRunnable, STATUS_REFRESH_INTERVAL_MS);
}
/**
* After a call crash/error the process may leave ZeroTier stopped while the UI
* still thinks VPN should be up. Restart the last-activated network if TUN is gone.
*/
private void ensureVpnServiceRunning() {
if (Utils.getFirstTunIpv4() != null) {
vpnOnline = true;
return;
}
try {
if (VpnService.prepare(this) != null) {
return;
}
NetworkDao networkDao = ((BackOneConnectApplication) getApplication())
.getDaoSession()
.getNetworkDao();
List<Network> activated = networkDao.queryBuilder()
.where(NetworkDao.Properties.LastActivated.eq(true))
.list();
if (activated == null || activated.isEmpty()) {
activated = networkDao.loadAll();
}
if (activated == null || activated.isEmpty()) {
return;
}
Network network = activated.get(0);
Intent intent = new Intent(this, ZeroTierOneService.class);
intent.putExtra(ZeroTierOneService.ZT1_NETWORK_ID, network.getNetworkId());
intent.putExtra(ZeroTierOneService.ZT1_USE_DEFAULT_ROUTE, network.getUseDefaultRoute());
startService(intent);
log("restarted VPN service for network " + network.getNetworkIdStr());
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
protected void onPause() {
log("onPause");
activityResumed = false;
super.onPause();
collapseFab();
statusRefreshHandler.removeCallbacks(statusRefreshRunnable);
statusRefreshHandler.removeCallbacks(refreshTimeoutRunnable);
}
private void collapseFab() {
if (fabExpanded) {
fab.setImageResource(R.drawable.qr_glass);
fabScan.clearAnimation();
fabGen.clearAnimation();
fabScan.setY(fabScan.getY() + 200);
fabGen.setY(fabGen.getY() + 200 * 2);
fabExpanded = false;
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == MIC_PERMISSION_REQUEST_CODE
&& !Utils.allGranted(grantResults)) {
Toast.makeText(this, R.string.permission_mic, Toast.LENGTH_LONG).show();
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
log("onCreateOptionsMenu");
getMenuInflater().inflate(R.menu.menu_main_activity, menu);
return true;
}
private void log(String s) {
Log.d(this, s);
}
}