package com.simonramstedt.yoke; import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.content.SharedPreferences; import android.content.res.Resources; import android.net.nsd.NsdManager; import android.net.nsd.NsdServiceInfo; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.text.InputType; import android.util.Log; import android.view.Display; import android.view.MenuItem; import android.view.View; import android.view.WindowManager; import android.webkit.JavascriptInterface; import android.webkit.WebView; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.SecurityException; import java.lang.StackTraceElement; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.net.ServerSocket; import java.net.SocketException; import java.net.URL; import java.net.URLConnection; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.json.JSONArray; import org.json.JSONObject; import org.json.JSONException; public class YokeActivity extends Activity implements NsdManager.DiscoveryListener { private final String SERVICE_TYPE = "_yoke._udp."; private String NOTHING; private String ENTER_IP; private NsdManager mNsdManager; private NsdServiceInfo mService; private DatagramSocket mSocket; private String vals_str = null; private Map<String, NsdServiceInfo> mServiceMap = new HashMap<>(); private List<String> mServiceNames = new ArrayList<>(); private SharedPreferences sharedPref; private TextView mTextView; private Spinner mSpinner; private ProgressBar mProgressBar; private ArrayAdapter<String> mAdapter; private Handler handler; private WebView wv; private Resources res; private File currentJoypadPath; private File currentMainPath; private File currentManifestPath; private File futureJoypadPath; private File futureMainPath; private File futureManifestPath; private String currentHost = null; private int currentPort = 0; // the value is irrelevant if currentHost is null private void log(String m) { if (BuildConfig.DEBUG) Log.d("Yoke", m); } private void logError(String m, Exception e) { Log.e("Yoke", m); if (e != null) { StringBuilder sb = new StringBuilder(); for (StackTraceElement element : e.getStackTrace()) { sb.append(element.toString()); sb.append("\n"); } Log.e("Yoke", sb.toString()); } YokeActivity.this.runOnUiThread(() -> { AlertDialog.Builder builder = new AlertDialog.Builder(YokeActivity.this); builder.setMessage(m); builder.setNegativeButton(R.string.dismiss_error, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int id) { dialog.cancel(); } }); builder.show(); }); } private void deleteRecursively(File joypadPath) throws IOException { // Deleting a folder without emptying it first fails or raises an error, // and there is no one-liner in this version to empty its contents. So: ArrayList<File> erasables = new ArrayList<>(); erasables.add(joypadPath); for (int i = 0; i < erasables.size(); i++) { // don't optimize the line above. The ArrayList will be modified at every iteration. File folder = erasables.get(i); if (folder.isDirectory()) { for (String entry : folder.list()) { erasables.add(new File(folder, entry)); } } } // Files should already be ordered by depth, so we iterate backwards: for (int i = erasables.size() - 1; i >= 0; i--) { File currentFile = erasables.get(i); if (!currentFile.delete()) { throw new IOException(String.format(res.getString(R.string.error_could_not_delete), currentFile)); } } } class WebAppInterface { Context mContext; // Instantiate the interface and set the context WebAppInterface(Context c) { mContext = c; } // Webpage uses this method to update joypad state: @JavascriptInterface public void update_vals(String vals) { vals_str = vals; update(); } } // https://stackoverflow.com/questions/15758856/android-how-to-download-file-from-webserver/ class DownloadFilesFromURL extends AsyncTask<String, Long, Void> { private final long INDETERMINATE = -1L; private final long DETERMINATE = -2L; private final long SUCCESS = -4L; private final int MAX_PROGRESS = 1000; @Override protected void onPreExecute() { super.onPreExecute(); try { if (futureJoypadPath.exists()) { deleteRecursively(futureJoypadPath); } log(String.format( res.getString(R.string.log_creating_folder), futureJoypadPath.getAbsolutePath() )); if (!futureJoypadPath.mkdirs()) { throw new IOException(String.format( res.getString(R.string.error_could_not_create_folder), futureJoypadPath.getAbsolutePath() )); } } catch (SecurityException e) { logError(res.getString(R.string.error_security_exception), e); cancel(true); } catch (IOException e) { logError(e.getLocalizedMessage(), e); cancel(true); } } @Override protected Void doInBackground(String... f_url) { StringBuilder manifestSB = new StringBuilder(); try { byte data[] = new byte[1024]; URL url = new URL(f_url[0] + "manifest.json"); InputStream input = new BufferedInputStream(url.openStream(), 8192); OutputStream output = new FileOutputStream(futureManifestPath); int count; publishProgress(0L, INDETERMINATE); while ((count = input.read(data)) != -1) { output.write(data, 0, count); } BufferedReader inputBR = new BufferedReader( new FileReader(futureManifestPath) ); String line = ""; while ((line = inputBR.readLine()) != null) { manifestSB.append(line).append("\n"); } JSONObject manifestJSON = new JSONObject(manifestSB.toString()); long totalBytes = manifestJSON.optLong("size", INDETERMINATE); if (totalBytes != INDETERMINATE) publishProgress(0L, DETERMINATE); JSONArray entries = manifestJSON.getJSONArray("folders"); for (int i = 0, length = entries.length(); i < length; i++) { File newFolder = new File(futureJoypadPath, entries.getString(i)); log(String.format(res.getString(R.string.log_creating_folder), newFolder.getAbsolutePath())); if (!newFolder.mkdirs()) { throw new IOException(String.format( res.getString(R.string.error_could_not_create_folder), newFolder.getAbsolutePath() )); } } entries = manifestJSON.getJSONArray("files"); long cumulativeBytes = 0; for (int i = 0, length=entries.length(); i < length; i++) { File newFile = new File(futureJoypadPath, entries.getString(i)); log(String.format(res.getString(R.string.log_downloading_file), newFile.getAbsolutePath())); input = new BufferedInputStream(new URL(f_url[0] + entries.getString(i)).openStream(), 8192); output = new FileOutputStream(newFile); while ((count = input.read(data)) != -1) { cumulativeBytes += count; output.write(data, 0, count); publishProgress(cumulativeBytes, totalBytes); } output.flush(); output.close(); input.close(); } } catch (IOException e) { if (e.getLocalizedMessage().contains(" ECONNREFUSED ")) { logError(res.getString(R.string.error_connection_refused), e); } else { logError(e.getLocalizedMessage(), e); } cancel(true); } catch (JSONException e) { logError(String.format(res.getString(R.string.error_json_exception), e.getLocalizedMessage()), e); cancel(true); } catch (SecurityException e) { logError(res.getString(R.string.error_security_exception), e); cancel(true); } return null; } protected void onProgressUpdate(Long... progress) { if (progress[1] == INDETERMINATE) { mProgressBar.setIndeterminate(true); } else if (progress[1] == DETERMINATE) { mProgressBar.setIndeterminate(false); mProgressBar.setProgress(0); mProgressBar.setMax((int)MAX_PROGRESS); } else if (progress[1] == SUCCESS) { mProgressBar.setIndeterminate(false); mProgressBar.setProgress(0); Toast.makeText(YokeActivity.this, res.getString(R.string.toast_layout_succesfully_upgraded), Toast.LENGTH_LONG).show(); } else { mProgressBar.setProgress((int)(progress[0]*MAX_PROGRESS/progress[1])); } } @Override protected void onPostExecute(Void v) { try { if (currentJoypadPath.exists()) { deleteRecursively(currentJoypadPath); } if (!futureJoypadPath.renameTo(currentJoypadPath)) { logError(String.format(res.getString(R.string.error_could_not_rename), futureJoypadPath.getAbsolutePath()), null); cancel(true); } } catch (IOException e) { logError(String.format(res.getString(R.string.error_io_exception), e.getLocalizedMessage()), e); cancel(true); } (new Thread(()-> publishProgress(0L, SUCCESS))).start(); } @Override protected void onCancelled() { } } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); sharedPref = getPreferences(Context.MODE_PRIVATE); setContentView(R.layout.main_wv); wv = findViewById(R.id.webView); wv.getSettings().setJavaScriptEnabled(true); wv.addJavascriptInterface(new WebAppInterface(this), "Yoke"); mNsdManager = (NsdManager) getSystemService(Context.NSD_SERVICE); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // Localization. TODO: detect NOTHING and MANUAL CONNECTION buttons by id, not by content. res = getResources(); NOTHING = res.getString(R.string.dropdown_nothing); ENTER_IP = res.getString(R.string.dropdown_enter_ip); // Paths for layout (can't define them until Android context is established) currentJoypadPath = new File(getFilesDir(), "joypad"); currentMainPath = new File(currentJoypadPath, "main.html"); currentManifestPath = new File(currentJoypadPath, "manifest.json"); futureJoypadPath = new File(getFilesDir(), "future"); futureMainPath = new File(futureJoypadPath, "main.html"); futureManifestPath = new File(futureJoypadPath, "manifest.json"); // Progress bar for download: mProgressBar = (ProgressBar) findViewById(R.id.downloadProgress); // Filling spinner with addresses to connect to: mTextView = (TextView) findViewById(R.id.textView); mSpinner = (Spinner) findViewById(R.id.spinner); mAdapter = new ArrayAdapter<>(getApplicationContext(), android.R.layout.simple_spinner_item); mAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); mAdapter.add(NOTHING); mAdapter.add(ENTER_IP); for (String adr : sharedPref.getString("addresses", "").split(System.lineSeparator())) { adr = adr.trim(); // workaround for android bug where random whitespace is added to Strings in shared preferences if (!adr.isEmpty()) { mAdapter.add(adr); mServiceNames.add(adr); log(String.format(res.getString(R.string.log_adding_address), adr)); } } mSpinner.setAdapter(mAdapter); mSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int pos, long l) { String tgt = parent.getItemAtPosition(pos).toString(); log(String.format(res.getString(R.string.log_spinner_selected), tgt)); // clean up old target if no longer available String oldtgt = mSpinner.getSelectedItem().toString(); if (!mServiceNames.contains(oldtgt) && !oldtgt.equals(NOTHING) && !oldtgt.equals(ENTER_IP)) { mAdapter.remove(oldtgt); if (oldtgt.equals(tgt)) { tgt = NOTHING; } } closeConnection(); if (tgt.equals(NOTHING)) { } else if (tgt.equals(ENTER_IP)) { AlertDialog.Builder builder = new AlertDialog.Builder(YokeActivity.this); builder.setTitle(res.getString(R.string.enter_ip_title)); final EditText input = new EditText(YokeActivity.this); input.setInputType(InputType.TYPE_CLASS_TEXT); input.setHint(res.getString(R.string.enter_ip_hint)); builder.setView(input); builder.setOnCancelListener(new DialogInterface.OnCancelListener() { public void onCancel(DialogInterface dialog) { mSpinner.setSelection(mAdapter.getPosition(NOTHING)); mTextView.setText(res.getString(R.string.toolbar_connect_to)); currentHost = null; } }); builder.setPositiveButton(res.getString(R.string.enter_ip_ok), (dialog, which) -> { String name = input.getText().toString(); boolean invalid = name.split(":").length != 2; if (!invalid) { try { Integer.parseInt(name.split(":")[1]); } catch (NumberFormatException e) { invalid = true; } } if (invalid) { mTextView.setText(res.getString(R.string.toolbar_connect_to)); mSpinner.setSelection(mAdapter.getPosition(NOTHING)); currentHost = null; Toast.makeText(YokeActivity.this, res.getString(R.string.toast_invalid_address), Toast.LENGTH_LONG).show(); } else { mServiceNames.add(name); mAdapter.add(name); mSpinner.setSelection(mAdapter.getPosition(name)); SharedPreferences.Editor editor = sharedPref.edit(); String addresses = sharedPref.getString("addresses", ""); addresses = addresses + name + System.lineSeparator(); editor.putString("addresses", addresses); editor.apply(); } }); builder.setNegativeButton(res.getString(R.string.enter_ip_cancel), (dialog, which) -> { dialog.cancel(); }); builder.setOnDismissListener(new DialogInterface.OnDismissListener() { @Override public void onDismiss(DialogInterface dialog) {dialog.cancel();} }); builder.show(); } else { log(String.format(res.getString(R.string.log_service_targeting), tgt)); if (mServiceMap.containsKey(tgt)) { connectToService(tgt); } else { connectToAddress(tgt); } } } @Override public void onNothingSelected(AdapterView<?> adapterView) { log(res.getString(R.string.log_nothing_selected)); } }); } @Override protected void onResume() { super.onResume(); mNsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, this); handler = new Handler(); handler.post(new Runnable() { @Override public void run() { update(); if (handler != null) handler.postDelayed(this, 20); } }); } @Override protected void onPause() { super.onPause(); mNsdManager.stopServiceDiscovery(this); closeConnection(); handler = null; } public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (hasFocus) { getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); } } private void update() { if (mSocket != null && vals_str != null) { send(vals_str.getBytes()); } } public void send(byte[] msg) { try { mSocket.send(new DatagramPacket(msg, msg.length)); } catch (SecurityException e) { logError(res.getString(R.string.error_sending_security_exception), e); closeConnection(); mTextView.setText(res.getString(R.string.toolbar_connect_to)); mSpinner.setSelection(mAdapter.getPosition(NOTHING)); } catch (IOException e) { if (e.getLocalizedMessage().contains(" ECONNREFUSED ")) { logError(res.getString(R.string.error_connection_refused), e); } else { logError(e.getLocalizedMessage(), e); } closeConnection(); mTextView.setText(res.getString(R.string.toolbar_connect_to)); mSpinner.setSelection(mAdapter.getPosition(NOTHING)); } } public void connectToService(String tgt) { NsdServiceInfo service = mServiceMap.get(tgt); log(String.format(res.getString(R.string.log_service_resolving), service.getServiceType())); mNsdManager.resolveService(service, new NsdManager.ResolveListener() { @Override public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) { logError(String.format(res.getString(R.string.log_service_resolve_error), errorCode), null); mTextView.setText(res.getString(R.string.toolbar_connect_to)); mSpinner.setSelection(mAdapter.getPosition(NOTHING)); currentHost = null; } @Override public void onServiceResolved(NsdServiceInfo serviceInfo) { // check name again (could have changed in the mean time) if (tgt.equals(serviceInfo.getServiceName())) { log(String.format(res.getString(R.string.log_service_resolve_success), serviceInfo)); mService = serviceInfo; openSocket(mService.getHost().getHostName(), mService.getPort()); } } }); } public void connectToAddress(String tgt) { log(String.format(res.getString(R.string.log_directly_connecting), tgt)); String[] addr = tgt.split(":"); (new Thread(()-> openSocket(addr[0], Integer.parseInt(addr[1])))).start(); } public void reconnect(View view) { String tgt = mSpinner.getSelectedItem().toString(); if (currentHost == null) { Toast.makeText(YokeActivity.this, res.getString(R.string.toast_connected_to_nowhere), Toast.LENGTH_LONG).show(); } else { log(res.getString(R.string.log_udp_closed)); mSocket.close(); mSocket = null; wv.loadUrl("about:blank"); vals_str = null; (new Thread(()-> openSocket(currentHost, currentPort))).start(); } } public void showOverflowMenu(View view) { PopupMenu popup = new PopupMenu(this, view); popup.inflate(R.menu.overflow); popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.upgradeLayout: if (currentHost != null) { new DownloadFilesFromURL().execute( "http://" + currentHost + ":" + Integer.toString(currentPort) + "/" ); } else { Toast.makeText(YokeActivity.this, res.getString(R.string.toast_connected_to_nowhere), Toast.LENGTH_LONG ).show(); } return true; default: return false; } } }); popup.show(); } public void openSocket(String host, int port) { currentHost = host; currentPort = port; log(String.format(res.getString(R.string.log_opening_udp), host, port)); try { mSocket = new DatagramSocket(0); mSocket.connect(InetAddress.getByName(host), port); log(res.getString(R.string.log_open_udp_success)); String url = "file://" + currentMainPath.toString(); YokeActivity.this.runOnUiThread(() -> { mTextView.setText(res.getString(R.string.toolbar_connected_to)); if (currentMainPath.exists()) { wv.loadUrl(url); } else { Toast.makeText(YokeActivity.this, String.format( res.getString(R.string.toast_download_layout_first), res.getString(R.string.menu_upgrade_layout), res.getString(R.string.toolbar_reconnect) ), Toast.LENGTH_LONG).show(); } }); log(String.format(res.getString(R.string.log_loading_url), url)); } catch (SocketException | UnknownHostException e) { mSocket = null; currentHost = null; YokeActivity.this.runOnUiThread(() -> { mSpinner.setSelection(mAdapter.getPosition(NOTHING)); }); logError(String.format(res.getString(R.string.error_open_udp_error), host, port), e); } } public void onDiscoveryStarted(String serviceType) { log(String.format(res.getString(R.string.log_discovery_started), serviceType)); } @Override public void onServiceFound(NsdServiceInfo service) { log(res.getString(R.string.log_service_found) + service); mServiceMap.put(service.getServiceName(), service); mServiceNames.add(service.getServiceName()); this.runOnUiThread(() -> { if (mSpinner.getSelectedItem().toString().equals(service.getServiceName())) return; mAdapter.add(service.getServiceName()); }); } @Override public void onServiceLost(NsdServiceInfo service) { log(res.getString(R.string.log_service_lost) + service); mServiceMap.remove(service.getServiceName()); mServiceNames.remove(service.getServiceName()); this.runOnUiThread(() -> { if (mSpinner.getSelectedItem().toString().equals(service.getServiceName())) return; mAdapter.remove(service.getServiceName()); }); } @Override public void onDiscoveryStopped(String serviceType) { log(String.format(res.getString(R.string.log_discovery_stopped), serviceType)); } @Override public void onStartDiscoveryFailed(String serviceType, int errorCode) { logError(String.format(res.getString(R.string.error_discovery_failed), errorCode), null); mNsdManager.stopServiceDiscovery(this); } @Override public void onStopDiscoveryFailed(String serviceType, int errorCode) { logError(String.format(res.getString(R.string.error_discovery_failed), errorCode), null); mNsdManager.stopServiceDiscovery(this); } private void closeConnection() { mService = null; if (mSocket != null) { log(res.getString(R.string.log_udp_closed)); mSocket.close(); mSocket = null; } currentHost = null; wv.loadUrl("about:blank"); vals_str = null; } }