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