diff --git a/BTLib/build.gradle b/BTLib/build.gradle index b057ceca..6928f749 100644 --- a/BTLib/build.gradle +++ b/BTLib/build.gradle @@ -40,6 +40,12 @@ android { baseline = file("lint-baseline.xml") } + testOptions { + unitTests.all { + useJUnitPlatform() + } + } + } dependencies { @@ -66,4 +72,11 @@ dependencies { api libs.coroutines.android api libs.datastore.preferences + + api 'com.github.ncmud:mth:2.0.4' + + testImplementation libs.junit5.api + testRuntimeOnly libs.junit5.engine + testRuntimeOnly libs.junit5.launcher + testImplementation libs.coroutines.test } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/Connection.java b/BTLib/src/main/java/com/offsetnull/bt/service/Connection.java index 169b27e8..84209178 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/Connection.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/Connection.java @@ -8,10 +8,8 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; -import android.os.Bundle; import android.os.Environment; import android.os.Handler; -import android.os.Message; import android.util.Log; import com.offsetnull.bt.alias.AliasData; @@ -77,7 +75,8 @@ public class Connection GMCPContext, TriggerContext, AliasContext, - SettingsContext { + SettingsContext, + ConnectionHandle { /** Initiates the connection with the server. */ public static final int MESSAGE_STARTUP = 1; @@ -206,22 +205,22 @@ public class Connection * Sent from the foreground window indicating that the DataPumper should re-establish the tcp * connection to the server. */ - private static final int MESSAGE_RECONNECT = 31; + static final int MESSAGE_RECONNECT = 31; /** Sent from the foreground window, initates a settings reset. */ - private static final int MESSAGE_DORESETSETTINGS = 27; + static final int MESSAGE_DORESETSETTINGS = 27; /** Sent from the foreground window, adds an external plugin at the given path. */ - private static final int MESSAGE_ADDLINK = 28; + static final int MESSAGE_ADDLINK = 28; /** Sent from the foreground window, deletes and removes a plugin. */ - private static final int MESSAGE_DELETEPLUGIN = 29; + static final int MESSAGE_DELETEPLUGIN = 29; /** * Sent from Plugin.CallPlugin calls an anonymous global function in the target plugin with * arguments. */ - private static final int MESSAGE_CALLPLUGIN = 35; + static final int MESSAGE_CALLPLUGIN = 35; /** Sent from the timer command. */ static final int MESSAGE_TIMERINFO = 36; @@ -238,6 +237,12 @@ public class Connection /** Sent from the timer command. */ static final int MESSAGE_TIMERSTOP = 40; + /** Server negotiated WILL ECHO — disable client local echo. */ + static final int MESSAGE_DISABLE_LOCAL_ECHO = 42; + + /** Server negotiated WONT ECHO — re-enable client local echo. */ + static final int MESSAGE_ENABLE_LOCAL_ECHO = 43; + /** The value of 4. */ private static final int FOUR = 4; @@ -271,45 +276,48 @@ public class Connection /** Value of -2. */ private static final int NEGATIVE_TWO = -2; - private TriggerManager mTriggerManager; - private AliasManager mAliasManager; + TriggerManager mTriggerManager; + AliasManager mAliasManager; private SettingsPersistence mSettingsPersistence; /** String name of the default output window. */ private static final String MAIN_WINDOW = "mainDisplay"; /** Manages window tokens, callbacks, and window-related operations. */ - private ConnectionWindowManager mWindowManager; + ConnectionWindowManager mWindowManager; /** The auto reconnect limit helper varialbe. */ private Integer mAutoReconnectLimit; /** The current auto reconnect attempt. */ - private Integer mAutoReconnectAttempt = 0; + Integer mAutoReconnectAttempt = 0; /** Weather or not we should auto reconnect on connection failure. */ private Boolean mAutoReconnect; - /** - * The main looper handler for this "foreground" thread, although I'm not sure if service - * processes get "foreground threads". - */ - private Handler mHandler = null; + /** Coroutine-based event loop replacing the legacy Handler dispatch. */ + ConnectionEventLoop mEventLoop; + + /** Backward-compatible Handler shim for callers still sending Message objects. */ + private Handler mHandlerShim = null; /** Global handler for the speedwalk command, useful for changing the settings. */ private SpeedwalkCommand mSpeedwalkCommand = null; /** Main tracker for plugins, generic ordered list of plugins in the order they were loaded. */ - private ArrayList mPlugins = null; + ArrayList mPlugins = null; /** Global map for handling the capture transformation for triggers and aliases. */ private HashMap mCaptureMap = new HashMap(); /** The DataPumper instance for this connection. */ - private DataPumper mPump = null; + DataPumper mPump = null; + + /** The MTH TelnetClientSession for this connection. */ + mth.core.client.TelnetClientSession mTelnetSession = null; - /** The Processor instance for this connection. */ - private Processor mProcessor = null; + /** GMCP supports string sent during negotiation (e.g. "\"char 1\""). */ + String mGMCPSupports = "\"char 1\""; // TextTree buffer = null; @@ -323,29 +331,34 @@ public class Connection private boolean mLoaded = false; /** Launcher display name for this Connection. */ - private String mDisplay; + String mDisplay; /** Host name for this connection. */ - private String mHost; + String mHost; /** Port indication for this connection. */ - private int mPort; + int mPort; /** Instance of our parent service. This is bad. */ - private StellarService mService = null; + StellarService mService = null; /** A simple holder for if we are connected or not. */ - private boolean mIsConnected = false; + boolean mIsConnected = false; /** The main settings wad/plugin. */ - private ConnectionSettingsPlugin mSettings = null; + ConnectionSettingsPlugin mSettings = null; - private TimerManager mTimerManager; - private GMCPHandler mGMCPHandler; + TimerManager mTimerManager; + GMCPHandler mGMCPHandler; /** The keyboard command instance, not sure why this is here. */ private KeyboardCommand mKeyboardCommand; + private ConnectionDispatcher mDispatcher; + + /** Cancellable job for delayed reconnect. */ + private kotlinx.coroutines.Job mReconnectJob = null; + /** Value of CRLF. */ private String mCRLF = "\r\n"; @@ -402,12 +415,38 @@ public Connection( this.mService = service; mPlugins = new ArrayList(); - mHandler = new Handler(new ConnectionHandler()); mTriggerManager = new TriggerManager(this); mWindowManager = new ConnectionWindowManager(); mSettingsPersistence = new SettingsPersistence(this); + mDispatcher = new ConnectionDispatcher( + new TriggerManagerAdapter(this), + new PumpAdapter(this), + new BellCallbacksAdapter(this), + new DisplayAdapter(this), + new LifecycleAdapter(this), + new WindowManagerAdapter(this), + new PluginManagerAdapter(this), + new TimerManagerAdapter(this), + new GmcpAdapter(this), + new AliasManagerAdapter(this), + "UTF-8"); + + mEventLoop = new ConnectionEventLoop(mDispatcher); + mEventLoop.start(); + + final Connection self = this; + mHandlerShim = new ConnectionHandlerShim( + mEventLoop, + bytes -> self.sendToServer(bytes), + str -> { + try { + self.sendToServer(str.getBytes(self.mSettings.getEncoding())); + } catch (java.io.UnsupportedEncodingException ignored) { } + }, + () -> self.mPump != null && self.mPump.isConnected()); + SharedPreferences sprefs = this.getContext().getSharedPreferences("STATUS_BAR_HEIGHT", 0); mStatusBarHeight = sprefs.getInt( @@ -426,242 +465,6 @@ public Connection( } - /** - * The connection handler message queue. Coordinates multithreaded efforts from the DataPumper - * and foreground window via the Service. - */ - private class ConnectionHandler implements Handler.Callback { - - @SuppressWarnings("unchecked") - @Override - public boolean handleMessage(final Message msg) { - switch (msg.what) { - case MESSAGE_TERMINATED_BY_PEER: - killNetThreads(true); - doDisconnect(true); - mIsConnected = false; - break; - case MESSAGE_TIMERSTOP: - mTimerManager.handleAction( - (String) msg.obj, msg.arg2, TimerManager.TimerAction.STOP); - break; - case MESSAGE_TIMERSTART: - mTimerManager.handleAction( - (String) msg.obj, msg.arg2, TimerManager.TimerAction.PLAY); - break; - case MESSAGE_TIMERRESET: - mTimerManager.handleAction( - (String) msg.obj, msg.arg2, TimerManager.TimerAction.RESET); - break; - case MESSAGE_TIMERINFO: - mTimerManager.handleAction( - (String) msg.obj, msg.arg2, TimerManager.TimerAction.INFO); - break; - case MESSAGE_TIMERPAUSE: - mTimerManager.handleAction( - (String) msg.obj, msg.arg2, TimerManager.TimerAction.PAUSE); - break; - case MESSAGE_CALLPLUGIN: - String ptmp = msg.getData().getString("PLUGIN"); - String ftmp = msg.getData().getString("FUNCTION"); - String dtmp = msg.getData().getString("DATA"); - doCallPlugin(ptmp, ftmp, dtmp); - break; - case MESSAGE_SETTRIGGERSDIRTY: - mTriggerManager.setDirty(); - break; - case MESSAGE_RELOADSETTINGS: - reloadSettings(); - break; - case MESSAGE_TRIGGER_LUA_ERROR: - dispatchLuaError((String) msg.obj); - break; - case MESSAGE_RECONNECT: - doReconnect(); - break; - case MESSAGE_CONNECTED: - mAutoReconnectAttempt = 0; - break; - case MESSAGE_DELETEPLUGIN: - doDeletePlugin((String) msg.obj); - break; - case MESSAGE_ADDLINK: - doAddLink((String) msg.obj); - break; - case MESSAGE_DORESETSETTINGS: - doResetSettings(); - break; - case MESSAGE_PLUGINLUAERROR: - dispatchLuaError((String) msg.obj); - break; - case MESSAGE_EXPORTFILE: - exportSettings((String) msg.obj); - break; - case MESSAGE_IMPORTFILE: - Connection.this.mService.markWindowsDirty(); - importSettings((String) msg.obj, true, false); - break; - case MESSAGE_SAVESETTINGS: - String changedplugin = (String) msg.obj; - Connection.this.saveDirtyPlugin(changedplugin); - break; - case MESSAGE_GMCPTRIGGERED: - String plugin = msg.getData().getString("TARGET"); - String gcallback = msg.getData().getString("CALLBACK"); - @SuppressWarnings("unchecked") - HashMap gdata = (HashMap) msg.obj; - mGMCPHandler.handleCallback(plugin, gcallback, gdata); - break; - case MESSAGE_INVALIDATEWINDOWTEXT: - String wname = (String) msg.obj; - doInvalidateWindowText(wname); - break; - case MESSAGE_WINDOWXCALLS: - Object o = msg.obj; - if (o == null) { - o = ""; - } - String token = msg.getData().getString("TOKEN"); - String function = msg.getData().getString("FUNCTION"); - Connection.this.windowXCallS(token, function, o); - break; - case MESSAGE_WINDOWXCALLB: - byte[] bytesa = (byte[]) msg.obj; - String tokens = msg.getData().getString("TOKEN"); - String functions = msg.getData().getString("FUNCTION"); - Connection.this.windowXCallB(tokens, functions, bytesa); - break; - case MESSAGE_ADDFUNCTIONCALLBACK: - Bundle data = msg.getData(); - String id = data.getString("ID"); - String command = data.getString("COMMAND"); - String callback = data.getString("CALLBACK"); - int pid = -1; - for (int i = 0; i < mPlugins.size(); i++) { - Plugin p = mPlugins.get(i); - if (p.getName().equals(id)) { - pid = i; - } - } - if (pid != -1) { - FunctionCallbackCommand fcc = - new FunctionCallbackCommand(pid, command, callback); - mAliasManager.getSpecialCommands().put(fcc.commandName, fcc); - } - break; - case MESSAGE_WINDOWBUFFER: - boolean set = (msg.arg1 == 0) ? false : true; - - String name = (String) msg.obj; - - for (WindowToken tok : mWindowManager.getWindows()) { - if (tok.getName().equals(name)) { - tok.setBufferText(set); - } - } - break; - case MESSAGE_NEWWINDOW: - WindowToken tok = (WindowToken) msg.obj; - mWindowManager.getWindows().add(tok); - break; - case MESSAGE_DRAWINDOW: - Connection.this.redrawWindow((String) msg.obj); - break; - case MESSAGE_LUANOTE: - String str = (String) msg.obj; - if (str != null) { - try { - dispatchNoProcess(str.getBytes(mSettings.getEncoding())); - } catch (UnsupportedEncodingException e1) { - e1.printStackTrace(); - } - } - break; - case MESSAGE_LINETOWINDOW: - Object line = msg.obj; - String target = msg.getData().getString("TARGET"); - Connection.this.lineToWindow(target, line); - break; - case MESSAGE_SENDDATA_STRING: - try { - byte[] bytes = ((String) msg.obj).getBytes(mSettings.getEncoding()); - sendToServer(bytes); - } catch (UnsupportedEncodingException e1) { - e1.printStackTrace(); - } - break; - case MESSAGE_SENDDATA_BYTES: - sendToServer((byte[]) msg.obj); - break; - case MESSAGE_SENDGMCPDATA: - if (mPump != null && mPump.isConnected()) { - mGMCPHandler.sendData((String) msg.obj); - } else { - mHandler.sendMessageDelayed( - mHandler.obtainMessage(MESSAGE_SENDGMCPDATA, msg.obj), - FIVE_HUNDRED_MILLIS); - } - break; - case MESSAGE_STARTUP: - doStartup(); - break; - case MESSAGE_STARTCOMPRESS: - mPump.getHandler() - .sendMessage( - mPump.getHandler() - .obtainMessage(DataPumper.MESSAGE_COMPRESS, msg.obj)); - break; - case MESSAGE_SENDOPTIONDATA: - Bundle b = msg.getData(); - byte[] obytes = b.getByteArray("THE_DATA"); - String message = b.getString("DEBUG_MESSAGE"); - if (message != null) { - sendDataToWindow(message); - } - - if (mPump != null) { - mPump.sendData(obytes); - } - break; - case MESSAGE_PROCESSORWARNING: - sendDataToWindow((String) msg.obj); - break; - case MESSAGE_BELLINC: - if (mSettings.isVibrateOnBell()) { - Connection.this.mService.doVibrateBell(); - } - if (mSettings.isNotifyOnBell()) { - Connection.this.mService.doNotifyBell( - Connection.this.mDisplay, - Connection.this.mHost, - Connection.this.mPort); - } - if (mSettings.isDisplayOnBell()) { - Connection.this.mService.doDisplayBell(); - } - break; - case MESSAGE_DODIALOG: - dispatchDialog((String) msg.obj); - break; - case MESSAGE_PROCESS: - try { - mTriggerManager.dispatch((byte[]) msg.obj); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - break; - case MESSAGE_DISCONNECTED: - killNetThreads(true); - doDisconnect(false); - mIsConnected = false; - break; - default: - break; - } - return true; - } - } - /** * Quick frontend for dispatchNoProcess(...) for sending a lua error message. * @@ -737,7 +540,7 @@ protected final void windowXCallB( * @param function Name of the anonymous global function to call. * @param data String argument to provide to @param function. */ - private void doCallPlugin(final String plugin, final String function, final String data) { + void doCallPlugin(final String plugin, final String function, final String data) { Plugin p = mPluginMap.get(plugin); if (p != null) { p.callFunction(function, data); @@ -860,7 +663,7 @@ private void loadPlugins(final ArrayList tmpPlugs, final String summary) link, mService.getApplicationContext(), tmplist, - mHandler, + mHandlerShim, this); try { @@ -987,7 +790,7 @@ public final void unregisterWindowCallback(final WindowCallback callback) { * @param override Indicates weather the auto reconnect should be overridden. */ protected final void doDisconnect(final boolean override) { - if (mHandler == null) { + if (mEventLoop == null) { return; } if (mAutoReconnect && !override) { @@ -1002,9 +805,9 @@ protected final void doDisconnect(final boolean override) { + " tries remaining." + Colorizer.getWhiteColor() + "\n"; - mHandler.sendMessage( - mHandler.obtainMessage(Connection.MESSAGE_PROCESSORWARNING, message)); - mHandler.sendEmptyMessageDelayed(MESSAGE_RECONNECT, THREE_THOUSAND_MILLIS); + mEventLoop.send(new ConnectionCommand.ProcessorWarning(message)); + mReconnectJob = mEventLoop.sendDelayed( + ConnectionCommand.Reconnect.INSTANCE, THREE_THOUSAND_MILLIS); return; } } @@ -1022,26 +825,15 @@ protected final void killNetThreads(final boolean noreconnect) { if (mPump == null) { return; } - if (mPump != null) { - if (mPump.getHandler() != null) { - mPump.closeSocket(); - // mPump.getHandler().removeMessages(DataPumper.MESSAGE_RETRIEVE); - mPump.getHandler().removeCallbacksAndMessages(null); - mPump.getHandler().sendEmptyMessage(DataPumper.MESSAGE_END); - try { - mPump.join(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } + mPump.shutdown(); - mProcessor = null; + mTelnetSession = null; if (noreconnect) { - if (mHandler != null) { - mHandler.removeMessages(MESSAGE_RECONNECT); + if (mReconnectJob != null) { + mReconnectJob.cancel(null); + mReconnectJob = null; } } @@ -1054,7 +846,6 @@ protected final void killNetThreads(final boolean noreconnect) { * @param data The data to send. */ public void dispatchNoProcess(final byte[] data) { - mWindowManager.getWindows().get(0).getBuffer().addBytesImplSimple(data); sendBytesToWindow(data); } @@ -1063,6 +854,22 @@ public final void setTriggersDirty() { mTriggerManager.setDirty(); } + final void addFunctionCallbackImpl( + final String id, final String command, final String callback) { + int pid = -1; + for (int i = 0; i < mPlugins.size(); i++) { + Plugin p = mPlugins.get(i); + if (p.getName().equals(id)) { + pid = i; + } + } + if (pid != -1) { + FunctionCallbackCommand fcc = + new FunctionCallbackCommand(pid, command, callback); + mAliasManager.getSpecialCommands().put(fcc.commandName, fcc); + } + } + /** * Called from a few places I think. Triggers the network disconnected dialog in the foreground * window. Unless the auto reconnect is set. @@ -1070,7 +877,7 @@ public final void setTriggersDirty() { * @param str The message fro the dialog. */ protected final void dispatchDialog(final String str) { - if (mHandler == null || str == null) { + if (mEventLoop == null || str == null) { return; } if (mAutoReconnect) { @@ -1088,9 +895,9 @@ protected final void dispatchDialog(final String str) { + " tries remaining." + Colorizer.getWhiteColor() + "\n"; - mHandler.sendMessage( - mHandler.obtainMessage(Connection.MESSAGE_PROCESSORWARNING, message)); - mHandler.sendEmptyMessageDelayed(MESSAGE_RECONNECT, TWENTY_THOUSAND_MILLIS); + mEventLoop.send(new ConnectionCommand.ProcessorWarning(message)); + mReconnectJob = mEventLoop.sendDelayed( + ConnectionCommand.Reconnect.INSTANCE, TWENTY_THOUSAND_MILLIS); return; } } @@ -1120,15 +927,25 @@ public final void sendBytesToWindow(final byte[] data) { mWindowManager.sendBytesToWindow(data); } + @Override + public void startup() { + if (mPump == null) { + doStartup(); + } + } + /** Meat of the startup sequence. Starts the net threads after the settings have been loaded. */ private void doStartup() { killNetThreads(true); - mPump = new DataPumper(mHost, mPort, mHandler); + mPump = new DataPumper(mHost, mPort, mHandlerShim); - mProcessor = - new Processor(mHandler, mSettings.getEncoding(), mService.getApplicationContext()); + mTelnetSession = new mth.core.client.TelnetClientSession( + new TelnetDelegateAdapter(this), + com.offsetnull.bt.settings.ConfigurationLoader.getConfigurationValue( + "terminalTypeString", mService.getApplicationContext()), + 80, 24); initSettings(); mPump.start(); @@ -1564,15 +1381,13 @@ public final byte[] doKeyboardAliasReplace(final byte[] bytes, final Boolean rep /** Helper method that kicks off the reconnection sequence. */ public final void startReconnect() { - mHandler.sendEmptyMessage(MESSAGE_RECONNECT); + mEventLoop.send(ConnectionCommand.Reconnect.INSTANCE); } /** Helper method to initiate a reconnect right now. */ public final void doReconnect() { if (mPump != null) { - if (mPump.getHandler() != null) { - mPump.getHandler().sendEmptyMessage(DataPumper.MESSAGE_END); - } + mPump.shutdown(); mPump = null; } @@ -1978,9 +1793,7 @@ public final void updateSetting(final String key, final String value) { mSettings.setSemiIsNewLine((Boolean) o.getValue()); break; case debug_telnet: - if (mProcessor != null) { - mProcessor.setDebugTelnet((Boolean) o.getValue()); - } + mSettings.setDebugTelnet((Boolean) o.getValue()); break; case encoding: this.doUpdateEncoding((String) o.getValue()); @@ -2043,10 +1856,9 @@ public final void updateSetting(final String key, final String value) { mService.dispatchShowRegexWarning((Boolean) o.getValue()); break; case use_gmcp: - mProcessor.setUseGMCP((Boolean) o.getValue()); break; case gmcp_supports: - mProcessor.setGMCPSupports((String) o.getValue()); + mGMCPSupports = (String) o.getValue(); break; default: break; @@ -2063,9 +1875,6 @@ public final void updateSetting(final String key, final String value) { */ private void doSetDebugTelnet(final Boolean value) { mSettings.setDebugTelnet(value); - if (mProcessor != null) { - mProcessor.setDebugTelnet(value); - } } /** @@ -2098,16 +1907,8 @@ private void doSetKeepWifiAlive(final Boolean value) { * @param value New value to use. */ private void doUpdateEncoding(final String value) { - if (mProcessor == null) { - return; - } - mProcessor.setEncoding(value); - // this.encoding = value; mSettings.setEncoding(value); mTriggerManager.setEncoding(value); - if (mProcessor != null) { - this.mProcessor.setEncoding(value); - } for (int i = 0; i < mWindowManager.getWindows().size(); i++) { WindowToken w = mWindowManager.getWindows().get(i); w.getBuffer().setEncoding(value); @@ -2251,16 +2052,11 @@ private void sendToServer(final byte[] bytes) { } if (d.mVisString != null && !d.mVisString.equals("")) { if (mSettings.isLocalEcho()) { - mWindowManager - .getWindows() - .get(0) - .getBuffer() - .addBytesImplSimple(d.mVisString.getBytes(mSettings.getEncoding())); sendBytesToWindow(d.mVisString.getBytes(mSettings.getEncoding())); } } } catch (IOException e) { - mHandler.sendEmptyMessage(MESSAGE_DISCONNECTED); + mEventLoop.send(ConnectionCommand.Disconnected.INSTANCE); } } @@ -2306,7 +2102,7 @@ public final void exportSettings(final String path) { * @param path Path to save settings to, this must be absolute from the root directory (?) */ public final void startExportSequence(final String path) { - mHandler.sendMessage(mHandler.obtainMessage(MESSAGE_EXPORTFILE, path)); + mEventLoop.send(new ConnectionCommand.ExportFile(path)); } /** @@ -2316,7 +2112,7 @@ public final void startExportSequence(final String path) { * @param save flag to save the settings after loading. * @param loadmessage verb for loading(true) or importing(false) */ - private void importSettings(final String path, final boolean save, final boolean loadmessage) { + void importSettings(final String path, final boolean save, final boolean loadmessage) { shutdownPlugins(); String verb = null; @@ -2388,7 +2184,7 @@ private void importSettings(final String path, final boolean save, final boolean ArrayList tmpplugs = new ArrayList(); ConnectionSetttingsParser newsettings = new ConnectionSetttingsParser( - null, mService.getApplicationContext(), tmpplugs, mHandler, this); + null, mService.getApplicationContext(), tmpplugs, mHandlerShim, this); tmpplugs = newsettings.load(this, dataDir); Plugin buttonwindow = tmpplugs.get(1); @@ -2636,7 +2432,7 @@ private void importSettings(final String path, final boolean save, final boolean path, mService.getApplicationContext(), tmpplugs, - mHandler, + mHandlerShim, this); ApplicationInfo ai = null; try { @@ -2801,7 +2597,7 @@ private void initSettings() { /** Entry point for the foreground window to reset the settings for this connection. */ public final void resetSettings() { - this.mHandler.sendEmptyMessage(MESSAGE_DORESETSETTINGS); + mEventLoop.send(ConnectionCommand.ResetSettings.INSTANCE); } /** Work horse routine that actually resets the settings. */ @@ -2819,7 +2615,7 @@ public final void doResetSettings() { * @param path Path of the settings to load. */ public final void startLoadSettingsSequence(final String path) { - mHandler.sendMessage(mHandler.obtainMessage(MESSAGE_IMPORTFILE, path)); + mEventLoop.send(new ConnectionCommand.ImportFile(path)); } /** @@ -2839,7 +2635,7 @@ public final void doAddLink(final String path) { * @param path The location of the external settings file. */ public final void addLink(final String path) { - mHandler.sendMessage(mHandler.obtainMessage(MESSAGE_ADDLINK, path)); + mEventLoop.send(new ConnectionCommand.AddLink(path)); } /** @@ -2847,7 +2643,7 @@ public final void addLink(final String path) { * * @param plugin The name of the plugin to remove. */ - private void doDeletePlugin(final String plugin) { + void doDeletePlugin(final String plugin) { Plugin p = mPluginMap.remove(plugin); String remove = null; @@ -2874,7 +2670,7 @@ private void doDeletePlugin(final String plugin) { * @param plugin The name of the plugin to remove. */ public final void deletePlugin(final String plugin) { - mHandler.sendMessage(mHandler.obtainMessage(MESSAGE_DELETEPLUGIN, plugin)); + mEventLoop.send(new ConnectionCommand.DeletePlugin(plugin)); } /** @@ -2957,8 +2753,13 @@ public final void shutdown() { } mSettings.shutdown(); mSettings = null; - mHandler.removeMessages(MESSAGE_RECONNECT); - mHandler = null; + if (mReconnectJob != null) { + mReconnectJob.cancel(null); + mReconnectJob = null; + } + mEventLoop.shutdown(); + mEventLoop = null; + mHandlerShim = null; mService.removeConnectionNotification(mDisplay); } @@ -2979,7 +2780,9 @@ public final String getPluginPath(final String plugin) { * @param str The string to send. */ public final void dispatchLuaText(final String str) { - mHandler.sendMessage(mHandler.obtainMessage(Connection.MESSAGE_LUANOTE, str)); + if (str != null) { + mEventLoop.send(new ConnectionCommand.LuaNote(str)); + } } /** @@ -3010,11 +2813,7 @@ public final SettingsChangedListener getSettingsListener() { @Override public final void callPlugin(final String plugin, final String function, final String data) { - Message m = mHandler.obtainMessage(MESSAGE_CALLPLUGIN); - m.getData().putString("PLUGIN", plugin); - m.getData().putString("FUNCTION", function); - m.getData().putString("DATA", data); - mHandler.sendMessage(m); + mEventLoop.send(new ConnectionCommand.CallPlugin(plugin, function, data)); } @Override @@ -3037,12 +2836,25 @@ public final boolean isPluginInstalled(final String desired) { } /** - * Getter for mHandler. + * Returns a backward-compatible Handler shim for callers that still use Message-based dispatch. * - * @return The handler associated with this connection. + * @return The handler shim associated with this connection. + * @deprecated Use {@link #sendCommand(ConnectionCommand)} instead. */ + @Deprecated public final Handler getHandler() { - return mHandler; + return mHandlerShim; + } + + /** + * Sends a command through the event loop for dispatch. + * + * @param command The command to dispatch. + */ + public final void sendCommand(final ConnectionCommand command) { + if (mEventLoop != null) { + mEventLoop.send(command); + } } /** @@ -3073,13 +2885,13 @@ public final DataPumper getPump() { return mPump; } - /** - * Getter for mProcessor. - * - * @return the processor associated with this connection. - */ - public final Processor getProcessor() { - return mProcessor; + public final mth.core.client.TelnetClientSession getTelnetSession() { + return mTelnetSession; + } + + @Override + public void sendGMCPTriggered(String plugin, String callback, java.util.HashMap data) { + sendCommand(new ConnectionCommand.GmcpTriggered(plugin, callback, data)); } /** diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/ConnectionWindowManager.java b/BTLib/src/main/java/com/offsetnull/bt/service/ConnectionWindowManager.java index 9d87121c..a368a50d 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/ConnectionWindowManager.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/ConnectionWindowManager.java @@ -139,15 +139,15 @@ public void lineToWindow(final String target, final Object line, final String en tmp.updateMetrics(); byte[] lol = tmp.dumpToBytes(false); - try { - w.getBuffer().addBytesImpl(lol); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - WindowCallback c = mWindowCallbackMap.get(target); if (c != null) { c.rawDataIncoming(lol); + } else { + try { + w.getBuffer().addBytesImpl(lol); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } } } } @@ -233,6 +233,18 @@ public void sendBytesToWindow(final byte[] data) { WindowCallback c = mWindowCallbackMap.get(MAIN_WINDOW); if (c != null) { c.rawDataIncoming(data); + } else { + // No callback registered yet — write directly to the buffer. + for (WindowToken w : mWindows) { + if (w.getName().equals(MAIN_WINDOW)) { + try { + w.getBuffer().addBytesImpl(data); + } catch (java.io.UnsupportedEncodingException e) { + e.printStackTrace(); + } + break; + } + } } } } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/DataPumper.java b/BTLib/src/main/java/com/offsetnull/bt/service/DataPumper.java index 8400de55..9a9cd5c6 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/DataPumper.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/DataPumper.java @@ -4,219 +4,69 @@ package com.offsetnull.bt.service; import android.os.Handler; -import android.os.Looper; import android.os.Message; import android.util.Log; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; +import com.offsetnull.bt.service.net.DataPumperBridge; +import com.offsetnull.bt.service.net.PumpEvent; +import com.offsetnull.bt.service.net.RealSocketIO; + import java.math.BigInteger; import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.ProtocolException; -import java.net.Socket; -import java.net.SocketAddress; -import java.net.SocketException; -import java.net.SocketTimeoutException; import java.net.UnknownHostException; -import java.nio.ByteBuffer; -import java.util.zip.DataFormatException; -import java.util.zip.Inflater; /** - * Data pumper thread implementation. This object manages itself as a thread, as well as a child - * thread process to write output data to. + * Data pumper implementation. Delegates network I/O to coroutine-based + * DataPumperLoop via {@link DataPumperBridge}, while preserving the public + * API that {@link Connection} and {@link GMCPHandler} depend on. */ -public class DataPumper extends Thread { - /** Constant indicating the socket polling loop. */ +public class DataPumper { + // Keep legacy message constants for any remaining references. public static final int MESSAGE_RETRIEVE = 100; - - /** Constant indicating orderly shutdown via user. */ public static final int MESSAGE_END = 102; - - /** Constant indicating that data transfer should begin. */ public static final int MESSAGE_INITXFER = 103; - - /** Constant indicating the end of the data transfer. */ public static final int MESSAGE_ENDXFER = 104; - - /** Contant indicating that compression should start. */ public static final int MESSAGE_COMPRESS = 105; - - /** Constant indicating that compression should end. */ public static final int MESSAGE_NOCOMPRESS = 106; - - /** Constant indicating that throttling should begin. */ public static final int MESSAGE_THROTTLE = 108; - - /** Constant indicating that throttling should end. */ public static final int MESSAGE_NOTHROTTLE = 109; - /** Timeout value in millis. */ private static final int SOCKET_TIMEOUT = 14000; - /** Socket buffer size. */ - private static final int SOCKET_BUFFER_SIZE = 1024; - - /** No throttling delay. */ - private static final int NO_THROTTLE_DELAY = 100; - - /** Throttling delay. */ - private static final int THROTTLE_DELAY = 1500; - - /** Working buffer size for the decompression routine. */ - private static final int DECOMPRESSION_BUFFER_SIZE = 256; - - /** The handler for this thread. */ - private Handler mHandler = null; - - /** The input stream from the socket. */ - private InputStream mReader = null; - - /** The handler to communicate progress and data with. */ - private Handler mReportTo = null; - - /** The compression state. */ - private boolean mCompressed = false; - - /** Zlib inflater object. */ - private Inflater mDecompressor = null; - - /** Indicates the throttling state. */ - private boolean mThrottle = false; - - /** Holder for the output writer thread. */ - private OutputWriterThread mWriterThread = null; + private final Handler mReportTo; + private final String mHost; + private final int mPort; - /** Holder for the host name for the connection. */ - private String mHost = ""; + private RealSocketIO mSocket; + private DataPumperBridge mBridge; + private volatile boolean mConnected = false; - /** Holder for the port number for the connection. */ - private int mPort; - - /** Holder for the actual socket used to communicate with. */ - private Socket mSocket = null; - - /** Tracker for MCCP Corruption. */ - private boolean mCorrupted = false; - - /** Tracker for the intention of corrupting the mccp stream. */ - private boolean mDoCorrupt = false; - - /** Tracker for if we are connected or not. */ - private boolean mConnected = false; - - /** Tracker for the intention of closing the socket. */ - private boolean mClosing = false; - - /** - * Generic constructor. - * - * @param host Host name for the connection. - * @param port Port number to use. - * @param useme Handler to report to when interesting things happen. - */ public DataPumper(final String host, final int port, final Handler useme) { this.mHost = host; this.mPort = port; - mReportTo = useme; + this.mReportTo = useme; } /** - * Sends data to the socket asynchronously. - * - * @param data the bytes to send to the server. + * Starts the DataPumper. Replaces Thread.start(). + * Connects on a background thread, then launches the coroutine loop. */ - public final void sendData(final byte[] data) { - Message msg = - mWriterThread.mOutputHandler.obtainMessage(OutputWriterThread.MESSAGE_SEND, data); - mWriterThread.mOutputHandler.sendMessage(msg); - } - - /** Utility class for housing the output writer thread. */ - class OutputWriterThread extends Thread { - /** Constant indicating orderly shutdown of this thread. */ - protected static final int MESSAGE_END = 101; - - /** Constant indicating that there is data to send. */ - protected static final int MESSAGE_SEND = 102; - - /** Abstraction from the raw stream. */ - private BufferedOutputStream mWriter = null; - - /** Handler for this thread. */ - private Handler mOutputHandler = null; - - /** - * Generic constructor. - * - * @param stream The stream from the socket to use when writing data. - */ - public OutputWriterThread(final BufferedOutputStream stream) { - mWriter = stream; - } - - @Override - public void run() { - Looper.prepare(); - mOutputHandler = new Handler(new WriteHandler()); - Looper.loop(); - } - } - - /** The write queue is contained in this handler callback. */ - private class WriteHandler implements Handler.Callback { - - @Override - public boolean handleMessage(final Message msg) { - switch (msg.what) { - case OutputWriterThread.MESSAGE_SEND: - // this will always be the same thing. - byte[] data = (byte[]) msg.obj; - - try { - mWriterThread.mWriter.write(data); - mWriterThread.mWriter.flush(); - } catch (IOException e1) { - dispatchDialog(e1.getMessage()); - mConnected = false; - } - break; - case OutputWriterThread.MESSAGE_END: - Log.e("TEST", "OUTPUT WRITER THREAD SHUTTING DOWN"); - - mWriterThread.mOutputHandler.getLooper().quit(); - break; - default: - break; - } - return true; - } + public final void start() { + new Thread(this::init, "DataPumper-init").start(); } /** Startup and initialization routine. */ public final void init() { - this.setName("DataPumper"); - InetAddress addr = null; - mClosing = false; - sendWarning( - new String( - Colorizer.getBrightCyanColor() - + "Attempting connection to: " - + Colorizer.getBrightYellowColor() - + mHost - + ":" - + mPort - + "\n" - + Colorizer.getBrightCyanColor() - + "Timeout set to 14 seconds." - + Colorizer.getWhiteColor() - + "\n")); - + Colorizer.getBrightCyanColor() + + "Attempting connection to: " + + Colorizer.getBrightYellowColor() + + mHost + ":" + mPort + "\n" + + Colorizer.getBrightCyanColor() + + "Timeout set to 14 seconds." + + Colorizer.getWhiteColor() + "\n"); + + InetAddress addr; try { addr = InetAddress.getByName(mHost); } catch (UnknownHostException e) { @@ -225,423 +75,136 @@ public final void init() { } String ip = addr.getHostAddress(); - if (!ip.equals(mHost)) { sendWarning( - Colorizer.getBrightCyanColor() - + "Looked up: " - + Colorizer.getBrightYellowColor() - + ip - + Colorizer.getBrightCyanColor() - + " for " - + Colorizer.getBrightYellowColor() - + mHost - + Colorizer.getWhiteColor() - + "\n"); + Colorizer.getBrightCyanColor() + "Looked up: " + + Colorizer.getBrightYellowColor() + ip + + Colorizer.getBrightCyanColor() + " for " + + Colorizer.getBrightYellowColor() + mHost + + Colorizer.getWhiteColor() + "\n"); } - mSocket = new Socket(); + mSocket = new RealSocketIO(mHost, mPort); try { + mSocket.connect(SOCKET_TIMEOUT); - mSocket = new Socket(); - SocketAddress adr = new InetSocketAddress(addr, mPort); - mSocket.setKeepAlive(true); - mSocket.setSoTimeout(0); - mSocket.connect(adr, SOCKET_TIMEOUT); sendWarning( - Colorizer.getBrightCyanColor() - + "Connected to: " - + Colorizer.getBrightYellowColor() - + mHost - + Colorizer.getBrightCyanColor() - + "!" - + Colorizer.getWhiteColor() - + "\n"); - - mSocket.setSendBufferSize(SOCKET_BUFFER_SIZE); - mWriterThread = - new OutputWriterThread(new BufferedOutputStream(mSocket.getOutputStream())); - mWriterThread.start(); + Colorizer.getBrightCyanColor() + "Connected to: " + + Colorizer.getBrightYellowColor() + mHost + + Colorizer.getBrightCyanColor() + "!" + + Colorizer.getWhiteColor() + "\n"); mConnected = true; - mReader = new BufferedInputStream(mSocket.getInputStream()); - mDecompressor = new Inflater(false); + + mBridge = new DataPumperBridge(mSocket, this::dispatchEvent); + mBridge.start(); mReportTo.sendEmptyMessage(Connection.MESSAGE_CONNECTED); - } catch (SocketException e) { - dispatchDialog("Socket Exception: " + e.getMessage()); - } catch (SocketTimeoutException e) { - dispatchDialog("Operation timed out."); - } catch (ProtocolException e) { - dispatchDialog("Protocol Exception: " + e.getMessage()); - } catch (IOException e) { - throw new RuntimeException(e); + + } catch (Exception e) { + dispatchDialog("Connection error: " + e.getMessage()); } } - /** - * Quick little helper method to send off the error dialog. - * - * @param str The message to put in the dialog. - */ - private void dispatchDialog(final String str) { - mReportTo.sendMessage(mReportTo.obtainMessage(Connection.MESSAGE_DODIALOG, str)); + private void dispatchEvent(PumpEvent event) { + if (event instanceof PumpEvent.DataReceived) { + byte[] data = ((PumpEvent.DataReceived) event).getData(); + Message msg = mReportTo.obtainMessage(Connection.MESSAGE_PROCESS, data); + synchronized (mReportTo) { + mReportTo.sendMessage(msg); + } + } else if (event instanceof PumpEvent.DisconnectedByPeer) { + sendWarning("\n" + Colorizer.getRedColor() + + "Connection terminated by peer." + + Colorizer.getWhiteColor() + "\n"); + mConnected = false; + mReportTo.sendEmptyMessage(Connection.MESSAGE_TERMINATED_BY_PEER); + } else if (event instanceof PumpEvent.Disconnected) { + mConnected = false; + mReportTo.sendEmptyMessage(Connection.MESSAGE_DISCONNECTED); + } else if (event instanceof PumpEvent.MccpFatalError) { + mConnected = false; + mReportTo.sendEmptyMessage(Connection.MESSAGE_MCCPFATALERROR); + } else if (event instanceof PumpEvent.Warning) { + sendWarning(((PumpEvent.Warning) event).getText()); + } else if (event instanceof PumpEvent.DialogError) { + dispatchDialog(((PumpEvent.DialogError) event).getMessage()); + } + } + + public final void sendData(final byte[] data) { + if (mBridge != null) { + mBridge.getLoop().send(data); + } } - /** - * Quick little helper method to send off a processor warning. - * - * @param str The processor warning to send. - */ public final void sendWarning(final String str) { mReportTo.sendMessage(mReportTo.obtainMessage(Connection.MESSAGE_PROCESSORWARNING, str)); } - @Override - public final void run() { - Looper.prepare(); - init(); - mHandler = new Handler(new ReadHandler()); - if (mReader != null) { - mHandler.sendEmptyMessage(MESSAGE_RETRIEVE); + public final void startCompression(final byte[] trailingData) { + if (mBridge != null) { + mBridge.getLoop().startCompression(trailingData); } - Looper.loop(); } - /** The reader thread is managed by this handler callback. */ - private class ReadHandler implements Handler.Callback { - @Override - public boolean handleMessage(final Message msg) { - // boolean doRestart = true; - switch (msg.what) { - case MESSAGE_THROTTLE: - mThrottle = true; - break; - case MESSAGE_NOTHROTTLE: - mHandler.removeMessages(MESSAGE_RETRIEVE); - mThrottle = false; - mHandler.sendEmptyMessage(MESSAGE_RETRIEVE); - - break; - case MESSAGE_RETRIEVE: - try { - getData(); - } catch (IOException e) { - throw new RuntimeException(e); - } - break; - case MESSAGE_END: - shutdownSocket(); - break; - case MESSAGE_INITXFER: - break; - case MESSAGE_ENDXFER: - mHandler.removeMessages(MESSAGE_RETRIEVE); - break; - case MESSAGE_COMPRESS: - try { - useCompression((byte[]) msg.obj); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - break; - case MESSAGE_NOCOMPRESS: - stopCompression(); - break; - default: - break; - } - if (!mHandler.hasMessages(MESSAGE_RETRIEVE) && mConnected) { - // only send if there are no messages already in queue. - if (!mThrottle) { - mHandler.sendEmptyMessageDelayed(MESSAGE_RETRIEVE, NO_THROTTLE_DELAY); - } else { - mHandler.sendEmptyMessageDelayed(MESSAGE_RETRIEVE, THROTTLE_DELAY); - } - } - - if (mClosing) { - shutdownSocket(); - } - return true; + public final void stopCompression() { + if (mBridge != null) { + mBridge.getLoop().stopCompression(); } + } - private void shutdownSocket() { - mHandler.removeMessages(MESSAGE_RETRIEVE); - Log.e("TEST", "DATA PUMPER STARTING END SEQUENCE"); - try { - if (mWriterThread != null) { - mWriterThread.mOutputHandler.sendEmptyMessage(OutputWriterThread.MESSAGE_END); - try { - Log.e("TEST", "KILLING WRITER THREAD"); - mWriterThread.join(); - Log.e("TEST", "WRITER THREAD DEAD"); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - if (mReader != null) { - mReader.close(); - } - if (mSocket != null) { - mSocket.close(); - } - } catch (IOException e1) { - e1.printStackTrace(); - } - Log.e("TEST", "Net reader thread stopping self."); - - Looper.myLooper().quit(); - DataPumper.this.interrupt(); - // Looper.myLooper().quit(); - } + public final boolean isConnected() { + return mConnected; } - /** Utility method to interrpt a blocked socket. */ - public final void interruptSocket() { - try { + public final void closeSocket() { + mConnected = false; + if (mSocket != null) { mSocket.close(); - } catch (IOException e) { - e.printStackTrace(); } } - /** - * Called when compression should be initated. - * - * @param input The data to start the compression with. This is whatever followed IAC SB MCCP2 - * IAC SE - * @throws UnsupportedEncodingException Thrown when a String<==>byte[] conversion is given a bad - * encoding option. - */ - private void useCompression(final byte[] input) throws UnsupportedEncodingException { - // Log.e("PUMP","COMPRESSION BEGINNING NOW!"); - mCompressed = true; - mCorrupted = false; - if (input == null) { - return; + /** Shuts down the coroutine scope and closes the socket. */ + public final void shutdown() { + mConnected = false; + if (mBridge != null) { + mBridge.shutdown(); + mBridge = null; } - if (input.length > 0) { - byte[] data = doDecompress(input); - if (data == null) { - return; - } - Message msg = - mReportTo.obtainMessage( - Connection.MESSAGE_PROCESS, data); // get a send data message. - synchronized (mReportTo) { - mReportTo.sendMessage(msg); // report to mom and dad. - } + if (mSocket != null) { + mSocket.close(); + mSocket = null; } } - /** Utility method to set the compression use flag. */ - private void stopCompression() { - mCompressed = false; - } - /** - * Utility method to convert byte[] to a hex string. - * - * @param bytes The byte array to convert. - * @return The hex string representation. + * Waits for the pumper to finish. Replaces Thread.join(). + * With coroutines, shutdown() is sufficient — this is a no-op for compatibility. */ - public static String toHex(final byte[] bytes) { - BigInteger bi = new BigInteger(1, bytes); - return String.format("%0" + (bytes.length << 1) + "X", bi); + public final void join() throws InterruptedException { + // Coroutine scope cancellation is synchronous enough for our purposes. } - /** - * The main data fetching routine. This takes care of reading data, managing disconnection and - * decompression. - * - * @throws IOException Thrown when there is a problem with the socket. - */ - private void getData() throws IOException { - int numtoread = 0; - try { - numtoread = mReader.available(); - } catch (IOException e) { - if (!mClosing) { - mReportTo.sendEmptyMessage(Connection.MESSAGE_DISCONNECTED); - } - mConnected = false; - return; - } - if (numtoread < 1) { - mReader.mark(1); - try { - if (mReader.read() == -1) { - if (!mClosing) { - sendWarning( - "\n" - + Colorizer.getRedColor() - + "Connection terminated by peer." - + Colorizer.getWhiteColor() - + "\n"); - mReportTo.sendEmptyMessage(Connection.MESSAGE_TERMINATED_BY_PEER); - } - mConnected = false; - } else { - mReader.reset(); - } - } catch (IOException e) { - e.printStackTrace(); - if (!mClosing) { - mReportTo.sendEmptyMessage(Connection.MESSAGE_DISCONNECTED); - } - mConnected = false; - return; - } - - } else { - byte[] data = new byte[numtoread]; - try { - mReader.read(data, 0, numtoread); - - } catch (IOException e) { - if (!mClosing) { - mReportTo.sendEmptyMessage(Connection.MESSAGE_DISCONNECTED); - } - mConnected = false; - } - - if (mCompressed) { - data = doDecompress(data); - if (data == null) { - return; - } - } - - if (mReportTo != null) { - Message msg = - mReportTo.obtainMessage( - Connection.MESSAGE_PROCESS, data); // get a send data message. - synchronized (mReportTo) { - mReportTo.sendMessage(msg); // report to mom and dad. - } - } - - data = null; // free data to the garbage collector. - } - } - - /** - * The workhorse decompression rotine. - * - * @param data Bytes in. - * @return Bytes out. - * @throws UnsupportedEncodingException Thown when there is a a problem with string encoding. - */ - private byte[] doDecompress(final byte[] data) throws UnsupportedEncodingException { - int count = 0; - - byte[] decompressedData = null; - - if (mDoCorrupt) { - mDoCorrupt = false; - if (data.length > 1) { - mDecompressor.setInput(data, 1, data.length - 1); - } - } else { - mDecompressor.setInput(data, 0, data.length); - } - - byte[] tmp = new byte[DECOMPRESSION_BUFFER_SIZE]; - - while (!mDecompressor.needsInput()) { - try { - count = mDecompressor.inflate(tmp, 0, tmp.length); - } catch (DataFormatException e) { - if (mReportTo != null) { - mDecompressor = new Inflater(false); - } - mReportTo.sendEmptyMessage(Connection.MESSAGE_MCCPFATALERROR); - mCompressed = false; - mCorrupted = true; - return null; - } - if (mDecompressor.finished()) { - int pos = data.length - mDecompressor.getRemaining(); - int length = mDecompressor.getRemaining(); - ByteBuffer b = ByteBuffer.allocate(length); - b.put(data, pos, length); - b.rewind(); - if (mReportTo != null) { - Message msg = - mReportTo.obtainMessage( - Connection.MESSAGE_PROCESS, - b.array()); // get a send data message. - synchronized (mReportTo) { - mReportTo.sendMessage(msg); // report to mom and dad. - } - } - mCompressed = false; - mDecompressor = new Inflater(false); - mCorrupted = false; - return null; - } - if (decompressedData == null && count > 0) { - ByteBuffer dcStart = ByteBuffer.allocate(count); - dcStart.put(tmp, 0, count); - decompressedData = dcStart.array(); - } else { // already have data, append tmp to us - if (count > 0) { // only perform this step if the inflation yielded results. - ByteBuffer tmpbuf = ByteBuffer.allocate(decompressedData.length + count); - tmpbuf.put(decompressedData, 0, decompressedData.length); - tmpbuf.put(tmp, 0, count); - tmpbuf.rewind(); - decompressedData = tmpbuf.array(); - } - } - } // end while - if (mCorrupted) { - return null; - } else { - return decompressedData; - } + /** @deprecated Use {@link #startCompression} directly. */ + public final Handler getHandler() { + return null; } - /** Utility function to corrup the MCCP stream. */ public final void corruptMe() { - mDoCorrupt = true; + Log.w("DataPumper", "corruptMe() called but not supported in coroutine-based DataPumper"); } - /** - * Utility method to get if the socket is connected. - * - * @return Weather or not the socket is connected. - */ - public final boolean isConnected() { - return mConnected; + public final void interruptSocket() { + closeSocket(); } - /** Utility method to close the socket, including the reader and writer threads. */ - public final void closeSocket() { - try { - mClosing = true; - mConnected = false; - if (mSocket != null) { - mSocket.shutdownInput(); - mSocket.shutdownOutput(); - mSocket.close(); - } - if (mReader != null) { - mReader.close(); - } - mSocket = null; - this.interrupt(); - } catch (IOException e) { - e.printStackTrace(); - } + private void dispatchDialog(final String str) { + mReportTo.sendMessage(mReportTo.obtainMessage(Connection.MESSAGE_DODIALOG, str)); } - /** - * Getter for mHandler. - * - * @return The mHandler handler for the reader thread. - */ - public final Handler getHandler() { - return mHandler; + public static String toHex(final byte[] bytes) { + BigInteger bi = new BigInteger(1, bytes); + return String.format("%0" + (bytes.length << 1) + "X", bi); } } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/GMCPContext.java b/BTLib/src/main/java/com/offsetnull/bt/service/GMCPContext.java index 8ab6b4ce..5d9f1f34 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/GMCPContext.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/GMCPContext.java @@ -3,6 +3,7 @@ import com.offsetnull.bt.service.plugin.ConnectionSettingsPlugin; import com.offsetnull.bt.service.plugin.Plugin; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -11,9 +12,9 @@ public interface GMCPContext { Map getPluginMap(); - Processor getProcessor(); + ConnectionSettingsPlugin getConnectionSettings(); - DataPumper getPump(); + mth.core.client.TelnetClientSession getTelnetSession(); - ConnectionSettingsPlugin getConnectionSettings(); + void sendGMCPTriggered(String plugin, String callback, HashMap data); } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/GMCPHandler.java b/BTLib/src/main/java/com/offsetnull/bt/service/GMCPHandler.java index 9ef704e6..23a72700 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/GMCPHandler.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/GMCPHandler.java @@ -1,25 +1,32 @@ package com.offsetnull.bt.service; +import android.util.Log; + import com.offsetnull.bt.responder.TriggerResponder; import com.offsetnull.bt.responder.script.ScriptResponder; import com.offsetnull.bt.service.plugin.Plugin; import com.offsetnull.bt.trigger.TriggerData; -import java.io.UnsupportedEncodingException; -import java.nio.ByteBuffer; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; import java.util.HashMap; public class GMCPHandler { - private static final int GMCP_PAYLOAD_SIZE = 5; - private final GMCPContext context; + private final HashMap> gmcpWatchers = new HashMap<>(); + + private final GMCPData gmcpData = new GMCPData(); + public GMCPHandler(GMCPContext context) { this.context = context; } public void loadTriggers() { + gmcpWatchers.clear(); String gmcpChar = context.getConnectionSettings().getGMCPTriggerChar(); for (int i = 0; i < context.getPlugins().size(); i++) { Plugin p = context.getPlugins().get(i); @@ -34,7 +41,7 @@ public void loadTriggers() { String module = t.getPattern().substring(1, t.getPattern().length()); String name = p.getName(); - context.getProcessor().addWatcher(module, name, callback); + addWatcher(module, name, callback); } } } @@ -43,6 +50,33 @@ public void loadTriggers() { } } + private void addWatcher(String module, String plugin, String callback) { + ArrayList list = gmcpWatchers.get(module); + if (list == null) { + list = new ArrayList<>(); + gmcpWatchers.put(module, list); + } + list.add(new GMCPWatcher(plugin, callback)); + } + + public void dispatchGMCPData(String module, String jsonString) { + try { + JSONObject jo = new JSONObject(jsonString); + gmcpData.absorb(module, jo); + } catch (JSONException e) { + Log.e("GMCP", "GMCP PARSING FOR: " + jsonString); + Log.e("GMCP", "REASON: " + e.getMessage()); + } + + ArrayList list = gmcpWatchers.get(module); + if (list != null) { + for (GMCPWatcher w : list) { + HashMap data = gmcpData.getTable(module); + context.sendGMCPTriggered(w.plugin, w.callback, data); + } + } + } + @SuppressWarnings("unchecked") public void handleCallback(String pluginName, String callback, HashMap data) { Plugin gp = context.getPluginMap().get(pluginName); @@ -52,25 +86,22 @@ public void handleCallback(String pluginName, String callback, HashMap 0) { + session.sendGMCP(gmcpData.substring(0, space), gmcpData.substring(space + 1)); + } } - fub.put(bIAC).put(bSE); - byte[] fubtmp = new byte[size]; - fub.rewind(); - fub.get(fubtmp); - DataPumper pump = context.getPump(); - if (pump != null && pump.isConnected()) { - pump.sendData(fubtmp); + } + + private static class GMCPWatcher { + final String plugin; + final String callback; + + GMCPWatcher(String plugin, String callback) { + this.plugin = plugin; + this.callback = callback; } } } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/OptionNegotiator.java b/BTLib/src/main/java/com/offsetnull/bt/service/OptionNegotiator.java deleted file mode 100644 index 97ea1b01..00000000 --- a/BTLib/src/main/java/com/offsetnull/bt/service/OptionNegotiator.java +++ /dev/null @@ -1,353 +0,0 @@ -/* - * Copyright (C) Dan Block 2013 - */ -package com.offsetnull.bt.service; - -import android.util.Log; - -import java.io.UnsupportedEncodingException; -import java.nio.ByteBuffer; - -/** Helper class to the Processor. This object keeps track of the negotiation responses. */ -public class OptionNegotiator { - /** The default number of columns for NAWS. */ - private static final int DEFAULT_COLS = 80; - - /** The default number of rows for NAWS. */ - private static final int DEFAULT_ROWS = 21; - - /** IAC WILL. */ - private static final byte IAC_WILL = (byte) 0xFB; // 251 - - /** IAC WONT. */ - private static final byte IAC_WONT = (byte) 0xFC; // 252 - - /** IAC DO. */ - private static final byte IAC_DO = (byte) 0xFD; // 253 - - /** IAC DONT. */ - private static final byte IAC_DONT = (byte) 0xFE; // 254 - - /** MCCP 2 compressiong marker. */ - private static final byte COMPRESS2 = (byte) 0x56; // 86 - - /** GMCP marker. */ - private static final byte GMCP = (byte) 201; - - /** Suppress goahead marker. */ - private static final byte SUPPRESS_GOAHEAD = (byte) 0x03; - - /** NAWS marker. */ - private static final byte NAWS_TYPE = (byte) 0x1F; // 31 -- NAWS, negotiate window size - - /** TELNET negotiation size. */ - private static final int NEGOTIATION_SIZE = 3; - - /** Size of the NAWS string. */ - private static final int NAWS_STRING_SIZE = 9; - - /** LSB mask for an int. */ - private static final int LSB_MASK = 0x000000FF; - - /** Second LSB mask for an int. */ - private static final int SLSB_MASK = 0x0000FF00; - - /** Maximum number of tries for TTYPE attempts. */ - private static final int TTYPE_MAX_TRIES = 3; - - /** TTYPE data location. */ - private static final int TTYPE_DATA_LOCATION = 3; - - /** Tracker for the configured number of columns for NAWS. */ - private int mColumns = DEFAULT_COLS; - - /** Tracker for the configured number of rows for NAWS. */ - private int mRows = DEFAULT_ROWS; - - /** Tracker for if naws has been negotiatated. */ - private boolean mIsNAWS = false; - - /** The termtype array of strings that will be iterated through for TTYPE negotiation. */ - private String[] mTermTypes = null; - - /** The termtype negotiation attempt number. */ - private int mTermTypeAttempt = 0; - - /** Selected termtype. */ - private String mTermType = null; - - /** Tracker for if naws data is current and sent to the server. */ - private boolean mDoneNAWS = false; - - /** Tracker for if GMCP should be negotiated. */ - private Boolean mUseGMCP = false; - - /** - * Constructor. - * - * @param ttype The package level configurable termtype option. - */ - public OptionNegotiator(final String ttype) { - mTermType = ttype; - mTermTypes = new String[] {mTermType, "ansi", "BlowTorch-256color", "UNKNOWN"}; - } - - /** - * The top level telnet processing routine. - * - * @param first I believe this will always be IAC - * @param second The action byte, WILL, WONT, DO, DONT - * @param third The actual negotiation type, TTYPE, NAWS, GMCP, MCCP2, etc. - * @return The response to the given telnet negotiation. - */ - public final byte[] processCommand(final byte first, final byte second, final byte third) { - - // byte SB = (byte)0xFA; //250 - subnegotiation start - // byte SE = (byte)0xF0; //240 - subnegotiation start - - // final byte COMPRESS1 = (byte)0x55; //85 - // final byte ATCP_CUSTOM = (byte)0xC8; //200 -- ATCP protocol, - // http://www.ironrealms.com/rapture/manual/files/FeatATCP-txt.html - // final byte AARD_CUSTOM = (byte)0x66; //102 -- Aardwolf custom, - // http://www.aardwolf.com/blog/2008/07/10/telnet-negotiation-control-mud-client-interaction/ - // final byte TERM_TYPE = (byte)0x18; //24 - - byte[] ret = new byte[NEGOTIATION_SIZE]; - - // first byte should always be 255. - if (first != TC.IAC) { - return null; - } - - byte response = 0x00; - - if (second == IAC_WILL) { - switch (third) { - case COMPRESS2: - response = IAC_DO; - break; - case SUPPRESS_GOAHEAD: - response = IAC_DO; - break; - case GMCP: - if (mUseGMCP) { - Log.e("GMCP", "IAC WILL GMP RECIEVED, RESPONDING DO"); - response = IAC_DO; - } else { - response = IAC_DONT; - } - break; - default: - response = IAC_DONT; - } - } - - if (second == IAC_DO) { - switch (third) { - case COMPRESS2: - response = IAC_WONT; - break; - case NAWS_TYPE: - response = IAC_WILL; - mIsNAWS = true; - mDoneNAWS = false; - break; - case TC.TERM: - response = IAC_WILL; - break; - default: - response = IAC_WONT; - } - } - - if (second == IAC_WONT) { - response = IAC_DONT; - } - - if (second == IAC_DONT) { - response = IAC_WONT; - } - - // construct return value - ret[0] = first; - ret[1] = response; - ret[2] = third; - - // byte[] additionalcmd = getCommandSubneg(ret[1],ret[2]); - - /*if(additionalcmd != null) { - //append subnegotiation onto stream. - ByteBuffer buf = ByteBuffer.allocate(ret.length + additionalcmd.length); - buf.put(ret,0,ret.length); - buf.put(additionalcmd,0,additionalcmd.length); - byte[] altret = buf.array(); - return altret; - }*/ - - return ret; - } - - /** - * Processing routine for telnet subnegotiations. - * - * @param sequence The whole telnet subnegotiation sequence. - * @return The response to sequence - */ - public final byte[] getSubnegotiationResponse(final byte[] sequence) { - // first some asserts - if (sequence[0] != TC.IAC - || sequence[1] != TC.SB - || sequence[sequence.length - 2] != TC.IAC - || sequence[sequence.length - 1] != TC.SE) { - // return null, not a valid suboption negotiation starting sequence. - return null; - } - - byte[] responsedata = null; - // Integer sw = new Integer((char)0xFF & sequence[2]); //fetch out the option number - switch (sequence[2]) { - case TC.TERM: - // get terminal response. - // String termtype = "UNKNOWN"; - - String termtype = mTermTypes[mTermTypeAttempt]; - // Log.e("PROCESSOR","Sending terminal type: " + termtype); - try { - responsedata = termtype.getBytes("ISO-8859-1"); - } catch (UnsupportedEncodingException e) { - throw new RuntimeException(e); - } - ByteBuffer buf = ByteBuffer.allocate(sequence.length + responsedata.length); - buf.put(sequence, 0, sequence.length - TTYPE_DATA_LOCATION); - buf.put((byte) 0x00); // fix the response to IS - buf.put(responsedata, 0, responsedata.length); - buf.put(sequence, sequence.length - 2, 2); - - if (mTermTypeAttempt < TTYPE_MAX_TRIES) { - mTermTypeAttempt++; - } // else return UNKNOWN every time - return buf.array(); - - // break; - case COMPRESS2: - // holy shit we have the compressor subnegotiation sequence start - // construct special return value to notify handler to switch to compression - // Log.e("PROCESSOR","COMPRESS2 ENCOUNTERED"); - - byte[] compressstart = new byte[1]; - compressstart[0] = TC.COMPRESS2; - return compressstart; - case GMCP: - return new byte[] {TC.GMCP}; - // break; - default: - } - - return null; - } - - /** - * Method to set the number of columns for NAWS. - * - * @param columns The columns to use for NAWS. - */ - public final void setColumns(final int columns) { - if (columns < 1) { - return; - } - if (this.mColumns != columns) { - mDoneNAWS = false; - } - this.mColumns = columns; - } - - /** - * Getter method for the NAWS Column data. - * - * @return The number of columns. - */ - public final int getColumns() { - return mColumns; - } - - /** - * Method to set the number of rows for NAWS. - * - * @param rows The rows to report for NAWS. - */ - public final void setRows(final int rows) { - if (rows < 1) { - return; - } - if (this.mRows != rows) { - mDoneNAWS = false; - } - this.mRows = rows; - } - - /** - * Getter method for NAWS rows. - * - * @return Number of rows that NAWS is reporting. - */ - public final int getRows() { - return mRows; - } - - /** - * Utility method to get the current NAWS String. Used for debugging telnet data. - * - * @return The NAWS String that will be sent to the server (or already sent, not sure). - */ - public final byte[] getNawsString() { - if (!mIsNAWS) { - return null; - } - if (mDoneNAWS) { - return null; - } - // Log.e("OPT","WHO LET THE NAWS OUT"); - ByteBuffer buf = ByteBuffer.allocate(NAWS_STRING_SIZE); - buf.put(TC.IAC); // IAC - buf.put(TC.SB); // SB - buf.put(TC.NAWS); // NAWS - // buf.put((byte)0x00); //IS - // extract high byte from column - byte highCol = (byte) ((SLSB_MASK & mColumns) >> 2); - byte lowCol = (byte) ((LSB_MASK & mColumns)); - buf.put(highCol); // columns, high byte - buf.put(lowCol); // columns, low byte - - byte highRow = (byte) ((SLSB_MASK & mRows) >> 2); - byte lowRow = (byte) ((LSB_MASK & mRows)); - buf.put(highRow); // lines, high byte - buf.put(lowRow); // lines, low byte - - buf.put(TC.IAC); // IAC - buf.put(TC.SE); // SE - - buf.rewind(); - byte[] suboption = buf.array(); - - mDoneNAWS = true; // only send naws once per valid session. - // send the data back. - return suboption; - } - - /** - * Reset method, this is just to let the processing routines know that the TTYPE stack is reset - * to the beginning. - */ - public final void reset() { - mTermTypeAttempt = 0; - } - - /** - * Setter method for mUseGMCP. - * - * @param useGMCP Weather or not to negotiate GMCP. - */ - public final void setUseGMCP(final Boolean useGMCP) { - mUseGMCP = useGMCP; - } -} diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/Processor.java b/BTLib/src/main/java/com/offsetnull/bt/service/Processor.java deleted file mode 100644 index 32b56980..00000000 --- a/BTLib/src/main/java/com/offsetnull/bt/service/Processor.java +++ /dev/null @@ -1,672 +0,0 @@ -/* - * Copyright (C) Dan Block 2013 - */ -package com.offsetnull.bt.service; - -import android.content.Context; -import android.os.Bundle; -import android.os.Handler; -import android.os.Message; -import android.util.Log; - -import com.offsetnull.bt.settings.ConfigurationLoader; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.UnsupportedEncodingException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.HashMap; - -/** Class implementation for the telnet state machine. */ -public class Processor { - /** Skippable bytes in the state machine for case 1. */ - private static final int SKIP_BYTES = 3; - - /** Telnet SUB payload byte count. */ - private static final int PAYLOAD_BYTES = 5; - - /** Handler object to dispatch results to. */ - private Handler mReportTo = null; - - /** Negotiation sublayer object. */ - private OptionNegotiator mOptionHandler; - - /** Selected encoding to use. */ - private String mEncoding = null; - - /** Application context. */ - private Context mContext = null; - - /** Weather or not to display telnet debugging messages. */ - private boolean mDebugTelnet = false; - - /** Holdover sequence buffer. Used when a telnet negotation spans a transmission boundary. */ - private byte[] mHoldover = null; - - /** GMCP Data holder object. */ - private GMCPData mGMCP = null; - - /** List of GMCP Triggers. */ - private HashMap> mGMCPTriggers = - new HashMap>(); - - /** GMCP Hello string. */ - private String mGMCPHello = "core.hello {\"client\": \"BlowTorch\",\"version\": \"1.4\"}"; - - /** Tracker for weather or not the use GMCP. */ - private Boolean mUseGMCP = false; - - /** GMCP Supports string. */ - private String mGMCPSupports = "core.supports.set [\"char 1\"]"; - - /** - * Constructor. - * - * @param useme reporting handler target. - * @param pEncoding selected encoding to use. - * @param pContext application content. - */ - public Processor(final Handler useme, final String pEncoding, final Context pContext) { - mReportTo = useme; - - mContext = pContext; - String ttype = ConfigurationLoader.getConfigurationValue("terminalTypeString", mContext); - mOptionHandler = new OptionNegotiator(ttype); - mGMCP = new GMCPData(); - setEncoding(pEncoding); - } - - /** - * Getter for mDebugTelnet. - * - * @return mDebugTelnet - */ - public final boolean isDebugTelnet() { - return mDebugTelnet; - } - - /** - * Setter for mDebugTelnet. - * - * @param debugTelnet value for mDebugTelnet - */ - public final void setDebugTelnet(final boolean debugTelnet) { - mDebugTelnet = debugTelnet; - } - - /** - * The main processing routine. - * - * @param data The data to process. - * @return The processed data minus telnet data. - */ - public final byte[] rawProcess(final byte[] data) { - if (data == null) { - return null; - } - - if (data.length == 1) { - if (data[0] == TC.IAC) { - return null; // nothing to do here. - } - } - - ByteBuffer buff = null; - if (mHoldover == null) { - buff = ByteBuffer.allocate(data.length); - } else { - buff = ByteBuffer.allocate(data.length + mHoldover.length); - buff.put(mHoldover); - mHoldover = null; - } - ByteBuffer opbuf = ByteBuffer.allocate(data.length * 2); - - int count = 0; // count of the number of bytes in the buffer; - for (int i = 0; i < data.length; i++) { - switch (data[i]) { - case TC.IAC: - // if the next byte is - if (i > data.length - 1) { - mHoldover = new byte[] {TC.IAC}; - return null; - } - if ((data[i + 1] >= TC.WILL && data[i + 1] <= TC.DONT) - || data[i + 1] == TC.SB) { - // Log.e("SERVICE", "DO IAC"); - // switch(data[i+1]) - if (data[i + 1] == TC.SB) { - // subnegotiation - // now we have an optional number of bytes between the - // indicated subnegotiation and the IAC SE end of - // sequence. - boolean done = false; - int j = i + SKIP_BYTES; - while (!done) { - if (j > data.length - 1) { - done = true; // immediately stop looking for the IAC bytes. - } else { - if (data[j] == TC.IAC) { - if (data[j + 1] == TC.SE) { - done = true; - } - } else { - // opbuf.put(data[j]); - j++; - } - } - } - // so if we are here, than j - (i+3) is the number of - // optional bytes. - opbuf = ByteBuffer.allocate(j - (i + SKIP_BYTES) + PAYLOAD_BYTES); - opbuf.put(TC.IAC); - opbuf.put(data[i + 1]); - opbuf.put(data[i + 2]); - if (j - (i + SKIP_BYTES) > 0) { - for (int q = i + SKIP_BYTES; q < j; q++) { - opbuf.put(data[q]); - } - } - opbuf.put(TC.IAC); - opbuf.put(TC.SE); - - opbuf.rewind(); - boolean compress = dispatchSUB(opbuf.array()); - if (compress) { - ByteBuffer b = ByteBuffer.allocate(data.length - PAYLOAD_BYTES - i); - for (int z = i + PAYLOAD_BYTES; z < data.length; z++) { - b.put(data[z]); - } - - b.rewind(); - mReportTo.sendMessageAtFrontOfQueue( - mReportTo.obtainMessage( - Connection.MESSAGE_STARTCOMPRESS, b.array())); - if (mDebugTelnet) { - String message = - "\n" - + Colorizer.getTeloptStartColor() - + "IN:[IAC SB COMPRESS2 IAC SE] -BEGIN" - + " COMPRESSION-" - + Colorizer.getResetColor() - + "\n"; - mReportTo.sendMessageDelayed( - mReportTo.obtainMessage( - Connection.MESSAGE_PROCESSORWARNING, message), - 1); - } - byte[] trunc = new byte[count]; - buff.rewind(); - buff.get(trunc, 0, count); - return trunc; - - } else { - i = i + 2 + (j - (i + SKIP_BYTES)) + 2; // (original pos, - // plus the 2 - // mandatory bytes, - // plus the optional - // data length, plus - // the 2 bytes at - // the end (one is - // included in the - // loop). - // Thus the extra 2 + 2, and not the PAYLOAD_BYTES constant. - } - } else { - dispatchIAC(data[i + 1], data[i + 2]); - i = i + 2; - } - } else { - - switch (data[i + 1]) { - case TC.IAC: - buff.put(data[i]); // add one IAC and consume the extra. - count++; - case TC.GOAHEAD: - case TC.IP: - // TODO: REAL IP HANDLING HERE, I THINK THIS INVOLVES - // SETTING THE CURSOR BACK TO A PLACE OR SOMETHING - case TC.BREAK: - case TC.AO: - // i think this one is more for us to send to the - // server. - case TC.EC: - // TODO: REAL ERASE CHARACTER - case TC.EL: - // TODO: REAL ERASE LINE - case TC.AYT: - i++; // consume the byte. - break; - default: - // everything else keep - break; - } - } - break; - case TC.BELL: - mReportTo.sendEmptyMessage(Connection.MESSAGE_BELLINC); - break; - case TC.CARRIAGE: - // strip carriage returns - break; - default: - buff.put(data[i]); - count++; - break; - } - } - - buff.rewind(); - byte[] tmp = new byte[count]; - buff.get(tmp, 0, count); - return tmp; - } - - /** - * Telnet negotiation sequence. - * - * @param action The action byte (WILL, WONT, DO, DONT) - * @param option The numeric indicator of the telnet negotiation type (TTYPE, GMCP, ECHO ...) - */ - public final void dispatchIAC(final byte action, final byte option) { - - byte[] resp = mOptionHandler.processCommand(TC.IAC, action, option); - Message sb = mReportTo.obtainMessage(Connection.MESSAGE_SENDOPTIONDATA, resp); - if (resp.length > 2) { - if (resp[2] == TC.NAWS) { - // naws has started. - disaptchNawsString(); - } - } - Bundle b = sb.getData(); - b.putByteArray("THE_DATA", resp); - String message = null; - if (mDebugTelnet) { - message = - Colorizer.getTeloptStartColor() - + "IN:[" - + TC.decodeIAC(new byte[] {TC.IAC, action, option}) - + "]" - + " "; - message += - Colorizer.getTeloptStartColor() - + "OUT:[" - + TC.decodeIAC(resp) - + "]" - + Colorizer.getResetColor() - + "\n"; - } - b.putString("DEBUG_MESSAGE", message); - sb.setData(b); - mReportTo.sendMessage(sb); - - if (action == TC.WILL && option == TC.GMCP) { - // so we are responding accordingly, but we want to "initialize" the gmcp - if (mUseGMCP) { - initGMCP(); - } - } - } - - /** - * Telnet subnegotiation handler. - * - * @param negotiation the subnegotiation sequence. - * @return I think the return value here means start compression. But that would be bad. - */ - public final boolean dispatchSUB(final byte[] negotiation) { - byte[] sub = mOptionHandler.getSubnegotiationResponse(negotiation); - - if (sub == null) { - return false; - } - - // special handling for the compression marker. - byte[] compressresp = new byte[1]; - compressresp[0] = TC.COMPRESS2; - - if (sub[0] == compressresp[0]) { - return true; - } else if (sub[0] == TC.GMCP) { - if (mDebugTelnet) { - String message = - "\n" - + Colorizer.getTeloptStartColor() - + "IN:[" - + TC.decodeSUB(negotiation) - + "]" - + Colorizer.getResetColor() - + "\n"; - mReportTo.sendMessageDelayed( - mReportTo.obtainMessage(Connection.MESSAGE_PROCESSORWARNING, message), 1); - } - byte[] foo = new byte[negotiation.length - PAYLOAD_BYTES]; - ByteBuffer wrap = ByteBuffer.wrap(negotiation); - wrap.rewind(); - wrap.position(SKIP_BYTES); - wrap.get(foo, 0, negotiation.length - PAYLOAD_BYTES); - try { - String whole = new String(foo, "UTF-8"); - int split = whole.indexOf(" "); - String module = whole.substring(0, split); - String data = whole.substring(split + 1, whole.length()); - try { - JSONObject jo = new JSONObject(data); - mGMCP.absorb(module, jo); - } catch (JSONException e) { - Log.e("GMCP", "GMCP PARSING FOR: " + data); - Log.e("GMCP", "REASON: " + e.getMessage()); - e.printStackTrace(); - } - - // TODO: THIS IS WHERE THE ACTUAL WORK IS DONE TO SEND MUD DATA. - ArrayList list = mGMCPTriggers.get(module); - if (list != null) { - for (int i = 0; i < list.size(); i++) { - GMCPWatcher tmp = list.get(i); - HashMap tmpdata = mGMCP.getTable(module); - Message gmsg = - mReportTo.obtainMessage(Connection.MESSAGE_GMCPTRIGGERED, tmpdata); - gmsg.getData().putString("TARGET", tmp.mPlugin); - gmsg.getData().putString("CALLBACK", tmp.mCallback); - mReportTo.sendMessage(gmsg); - } - } - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - - return false; - } else { - String message = null; - if (mDebugTelnet) { - message = - Colorizer.getTeloptStartColor() - + "IN:[" - + TC.decodeSUB(negotiation) - + "]" - + " "; - message += - Colorizer.getTeloptStartColor() - + "OUT:[" - + TC.decodeSUB(sub) - + "]" - + Colorizer.getResetColor() - + "\n"; - } - Message sbm = mReportTo.obtainMessage(Connection.MESSAGE_SENDOPTIONDATA); - Bundle b = sbm.getData(); - b.putByteArray("THE_DATA", sub); - b.putString("DEBUG_MESSAGE", message); - sbm.setData(b); - mReportTo.sendMessage(sbm); - return false; - } - } - - /** - * Setter for mEncoding. - * - * @param encoding Selected encoding. - */ - public final void setEncoding(final String encoding) { - this.mEncoding = encoding; - } - - /** - * Getter for mEncoding. - * - * @return The currently selected encoding. - */ - public final String getEncoding() { - return mEncoding; - } - - /** - * Helper method for NAWS. - * - * @param rows Rows in display. - * @param cols Columns in display. - */ - public final void setDisplayDimensions(final int rows, final int cols) { - mOptionHandler.setColumns(cols); - mOptionHandler.setRows(rows); - } - - /** Helper method for naws. This may happen because the foreground window changed shape. */ - public final void disaptchNawsString() { - byte[] nawsout = mOptionHandler.getNawsString(); - if (nawsout == null) { - return; - } - Message sbm = mReportTo.obtainMessage(Connection.MESSAGE_SENDOPTIONDATA); - Bundle b = sbm.getData(); - b.putByteArray("THE_DATA", nawsout); - - String message = null; - if (mDebugTelnet) { - message = - Colorizer.getTeloptStartColor() - + "OUT:[" - + TC.decodeSUB(nawsout) - + "]" - + Colorizer.getResetColor() - + "\n"; - } - b.putString("DEBUG_MESSAGE", message); - sbm.setData(b); - mReportTo.sendMessageDelayed(sbm, 2); - return; - } - - /** Reset method, this is called when the settings have been foreably reloaded. */ - public final void reset() { - mOptionHandler.reset(); - } - - /** - * Helper method to get a GMCP module quickly. - * - * @param str The module to get? - * @return The table of data? - */ - public final Object getGMCPValue(final String str) { - return mGMCP.get(str); - } - - /** - * Helper method to get a GMCP table for a given path. - * - * @param path The module path, e.g. char.vitals.hp - * @return The mapping of objects representing the gmcp table at the desired path. - */ - public final HashMap getGMCPTable(final String path) { - return mGMCP.getTable(path); - } - - /** Utility method to initialize GMCP. */ - public final void initGMCP() { - - try { - byte[] hellob = getGMCPResponse(mGMCPHello); - byte[] supportb = getGMCPResponse(mGMCPSupports); - - String hello = - Colorizer.getTeloptStartColor() - + "OUT:[" - + TC.decodeSUB(hellob) - + "]" - + Colorizer.getResetColor() - + "\n"; - String supports = - Colorizer.getTeloptStartColor() - + "OUT:[" - + TC.decodeSUB(supportb) - + "]" - + Colorizer.getResetColor() - + "\n"; - - Message hm = mReportTo.obtainMessage(Connection.MESSAGE_SENDOPTIONDATA); - Bundle bh = hm.getData(); - bh.putByteArray("THE_DATA", hellob); - if (mDebugTelnet) { - bh.putString("DEBUG_MESSAGE", hello); - } - hm.setData(bh); - mReportTo.sendMessage(hm); - - Message sm = mReportTo.obtainMessage(Connection.MESSAGE_SENDOPTIONDATA); - Bundle bs = sm.getData(); - bs.putByteArray("THE_DATA", supportb); - if (mDebugTelnet) { - bs.putString("DEBUG_MESSAGE", supports); - } - sm.setData(bs); - mReportTo.sendMessage(sm); - - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - } - - /** - * Helper method to respond to the GMCP negotiation sequence. - * - * @param str The subnegotiation string. - * @return The response. - * @throws UnsupportedEncodingException Thrown if the selected encoding isn't supported. - */ - public final byte[] getGMCPResponse(final String str) throws UnsupportedEncodingException { - // check for IAC in the string. - int iaccount = 0; - byte[] tmp = str.getBytes("ISO-8859-1"); - for (int i = 0; i < tmp.length; i++) { - if (tmp[i] == TC.IAC) { - iaccount++; - } - } - - byte[] resp = new byte[str.getBytes("ISO-8859-1").length + PAYLOAD_BYTES + iaccount]; - resp[0] = TC.IAC; - resp[1] = TC.SB; - resp[2] = TC.GMCP; - resp[resp.length - 1] = TC.SE; - resp[resp.length - 2] = TC.IAC; - int j = SKIP_BYTES; - for (int i = 0; i < tmp.length; i++) { - resp[j] = tmp[i]; - if (tmp[i] == TC.IAC) { - resp[j + 1] = TC.IAC; - j++; - } - j++; - } - - return resp; - } - - /** Utility method to dump the current gmcp data to the log. */ - public final void dumpGMCP() { - mGMCP.dumpCache(); - } - - /** - * Utility class representing a plugin wanting to execute a callback when a gmcp module changes. - */ - public class GMCPWatcher { - /** The plugin name. */ - private String mPlugin; - - /** The callback to execute. */ - private String mCallback; - - /** - * Constructor. - * - * @param plugin The plugin name. - * @param callback The callback name. - */ - public GMCPWatcher(final String plugin, final String callback) { - this.mPlugin = plugin; - this.mCallback = callback; - } - - /** - * Getter for mPlugin. - * - * @return the value of mPlugin - */ - public final String getPlugin() { - return mPlugin; - } - - /** - * Getter for mCallback. - * - * @return value of mCallback - */ - public final String getCallback() { - return mCallback; - } - } - - /** - * Adds a new gmcp watcher for a given module path. - * - * @param module Module path, e.g. char.vitals.hp. - * @param plugin The target plugin that is watching. - * @param callback The callback function to execute when module has changed. - */ - public final void addWatcher(final String module, final String plugin, final String callback) { - GMCPWatcher tmp = new GMCPWatcher(plugin, callback); - - ArrayList list = mGMCPTriggers.get(module); - if (list == null) { - ArrayList foo = new ArrayList(); - foo.add(tmp); - mGMCPTriggers.put(module, foo); - } else { - list.add(tmp); - } - } - - /** - * Setter method for mUseGMCP. - * - * @param value the new value for mUseGMCP. - */ - public final void setUseGMCP(final Boolean value) { - mUseGMCP = value; - mOptionHandler.setUseGMCP(mUseGMCP); - } - - /** - * Setter method for mGMCPSupports. - * - * @param value The new value for mGMCPSupports. - */ - public final void setGMCPSupports(final String value) { - mGMCPSupports = "core.supports.set [" + value + "]"; - } -} - -/* - * Straight from rfc 854 NAME CODE MEANING - * - * SE 240 End of subnegotiation parameters. NOP 241 No operation. Data Mark 242 - * The data stream portion of a Synch. This should always be accompanied by a - * TCP Urgent notification. Break 243 NVT character BRK. Interrupt Process 244 - * The function IP. Abort output 245 The function AO. Are You There 246 The - * function AYT. Erase character 247 The function EC. Erase Line 248 The - * function EL. Go ahead 249 The GA signal. SB 250 Indicates that what follows - * is subnegotiation of the indicated option. WILL (option code) 251 Indicates - * the desire to begin performing, or confirmation that you are now performing, - * the indicated option. WON'T (option code) 252 Indicates the refusal to - * perform, or continue performing, the indicated option. DO (option code) 253 - * Indicates the request that the other party perform, or confirmation that you - * are expecting the other party to perform, the indicated option. DON'T (option - * code) 254 Indicates the demand that the other party stop performing, or - * confirmation that you are no longer expecting the other party to perform, the - * indicated option. IAC 255 Data Byte 255. - */ diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/StellarService.java b/BTLib/src/main/java/com/offsetnull/bt/service/StellarService.java index a8eadc55..efff76d4 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/StellarService.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/StellarService.java @@ -22,7 +22,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.IBinder; -import android.os.Message; import android.os.Vibrator; import android.util.Log; @@ -57,27 +56,6 @@ */ public class StellarService extends Service { - /** Message constant indicating system startup. */ - protected static final int MESSAGE_STARTUP = 0; - - /** Message constant indicating a new connection launch. */ - protected static final int MESSAGE_NEWCONENCTION = 1; - - /** - * Message constant indicating that the active connection should switch to a different - * connection. - */ - protected static final int MESSAGE_SWITCH = 2; - - /** Message constant indicating that the active connection should reload its settings. */ - protected static final int MESSAGE_RELOADSETTINGS = 3; - - /** - * Not really sure what this is for but I think it had something to do with debugging an ANR in - * the service. - */ - protected static final int MESSAGE_STOPANR = 4; - /** Duration of a short interval of time. */ private static final int SHORT_DURATION = 300; @@ -93,11 +71,8 @@ public class StellarService extends Service { /** Tracker for if the foreground window is showing or hidden. */ private boolean mWindowShowing = true; - /** - * The handler object used to coordinate multi-threaded efforts from the aidl callback onto the - * main thread. - */ - private Handler mHandler = null; + /** Coroutine-based event loop that replaces the old Handler/ServiceHandler. */ + private ServiceEventLoop mEventLoop = null; /** The WifiLock object. */ private WifiManager.WifiLock mWifiLock = null; @@ -132,6 +107,9 @@ public class StellarService extends Service { /** The currently "Selected" connection. */ private String mConnectionClutch = ""; + /** Dispatcher that handles service-level commands. */ + private ServiceDispatcher mDispatcher = null; + /** The callback list of MainWindow activities that have bound to the Service. */ private final List mCallbacks = new ArrayList(); @@ -220,53 +198,32 @@ public final void onCreate() { e.printStackTrace(); } } - mHandler = new Handler(new ServiceHandler()); - } + mDispatcher = new ServiceDispatcher( + (display, host, port) -> { + Connection c = new Connection(display, host, port, StellarService.this); + mConnections.put(display, c); + return c; + } + ); + mEventLoop = new ServiceEventLoop(mDispatcher, new ServiceEventLoop.SideEffects() { + @Override + public void onNewConnection(String display) { + mConnectionClutch = display; + } - /** - * There are a few things that are needed to be handled on the main thread and the aidl bridge - * makes that difficult, so this is used to aggregate code onto the main thread rather than the - * dispatch threads. - */ - private class ServiceHandler implements Handler.Callback { - @Override - public boolean handleMessage(final Message msg) { - switch (msg.what) { - case MESSAGE_RELOADSETTINGS: - mConnections.get(mConnectionClutch).reloadSettings(); - reloadWindows(); - break; - case MESSAGE_STARTUP: - if (mConnections.get(mConnectionClutch).getPump() == null) { - mConnections - .get(mConnectionClutch) - .getHandler() - .sendEmptyMessage(Connection.MESSAGE_STARTUP); - } - break; - case MESSAGE_NEWCONENCTION: - Bundle b = msg.getData(); - String display = b.getString("DISPLAY"); - String host = b.getString("HOST"); - int port = b.getInt("PORT"); - - Connection c = mConnections.get(display); - if (c == null) { - // make new conneciton. - mConnectionClutch = display; - c = new Connection(display, host, port, StellarService.this); - mConnections.put(mConnectionClutch, c); - c.initWindows(); - } - break; - case MESSAGE_SWITCH: - switchTo((String) msg.obj); - break; - default: - break; + @Override + public void onReloadWindows() { + mConnectionClutch = mDispatcher.getActiveConnection(); + reloadWindows(); } - return true; - } + + @Override + public void onSwitchTo(String display) { + mConnectionClutch = mDispatcher.getActiveConnection(); + switchTo(display); + } + }); + mEventLoop.start(); } /** Implementation of the Service.onDestroy() method. */ @@ -828,7 +785,7 @@ public void unregisterLauncherCallback(final LauncherCallback c) { } public void initXfer() { - mHandler.sendEmptyMessage(MESSAGE_STARTUP); + mEventLoop.send(ServiceCommand.Startup.INSTANCE); } public void endXfer() { @@ -864,13 +821,7 @@ public void saveSettings() { } public void setConnectionData(final String host, final int port, final String display) { - Message msg = mHandler.obtainMessage(MESSAGE_NEWCONENCTION); - Bundle b = msg.getData(); - b.putString("DISPLAY", display); - b.putString("HOST", host); - b.putInt("PORT", port); - msg.setData(b); - mHandler.sendMessage(msg); + mEventLoop.send(new ServiceCommand.NewConnection(display, host, port)); } @SuppressWarnings("rawtypes") @@ -946,7 +897,9 @@ public void setDisplayDimensions(final int rows, final int cols) { if (c == null) { return; } - c.getProcessor().setDisplayDimensions(rows, cols); + if (c.getTelnetSession() != null) { + c.getTelnetSession().sendWindowSize(cols, rows); + } } public void reconnect(final String str) { @@ -1059,7 +1012,7 @@ public boolean isButtonSetLockedEditButtons(final String key) { public void startNewConnection(final String host, final int port, final String display) {} public void switchToConnection(final String display) { - mHandler.sendMessage(mHandler.obtainMessage(MESSAGE_SWITCH, display)); + mEventLoop.send(new ServiceCommand.SwitchConnection(display)); } public boolean isConnectedTo(final String display) { @@ -1102,7 +1055,7 @@ public String getScript(final String plugin, final String name) { } public void reloadSettings() { - mHandler.sendEmptyMessage(MESSAGE_RELOADSETTINGS); + mEventLoop.send(ServiceCommand.ReloadSettings.INSTANCE); } public void pluginXcallS(final String plugin, final String function, final String str) { diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/TimerManager.java b/BTLib/src/main/java/com/offsetnull/bt/service/TimerManager.java index 0dacc242..16e9cfa5 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/TimerManager.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/TimerManager.java @@ -166,54 +166,28 @@ public Object execute(final Object o, final Connection c) { } if (action.equals("info")) { - c.getHandler() - .sendMessage( - c.getHandler() - .obtainMessage(Connection.MESSAGE_TIMERINFO, ordinal)); + c.sendCommand(new ConnectionCommand.TimerAction( + ordinal, 0, ConnectionCommand.TimerActionType.INFO)); return null; } if (action.equals("reset")) { - c.getHandler() - .sendMessage( - c.getHandler() - .obtainMessage( - Connection.MESSAGE_TIMERRESET, - 0, - domsg, - ordinal)); + c.sendCommand(new ConnectionCommand.TimerAction( + ordinal, domsg, ConnectionCommand.TimerActionType.RESET)); return null; } if (action.equals("play")) { - c.getHandler() - .sendMessage( - c.getHandler() - .obtainMessage( - Connection.MESSAGE_TIMERSTART, - 0, - domsg, - ordinal)); + c.sendCommand(new ConnectionCommand.TimerAction( + ordinal, domsg, ConnectionCommand.TimerActionType.START)); return null; } if (action.equals("pause")) { - c.getHandler() - .sendMessage( - c.getHandler() - .obtainMessage( - Connection.MESSAGE_TIMERPAUSE, - 0, - domsg, - ordinal)); + c.sendCommand(new ConnectionCommand.TimerAction( + ordinal, domsg, ConnectionCommand.TimerActionType.PAUSE)); return null; } if (action.equals("stop")) { - c.getHandler() - .sendMessage( - c.getHandler() - .obtainMessage( - Connection.MESSAGE_TIMERSTOP, - 0, - domsg, - ordinal)); + c.sendCommand(new ConnectionCommand.TimerAction( + ordinal, domsg, ConnectionCommand.TimerActionType.STOP)); return null; } } else { diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/TriggerContext.java b/BTLib/src/main/java/com/offsetnull/bt/service/TriggerContext.java index ea22f12a..0ec8a218 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/TriggerContext.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/TriggerContext.java @@ -19,7 +19,7 @@ public interface TriggerContext { String getEncoding(); - Processor getProcessor(); + mth.core.client.TelnetClientSession getTelnetSession(); StellarService getService(); @@ -31,5 +31,10 @@ public interface TriggerContext { int getPort(); + /** @deprecated Use {@link #sendCommand(ConnectionCommand)} instead. */ + @Deprecated android.os.Handler getHandler(); + + /** Sends a command through the connection event loop. */ + void sendCommand(ConnectionCommand command); } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/TriggerManager.java b/BTLib/src/main/java/com/offsetnull/bt/service/TriggerManager.java index 869820dc..3d0d8836 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/TriggerManager.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/TriggerManager.java @@ -180,9 +180,10 @@ public final void buildTriggerSystem() { * encoding provided. */ public void dispatch(final byte[] data) throws UnsupportedEncodingException { - - byte[] raw = context.getProcessor().rawProcess(data); - if (raw == null) { + byte[] raw = context.getTelnetSession() != null + ? context.getTelnetSession().processInput(data) + : data; + if (raw == null || raw.length == 0) { return; } @@ -396,7 +397,6 @@ public void dispatch(final byte[] data) throws UnsupportedEncodingException { byte[] proc = mFinished.dumpToBytes(false); - buffer.addBytesImplSimple(proc); context.sendBytesToWindow(proc); } diff --git a/BTLib/src/main/java/com/offsetnull/bt/service/function/BellCommand.java b/BTLib/src/main/java/com/offsetnull/bt/service/function/BellCommand.java index 2eb21c1d..607f21f6 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/service/function/BellCommand.java +++ b/BTLib/src/main/java/com/offsetnull/bt/service/function/BellCommand.java @@ -1,6 +1,7 @@ package com.offsetnull.bt.service.function; import com.offsetnull.bt.service.Connection; +import com.offsetnull.bt.service.ConnectionCommand; public class BellCommand extends SpecialCommand { public BellCommand() { @@ -9,7 +10,7 @@ public BellCommand() { public Object execute(Object o, Connection c) { - c.getHandler().sendEmptyMessage(Connection.MESSAGE_BELLINC); + c.sendCommand(new ConnectionCommand.BellReceived()); return null; } diff --git a/BTLib/src/main/java/com/offsetnull/bt/window/MainWindow.java b/BTLib/src/main/java/com/offsetnull/bt/window/MainWindow.java index 0f448826..29f0ae3f 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/window/MainWindow.java +++ b/BTLib/src/main/java/com/offsetnull/bt/window/MainWindow.java @@ -201,7 +201,6 @@ public class MainWindow extends AppCompatActivity // boolean servicestarted = false; StellarService service = null; - Processor the_processor = null; private int statusBarHeight = 1; // GestureDetector gestureDetector = null; OnTouchListener gestureListener = null; @@ -484,6 +483,7 @@ public boolean onKey(View v, int keyCode, KeyEvent event) { mInputBox.setDrawingCacheEnabled(true); mInputBox.setVisibility(View.VISIBLE); mInputBox.setEnabled(true); + mInputBox.requestFocus(); mInputBox.setOnBackPressedListener( new BetterEditText.BackPressedListener() { diff --git a/BTLib/src/main/java/com/offsetnull/bt/window/TextTree.java b/BTLib/src/main/java/com/offsetnull/bt/window/TextTree.java index 6392e1fe..e50db0ad 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/window/TextTree.java +++ b/BTLib/src/main/java/com/offsetnull/bt/window/TextTree.java @@ -75,9 +75,8 @@ public TextTree() { public void addString(String str) { try { - this.addBytesImplSimple(str.getBytes(encoding)); + this.addBytesImpl(str.getBytes(encoding)); } catch (UnsupportedEncodingException e) { - // TODO Auto-generated catch block e.printStackTrace(); } } @@ -273,56 +272,6 @@ private static int getAsciiNumber(byte b) { private final byte u = (byte) 0x75; private final byte n = (byte) 0x6E; - public void addBytesImplSimple(byte[] data) { - // int startcount = this.getBrokenLineCount(); - ByteBuffer sb = ByteBuffer.allocate(data.length); - for (int i = 0; i < data.length; i++) { - if (data[i] == NEWLINE) { - int size = sb.position(); - byte[] buf = new byte[size]; - sb.rewind(); - sb.get(buf, 0, size); - - sb.clear(); - - try { - Text u = new Text(buf); - Line l = new Line(); - l.getData().addLast(u); - l.getData().addLast(new NewLine()); - addLine(l); - } catch (UnsupportedEncodingException e) { - - e.printStackTrace(); - } - - } else { - sb.put(data[i]); - } - } - - if (sb.position() > 0) { - int size = sb.position(); - byte[] buf = new byte[size]; - sb.rewind(); - sb.get(buf, 0, size); - - sb.clear(); - - try { - Text u = new Text(buf); - Line l = new Line(); - l.getData().addLast(u); - addLine(l); - } catch (UnsupportedEncodingException e) { - - e.printStackTrace(); - } - } - - this.prune(); - } - static enum RUN { WHITESPACE, TEXT, @@ -338,7 +287,6 @@ static enum RUN { public int addBytesImpl(byte[] data) throws UnsupportedEncodingException { // if(simpleMode) { - // addBytesImplSimple(data); // } // this actually shouldn't be too hard to do with just a for loop. // STATE init = STATE.TEXT; diff --git a/BTLib/src/main/java/com/offsetnull/bt/window/Window.java b/BTLib/src/main/java/com/offsetnull/bt/window/Window.java index 72c506c4..f2b33118 100644 --- a/BTLib/src/main/java/com/offsetnull/bt/window/Window.java +++ b/BTLib/src/main/java/com/offsetnull/bt/window/Window.java @@ -2062,7 +2062,11 @@ private void addBytesImpl(byte[] obj, boolean jumpToEnd) { if (mBufferText) { // synchronized(synch) { - mHoldBuffer.addBytesImplSimple(obj); + try { + mHoldBuffer.addBytesImpl(obj); + } catch (java.io.UnsupportedEncodingException e) { + e.printStackTrace(); + } // } return; } diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionCommand.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionCommand.kt new file mode 100644 index 00000000..ac972cbd --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionCommand.kt @@ -0,0 +1,88 @@ +package com.offsetnull.bt.service + +/** + * Sealed hierarchy representing all messages dispatched through Connection's handler. + * Maps 1:1 to the MESSAGE_* constants in Connection.java. + */ +sealed interface ConnectionCommand { + // Lifecycle + data object Startup : ConnectionCommand + data object Reconnect : ConnectionCommand + data object Connected : ConnectionCommand + data object Disconnected : ConnectionCommand + data class TerminatedByPeer(val unit: Unit = Unit) : ConnectionCommand + + // Network I/O + data class Process(val data: ByteArray) : ConnectionCommand + data class SendDataString(val text: String) : ConnectionCommand + data class SendDataBytes(val data: ByteArray) : ConnectionCommand + data class SendGmcpData(val data: String) : ConnectionCommand + data class StartCompress(val trailingData: ByteArray?) : ConnectionCommand + data class SendOptionData(val data: ByteArray, val debugMessage: String?) : ConnectionCommand + + // Display + data class ProcessorWarning(val text: String) : ConnectionCommand + data class BellReceived(val unit: Unit = Unit) : ConnectionCommand + data class DialogError(val message: String) : ConnectionCommand + data object MccpFatalError : ConnectionCommand + data class LuaNote(val text: String) : ConnectionCommand + data class LuaError(val message: String) : ConnectionCommand + data class TriggerLuaError(val message: String) : ConnectionCommand + + // Window management + data class LineToWindow(val target: String, val line: Any) : ConnectionCommand + data class DrawWindow(val name: String) : ConnectionCommand + data class NewWindow(val token: Any) : ConnectionCommand + data class WindowBuffer(val name: String, val enabled: Boolean) : ConnectionCommand + data class WindowXCallS( + val token: String, + val function: String, + val data: Any, + ) : ConnectionCommand + data class WindowXCallB( + val token: String, + val function: String, + val data: ByteArray, + ) : ConnectionCommand + data class InvalidateWindowText(val name: String) : ConnectionCommand + + // Plugin management + data class AddFunctionCallback( + val id: String, + val command: String, + val callback: String, + ) : ConnectionCommand + data class GmcpTriggered( + val plugin: String, + val callback: String, + val data: Any, + ) : ConnectionCommand + data class CallPlugin( + val plugin: String, + val function: String, + val data: String, + ) : ConnectionCommand + data class AddLink(val path: String) : ConnectionCommand + data class DeletePlugin(val name: String) : ConnectionCommand + + // Echo negotiation + data object DisableLocalEcho : ConnectionCommand + data object EnableLocalEcho : ConnectionCommand + + // Settings + data class SaveDirtyPlugin(val name: String) : ConnectionCommand + data class ExportFile(val path: String) : ConnectionCommand + data class ImportFile(val path: String) : ConnectionCommand + data object ReloadSettings : ConnectionCommand + data object ResetSettings : ConnectionCommand + data object SetTriggersDirty : ConnectionCommand + + // Timers + data class TimerAction( + val name: String, + val id: Int, + val action: TimerActionType, + ) : ConnectionCommand + + enum class TimerActionType { START, STOP, PAUSE, RESET, INFO } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionDispatcher.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionDispatcher.kt new file mode 100644 index 00000000..fcba1806 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionDispatcher.kt @@ -0,0 +1,191 @@ +package com.offsetnull.bt.service + +import java.io.UnsupportedEncodingException + +/** + * Dispatches [ConnectionCommand]s to the appropriate handlers. + * Extracted from Connection.ConnectionHandler.handleMessage() for testability. + */ +class ConnectionDispatcher( + private val triggerManager: TriggerManagerHandle? = null, + private val pump: PumpHandle? = null, + private val serviceCallbacks: BellCallbacks? = null, + private val display: DisplayHandle? = null, + private val lifecycle: LifecycleHandle? = null, + private val windowManager: WindowManagerHandle? = null, + private val pluginManager: PluginManagerHandle? = null, + private val timerManager: TimerManagerHandle? = null, + private val gmcpHandler: GmcpHandle? = null, + private val aliasManager: AliasManagerHandle? = null, + private val encoding: String = "UTF-8", +) { + @Suppress("LongMethod", "CyclomaticComplexMethod") + fun dispatch(command: ConnectionCommand) { + when (command) { + // Lifecycle + is ConnectionCommand.Startup -> lifecycle?.doStartup() + is ConnectionCommand.Reconnect -> lifecycle?.doReconnect() + is ConnectionCommand.Connected -> lifecycle?.resetAutoReconnect() + is ConnectionCommand.Disconnected -> { + lifecycle?.killNetThreads() + lifecycle?.doDisconnect(byPeer = false) + } + is ConnectionCommand.TerminatedByPeer -> { + lifecycle?.killNetThreads() + lifecycle?.doDisconnect(byPeer = true) + } + + // Network I/O + is ConnectionCommand.Process -> triggerManager?.dispatch(command.data) + is ConnectionCommand.SendDataString -> { + try { + val bytes = command.text.toByteArray(charset(encoding)) + pump?.send(bytes) + } catch (_: UnsupportedEncodingException) {} + } + is ConnectionCommand.SendDataBytes -> pump?.send(command.data) + is ConnectionCommand.SendGmcpData -> gmcpHandler?.sendData(command.data) + is ConnectionCommand.StartCompress -> pump?.startCompression(command.trailingData) + is ConnectionCommand.SendOptionData -> { + if (command.debugMessage != null) { + display?.sendDataToWindow(command.debugMessage) + } + pump?.send(command.data) + } + + // Display + is ConnectionCommand.ProcessorWarning -> display?.sendDataToWindow(command.text) + is ConnectionCommand.BellReceived -> { + if (serviceCallbacks?.vibrateOnBell == true) serviceCallbacks.doVibrateBell() + if (serviceCallbacks?.notifyOnBell == true) serviceCallbacks.doNotifyBell() + if (serviceCallbacks?.displayOnBell == true) serviceCallbacks.doDisplayBell() + } + is ConnectionCommand.DialogError -> display?.dispatchDialog(command.message) + is ConnectionCommand.MccpFatalError -> { + // Handled at DataPumper level now + } + is ConnectionCommand.LuaNote -> { + try { + display?.dispatchNoProcess(command.text.toByteArray(charset(encoding))) + } catch (_: UnsupportedEncodingException) {} + } + is ConnectionCommand.LuaError -> display?.dispatchNoProcess( + command.message.toByteArray(charset(encoding)) + ) + is ConnectionCommand.TriggerLuaError -> display?.dispatchNoProcess( + command.message.toByteArray(charset(encoding)) + ) + + // Window management + is ConnectionCommand.LineToWindow -> + windowManager?.lineToWindow(command.target, command.line) + is ConnectionCommand.DrawWindow -> windowManager?.redrawWindow(command.name) + is ConnectionCommand.NewWindow -> windowManager?.addWindow(command.token) + is ConnectionCommand.WindowBuffer -> + windowManager?.setWindowBuffer(command.name, command.enabled) + is ConnectionCommand.WindowXCallS -> + windowManager?.windowXCallS(command.token, command.function, command.data) + is ConnectionCommand.WindowXCallB -> + windowManager?.windowXCallB(command.token, command.function, command.data) + is ConnectionCommand.InvalidateWindowText -> + windowManager?.invalidateWindowText(command.name) + + // Plugin management + is ConnectionCommand.AddFunctionCallback -> + aliasManager?.addFunctionCallback(command.id, command.command, command.callback) + is ConnectionCommand.GmcpTriggered -> + gmcpHandler?.handleCallback(command.plugin, command.callback, command.data) + is ConnectionCommand.CallPlugin -> + pluginManager?.callPlugin(command.plugin, command.function, command.data) + is ConnectionCommand.AddLink -> pluginManager?.addLink(command.path) + is ConnectionCommand.DeletePlugin -> pluginManager?.deletePlugin(command.name) + + // Settings + is ConnectionCommand.SaveDirtyPlugin -> + pluginManager?.saveDirtyPlugin(command.name) + is ConnectionCommand.ExportFile -> pluginManager?.exportSettings(command.path) + is ConnectionCommand.ImportFile -> pluginManager?.importSettings(command.path) + is ConnectionCommand.ReloadSettings -> lifecycle?.reloadSettings() + is ConnectionCommand.ResetSettings -> pluginManager?.resetSettings() + is ConnectionCommand.SetTriggersDirty -> triggerManager?.setDirty() + + // Echo negotiation + is ConnectionCommand.DisableLocalEcho -> lifecycle?.setLocalEcho(false) + is ConnectionCommand.EnableLocalEcho -> lifecycle?.setLocalEcho(true) + + // Timers + is ConnectionCommand.TimerAction -> + timerManager?.handleAction(command.name, command.id, command.action) + } + } +} + +// ── Handle interfaces for testability ────────────────────────────────── + +interface TriggerManagerHandle { + fun dispatch(data: ByteArray) + fun setDirty() {} +} + +interface PumpHandle { + fun send(data: ByteArray) + fun startCompression(trailingData: ByteArray?) +} + +interface BellCallbacks { + val vibrateOnBell: Boolean + val notifyOnBell: Boolean + val displayOnBell: Boolean + fun doVibrateBell() + fun doNotifyBell() + fun doDisplayBell() +} + +interface DisplayHandle { + fun sendDataToWindow(text: String) + fun dispatchNoProcess(data: ByteArray) + fun dispatchDialog(message: String) +} + +interface LifecycleHandle { + fun killNetThreads() + fun doDisconnect(byPeer: Boolean) + fun doReconnect() + fun doStartup() + fun resetAutoReconnect() + fun reloadSettings() + fun setLocalEcho(enabled: Boolean) {} +} + +interface WindowManagerHandle { + fun lineToWindow(target: String, line: Any) + fun redrawWindow(name: String) + fun addWindow(token: Any) + fun setWindowBuffer(name: String, enabled: Boolean) + fun windowXCallS(token: String, function: String, data: Any) + fun windowXCallB(token: String, function: String, data: ByteArray) + fun invalidateWindowText(name: String) +} + +interface PluginManagerHandle { + fun callPlugin(plugin: String, function: String, data: String) + fun addLink(path: String) + fun deletePlugin(name: String) + fun saveDirtyPlugin(name: String) + fun exportSettings(path: String) + fun importSettings(path: String) + fun resetSettings() +} + +interface TimerManagerHandle { + fun handleAction(name: String, id: Int, action: ConnectionCommand.TimerActionType) +} + +interface GmcpHandle { + fun sendData(data: String) + fun handleCallback(plugin: String, callback: String, data: Any) +} + +interface AliasManagerHandle { + fun addFunctionCallback(id: String, command: String, callback: String) +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionEventLoop.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionEventLoop.kt new file mode 100644 index 00000000..5c195ac9 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionEventLoop.kt @@ -0,0 +1,53 @@ +package com.offsetnull.bt.service + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Replaces Connection's Handler/ConnectionHandler with a coroutine Channel. + * Dispatches [ConnectionCommand]s to [ConnectionDispatcher] on the main thread. + */ +class ConnectionEventLoop( + private val dispatcher: ConnectionDispatcher, +) { + private val channel = Channel(Channel.BUFFERED) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + fun start() { + scope.launch { + for (command in channel) { + try { + dispatcher.dispatch(command) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + android.util.Log.e("ConnectionEventLoop", "Error dispatching $command", e) + } + } + } + } + + fun send(command: ConnectionCommand) { + val result = channel.trySend(command) + if (result.isFailure) { + android.util.Log.e("ConnectionEventLoop", "trySend failed for $command", result.exceptionOrNull()) + } + } + + /** For delayed sends (reconnect timer, GMCP retry). Returns a Job that can be cancelled. */ + fun sendDelayed(command: ConnectionCommand, delayMs: Long): Job { + return scope.launch { + delay(delayMs) + channel.send(command) + } + } + + fun shutdown() { + channel.close() + scope.cancel() + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionHandleAdapters.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionHandleAdapters.kt new file mode 100644 index 00000000..ea939c7c --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionHandleAdapters.kt @@ -0,0 +1,186 @@ +package com.offsetnull.bt.service + +import java.io.UnsupportedEncodingException + +internal class TriggerManagerAdapter(private val conn: Connection) : TriggerManagerHandle { + override fun dispatch(data: ByteArray) { + try { + conn.mTriggerManager.dispatch(data) + } catch (_: UnsupportedEncodingException) {} + } + + override fun setDirty() { + conn.mTriggerManager.setDirty() + } +} + +internal class PumpAdapter(private val conn: Connection) : PumpHandle { + override fun send(data: ByteArray) { + conn.mPump?.sendData(data) + } + + override fun startCompression(trailingData: ByteArray?) { + conn.mPump?.startCompression(trailingData) + } +} + +internal class BellCallbacksAdapter(private val conn: Connection) : BellCallbacks { + override val vibrateOnBell: Boolean get() = conn.mSettings.isVibrateOnBell + override val notifyOnBell: Boolean get() = conn.mSettings.isNotifyOnBell + override val displayOnBell: Boolean get() = conn.mSettings.isDisplayOnBell + + override fun doVibrateBell() { + conn.mService.doVibrateBell() + } + + override fun doNotifyBell() { + conn.mService.doNotifyBell(conn.mDisplay, conn.mHost, conn.mPort) + } + + override fun doDisplayBell() { + conn.mService.doDisplayBell() + } +} + +internal class DisplayAdapter(private val conn: Connection) : DisplayHandle { + override fun sendDataToWindow(text: String) { + conn.sendDataToWindow(text) + } + + override fun dispatchNoProcess(data: ByteArray) { + conn.dispatchNoProcess(data) + } + + override fun dispatchDialog(message: String) { + conn.dispatchDialog(message) + } +} + +internal class LifecycleAdapter(private val conn: Connection) : LifecycleHandle { + override fun killNetThreads() { + conn.killNetThreads(true) + } + + override fun doDisconnect(byPeer: Boolean) { + conn.doDisconnect(byPeer) + conn.mIsConnected = false + } + + override fun doReconnect() { + conn.doReconnect() + } + + override fun doStartup() { + conn.startup() + } + + override fun resetAutoReconnect() { + conn.mAutoReconnectAttempt = 0 + } + + override fun reloadSettings() { + conn.reloadSettings() + } + + override fun setLocalEcho(enabled: Boolean) { + conn.mSettings.isLocalEcho = enabled + } +} + +internal class WindowManagerAdapter(private val conn: Connection) : WindowManagerHandle { + override fun lineToWindow(target: String, line: Any) { + conn.lineToWindow(target, line) + } + + override fun redrawWindow(name: String) { + conn.redrawWindow(name) + } + + override fun addWindow(token: Any) { + conn.mWindowManager.windows.add(token as WindowToken) + } + + override fun setWindowBuffer(name: String, enabled: Boolean) { + for (tok in conn.mWindowManager.windows) { + if (tok.name == name) { + tok.setBufferText(enabled) + } + } + } + + override fun windowXCallS(token: String, function: String, data: Any) { + conn.windowXCallS(token, function, data) + } + + override fun windowXCallB(token: String, function: String, data: ByteArray) { + conn.windowXCallB(token, function, data) + } + + override fun invalidateWindowText(name: String) { + conn.doInvalidateWindowText(name) + } +} + +@Suppress("UNCHECKED_CAST") +internal class PluginManagerAdapter(private val conn: Connection) : PluginManagerHandle { + override fun callPlugin(plugin: String, function: String, data: String) { + conn.doCallPlugin(plugin, function, data) + } + + override fun addLink(path: String) { + conn.doAddLink(path) + } + + override fun deletePlugin(name: String) { + conn.doDeletePlugin(name) + } + + override fun saveDirtyPlugin(name: String) { + conn.saveDirtyPlugin(name) + } + + override fun exportSettings(path: String) { + conn.exportSettings(path) + } + + override fun importSettings(path: String) { + conn.mService.markWindowsDirty() + conn.importSettings(path, true, false) + } + + override fun resetSettings() { + conn.doResetSettings() + } +} + +internal class TimerManagerAdapter(private val conn: Connection) : TimerManagerHandle { + override fun handleAction(name: String, id: Int, action: ConnectionCommand.TimerActionType) { + val timerAction = when (action) { + ConnectionCommand.TimerActionType.START -> TimerManager.TimerAction.PLAY + ConnectionCommand.TimerActionType.STOP -> TimerManager.TimerAction.STOP + ConnectionCommand.TimerActionType.PAUSE -> TimerManager.TimerAction.PAUSE + ConnectionCommand.TimerActionType.RESET -> TimerManager.TimerAction.RESET + ConnectionCommand.TimerActionType.INFO -> TimerManager.TimerAction.INFO + } + conn.mTimerManager.handleAction(name, id, timerAction) + } +} + +@Suppress("UNCHECKED_CAST") +internal class GmcpAdapter(private val conn: Connection) : GmcpHandle { + override fun sendData(data: String) { + conn.mGMCPHandler.sendData(data) + } + + override fun handleCallback(plugin: String, callback: String, data: Any) { + conn.mGMCPHandler.handleCallback( + plugin, callback, data as java.util.HashMap + ) + } +} + +internal class AliasManagerAdapter(private val conn: Connection) : AliasManagerHandle { + override fun addFunctionCallback(id: String, command: String, callback: String) { + conn.addFunctionCallbackImpl(id, command, callback) + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionHandlerShim.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionHandlerShim.kt new file mode 100644 index 00000000..34346d33 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ConnectionHandlerShim.kt @@ -0,0 +1,232 @@ +package com.offsetnull.bt.service + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message + +/** + * Backward-compatible Handler shim for callers that still send messages via + * Handler (Plugin, Processor, DataPumper, responders). + * + * Converts incoming [Message] objects to [ConnectionCommand] and forwards them + * through [ConnectionEventLoop]. Special cases (sendToServer, GMCP retry) are + * handled via callbacks to Connection. + * + * This is transitional — once Plugin/Processor/DataPumper are migrated to send + * ConnectionCommand directly, this class can be removed. + */ +class ConnectionHandlerShim( + private val eventLoop: ConnectionEventLoop, + private val sendToServerBytes: BytesSink, + private val sendToServerString: StringSink, + private val isPumpConnected: PumpCheck, +) : Handler(Looper.getMainLooper()) { + + /** Java-friendly functional interface for sending bytes. */ + fun interface BytesSink { + fun send(data: ByteArray) + } + + /** Java-friendly functional interface for sending strings. */ + fun interface StringSink { + fun send(data: String) + } + + /** Java-friendly functional interface for checking pump connectivity. */ + fun interface PumpCheck { + fun isConnected(): Boolean + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") + override fun handleMessage(msg: Message) { + when (msg.what) { + Connection.MESSAGE_TERMINATED_BY_PEER -> + eventLoop.send(ConnectionCommand.TerminatedByPeer()) + + Connection.MESSAGE_TIMERSTOP -> + eventLoop.send(ConnectionCommand.TimerAction( + msg.obj as String, msg.arg2, ConnectionCommand.TimerActionType.STOP)) + + Connection.MESSAGE_TIMERSTART -> + eventLoop.send(ConnectionCommand.TimerAction( + msg.obj as String, msg.arg2, ConnectionCommand.TimerActionType.START)) + + Connection.MESSAGE_TIMERRESET -> + eventLoop.send(ConnectionCommand.TimerAction( + msg.obj as String, msg.arg2, ConnectionCommand.TimerActionType.RESET)) + + Connection.MESSAGE_TIMERINFO -> + eventLoop.send(ConnectionCommand.TimerAction( + msg.obj as String, msg.arg2, ConnectionCommand.TimerActionType.INFO)) + + Connection.MESSAGE_TIMERPAUSE -> + eventLoop.send(ConnectionCommand.TimerAction( + msg.obj as String, msg.arg2, ConnectionCommand.TimerActionType.PAUSE)) + + Connection.MESSAGE_CALLPLUGIN -> { + val data = msg.data + eventLoop.send(ConnectionCommand.CallPlugin( + data.getString("PLUGIN") ?: "", + data.getString("FUNCTION") ?: "", + data.getString("DATA") ?: "")) + } + + Connection.MESSAGE_SETTRIGGERSDIRTY -> + eventLoop.send(ConnectionCommand.SetTriggersDirty) + + Connection.MESSAGE_RELOADSETTINGS -> + eventLoop.send(ConnectionCommand.ReloadSettings) + + Connection.MESSAGE_TRIGGER_LUA_ERROR -> + eventLoop.send(ConnectionCommand.TriggerLuaError(msg.obj as String)) + + Connection.MESSAGE_RECONNECT -> + eventLoop.send(ConnectionCommand.Reconnect) + + Connection.MESSAGE_CONNECTED -> + eventLoop.send(ConnectionCommand.Connected) + + Connection.MESSAGE_DELETEPLUGIN -> + eventLoop.send(ConnectionCommand.DeletePlugin(msg.obj as String)) + + Connection.MESSAGE_ADDLINK -> + eventLoop.send(ConnectionCommand.AddLink(msg.obj as String)) + + Connection.MESSAGE_DORESETSETTINGS -> + eventLoop.send(ConnectionCommand.ResetSettings) + + Connection.MESSAGE_PLUGINLUAERROR -> + eventLoop.send(ConnectionCommand.LuaError(msg.obj as String)) + + Connection.MESSAGE_EXPORTFILE -> + eventLoop.send(ConnectionCommand.ExportFile(msg.obj as String)) + + Connection.MESSAGE_IMPORTFILE -> + eventLoop.send(ConnectionCommand.ImportFile(msg.obj as String)) + + Connection.MESSAGE_SAVESETTINGS -> + eventLoop.send(ConnectionCommand.SaveDirtyPlugin(msg.obj as String)) + + Connection.MESSAGE_GMCPTRIGGERED -> { + val data = msg.data + eventLoop.send(ConnectionCommand.GmcpTriggered( + data.getString("TARGET") ?: "", + data.getString("CALLBACK") ?: "", + msg.obj ?: "")) + } + + Connection.MESSAGE_INVALIDATEWINDOWTEXT -> + eventLoop.send(ConnectionCommand.InvalidateWindowText(msg.obj as String)) + + Connection.MESSAGE_WINDOWXCALLS -> { + val o = msg.obj ?: "" + val data = msg.data + eventLoop.send(ConnectionCommand.WindowXCallS( + data.getString("TOKEN") ?: "", + data.getString("FUNCTION") ?: "", + o)) + } + + Connection.MESSAGE_WINDOWXCALLB -> { + val data = msg.data + eventLoop.send(ConnectionCommand.WindowXCallB( + data.getString("TOKEN") ?: "", + data.getString("FUNCTION") ?: "", + msg.obj as ByteArray)) + } + + Connection.MESSAGE_ADDFUNCTIONCALLBACK -> { + val data = msg.data + eventLoop.send(ConnectionCommand.AddFunctionCallback( + data.getString("ID") ?: "", + data.getString("COMMAND") ?: "", + data.getString("CALLBACK") ?: "")) + } + + Connection.MESSAGE_WINDOWBUFFER -> + eventLoop.send(ConnectionCommand.WindowBuffer( + msg.obj as String, msg.arg1 != 0)) + + Connection.MESSAGE_NEWWINDOW -> + eventLoop.send(ConnectionCommand.NewWindow(msg.obj)) + + Connection.MESSAGE_DRAWINDOW -> + eventLoop.send(ConnectionCommand.DrawWindow(msg.obj as String)) + + Connection.MESSAGE_LUANOTE -> { + val str = msg.obj as? String + if (str != null) { + eventLoop.send(ConnectionCommand.LuaNote(str)) + } + } + + Connection.MESSAGE_LINETOWINDOW -> { + val data = msg.data + eventLoop.send(ConnectionCommand.LineToWindow( + data.getString("TARGET") ?: "", + msg.obj)) + } + + // Special cases: these go through alias processing, not the dispatcher. + Connection.MESSAGE_SENDDATA_STRING -> { + val str = msg.obj as? String ?: return + sendToServerString.send(str) + } + + Connection.MESSAGE_SENDDATA_BYTES -> { + val bytes = msg.obj as? ByteArray ?: return + sendToServerBytes.send(bytes) + } + + Connection.MESSAGE_SENDGMCPDATA -> { + if (isPumpConnected.isConnected()) { + eventLoop.send(ConnectionCommand.SendGmcpData(msg.obj as String)) + } else { + eventLoop.sendDelayed( + ConnectionCommand.SendGmcpData(msg.obj as String), + GMCP_RETRY_DELAY_MS, + ) + } + } + + Connection.MESSAGE_STARTUP -> + eventLoop.send(ConnectionCommand.Startup) + + Connection.MESSAGE_STARTCOMPRESS -> + eventLoop.send(ConnectionCommand.StartCompress(msg.obj as? ByteArray)) + + Connection.MESSAGE_SENDOPTIONDATA -> { + val b = msg.data + eventLoop.send(ConnectionCommand.SendOptionData( + b.getByteArray("THE_DATA") ?: ByteArray(0), + b.getString("DEBUG_MESSAGE"))) + } + + Connection.MESSAGE_PROCESSORWARNING -> + eventLoop.send(ConnectionCommand.ProcessorWarning(msg.obj as String)) + + Connection.MESSAGE_BELLINC -> + eventLoop.send(ConnectionCommand.BellReceived()) + + Connection.MESSAGE_DODIALOG -> + eventLoop.send(ConnectionCommand.DialogError(msg.obj as String)) + + Connection.MESSAGE_PROCESS -> + eventLoop.send(ConnectionCommand.Process(msg.obj as ByteArray)) + + Connection.MESSAGE_DISCONNECTED -> + eventLoop.send(ConnectionCommand.Disconnected) + + Connection.MESSAGE_DISABLE_LOCAL_ECHO -> + eventLoop.send(ConnectionCommand.DisableLocalEcho) + + Connection.MESSAGE_ENABLE_LOCAL_ECHO -> + eventLoop.send(ConnectionCommand.EnableLocalEcho) + } + } + + companion object { + private const val GMCP_RETRY_DELAY_MS = 500L + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceCommand.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceCommand.kt new file mode 100644 index 00000000..e4af2eec --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceCommand.kt @@ -0,0 +1,8 @@ +package com.offsetnull.bt.service + +sealed interface ServiceCommand { + data class NewConnection(val display: String, val host: String, val port: Int) : ServiceCommand + data class SwitchConnection(val display: String) : ServiceCommand + data object Startup : ServiceCommand + data object ReloadSettings : ServiceCommand +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceDispatcher.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceDispatcher.kt new file mode 100644 index 00000000..601e9a37 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceDispatcher.kt @@ -0,0 +1,42 @@ +package com.offsetnull.bt.service + +class ServiceDispatcher( + private val connectionFactory: (String, String, Int) -> ConnectionHandle = { _, _, _ -> + throw IllegalStateException("No connection factory configured") + }, +) { + val connections = mutableMapOf() + var activeConnection: String? = null + private set + + fun dispatch(command: ServiceCommand) { + when (command) { + is ServiceCommand.NewConnection -> { + if (command.display !in connections) { + val conn = connectionFactory(command.display, command.host, command.port) + connections[command.display] = conn + activeConnection = command.display + conn.initWindows() + } + } + is ServiceCommand.SwitchConnection -> { + activeConnection = command.display + } + is ServiceCommand.Startup -> { + val conn = connections[activeConnection] ?: return + conn.startup() + } + is ServiceCommand.ReloadSettings -> { + val conn = connections[activeConnection] ?: return + conn.reloadSettings() + } + } + } +} + +/** Minimal interface that ServiceDispatcher needs from a Connection. */ +interface ConnectionHandle { + fun startup() + fun reloadSettings() + fun initWindows() +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceEventLoop.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceEventLoop.kt new file mode 100644 index 00000000..e6a26287 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/ServiceEventLoop.kt @@ -0,0 +1,57 @@ +package com.offsetnull.bt.service + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +/** + * Replaces StellarService's Handler/ServiceHandler with a coroutine Channel. + * Dispatches [ServiceCommand]s to [ServiceDispatcher] on the main thread, + * then invokes side-effect callbacks. + */ +class ServiceEventLoop( + private val dispatcher: ServiceDispatcher, + private val sideEffects: SideEffects, +) { + private val channel = Channel(Channel.BUFFERED) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + interface SideEffects { + fun onNewConnection(display: String) + fun onReloadWindows() + fun onSwitchTo(display: String) + } + + fun start() { + scope.launch { + for (command in channel) { + try { + dispatcher.dispatch(command) + when (command) { + is ServiceCommand.NewConnection -> + sideEffects.onNewConnection(command.display) + is ServiceCommand.ReloadSettings -> sideEffects.onReloadWindows() + is ServiceCommand.SwitchConnection -> + sideEffects.onSwitchTo(command.display) + else -> {} + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Log.e("ServiceEventLoop", "Error dispatching $command", e) + } + } + } + } + + fun send(command: ServiceCommand) { + channel.trySend(command) + } + + fun shutdown() { + channel.close() + scope.cancel() + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/TelnetDelegateAdapter.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/TelnetDelegateAdapter.kt new file mode 100644 index 00000000..5a3e8ba0 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/TelnetDelegateAdapter.kt @@ -0,0 +1,42 @@ +package com.offsetnull.bt.service + +import android.util.Log +import mth.core.client.TelnetClientDelegate + +internal class TelnetDelegateAdapter(private val conn: Connection) : TelnetClientDelegate { + + override fun write(data: ByteArray) { + conn.mPump?.sendData(data) + } + + override fun onLocalEchoChanged(enabled: Boolean) { + conn.mSettings.isLocalEcho = enabled + } + + override fun onGMCPNegotiated() { + val session = conn.mTelnetSession ?: return + session.sendGMCP("core.hello", "{\"client\": \"WAMDROID\",\"version\": \"2.0\"}") + val supports = conn.mGMCPSupports + session.sendGMCP("core.supports.set", "[$supports]") + } + + override fun onGMCPReceived(module: String, json: String) { + conn.mGMCPHandler.dispatchGMCPData(module, json) + } + + override fun onMSDPVariable(name: String, value: String) { + Log.d("MTH", "MSDP: $name = $value") + } + + override fun onPromptReceived() { + // Future: prompt detection via EOR/GA + } + + override fun onBellReceived() { + conn.sendCommand(ConnectionCommand.BellReceived()) + } + + override fun log(message: String) { + Log.d("MTH", message) + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/DataPumperBridge.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/DataPumperBridge.kt new file mode 100644 index 00000000..a11508ce --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/DataPumperBridge.kt @@ -0,0 +1,37 @@ +package com.offsetnull.bt.service.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** Java-friendly callback for pump events. */ +fun interface PumpEventListener { + fun onEvent(event: PumpEvent) +} + +/** + * Bridges the Java-based [com.offsetnull.bt.service.DataPumper] to the + * Kotlin coroutine-based [DataPumperLoop]. Hides coroutine API from Java. + */ +class DataPumperBridge( + socket: SocketIO, + listener: PumpEventListener, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val loop = DataPumperLoop(socket) { event -> + listener.onEvent(event) + } + + fun start() { + scope.launch { + loop.run() + } + } + + fun shutdown() { + scope.cancel() + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/DataPumperLoop.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/DataPumperLoop.kt new file mode 100644 index 00000000..0aaf785a --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/DataPumperLoop.kt @@ -0,0 +1,87 @@ +package com.offsetnull.bt.service.net + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import java.io.IOException + +/** + * Coroutine-based replacement for [com.offsetnull.bt.service.DataPumper]. + * + * Reads from [socket] in a loop, forwarding events via [onEvent]. + * Outbound data is queued via [send] and written on a separate coroutine. + * + * Cancel the coroutine running [run] to shut down. + */ +class DataPumperLoop( + private val socket: SocketIO, + private val onEvent: suspend (PumpEvent) -> Unit, +) { + private val outgoing = Channel(Channel.BUFFERED) + private val decompressor = MccpDecompressor() + private var compressed = false + + /** Queue bytes to be sent to the server. */ + fun send(data: ByteArray) { + outgoing.trySend(data) + } + + @Suppress("UnusedParameter") + fun startCompression(trailingData: ByteArray?) { + compressed = true + decompressor.reset() + } + + fun stopCompression() { + compressed = false + } + + /** Runs the read and write loops until cancelled or disconnected. */ + suspend fun run() = coroutineScope { + launch { writeLoop() } + readLoop() + outgoing.close() + } + + @Suppress("NestedBlockDepth") + private suspend fun readLoop() { + while (socket.isConnected) { + val data: ByteArray + try { + data = socket.read() + } catch (e: IOException) { + onEvent(PumpEvent.Disconnected(e)) + return + } + if (data.isEmpty()) { + onEvent(PumpEvent.DisconnectedByPeer) + return + } + val processed = if (compressed) { + val result = decompressor.decompress(data) + if (result == null) { + if (decompressor.isCorrupted) { + onEvent(PumpEvent.MccpFatalError) + return + } + continue + } + result.data + } else { + data + } + onEvent(PumpEvent.DataReceived(processed)) + } + } + + private suspend fun writeLoop() { + for (data in outgoing) { + try { + socket.write(data) + } catch (e: IOException) { + onEvent(PumpEvent.Disconnected(e)) + return + } + } + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/MccpDecompressor.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/MccpDecompressor.kt new file mode 100644 index 00000000..ae1569f3 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/MccpDecompressor.kt @@ -0,0 +1,60 @@ +package com.offsetnull.bt.service.net + +import java.io.ByteArrayOutputStream +import java.util.zip.DataFormatException +import java.util.zip.Inflater + +/** + * Handles MCCP (Mud Client Compression Protocol) zlib decompression. + * Ported from DataPumper.doDecompress(). + */ +class MccpDecompressor { + private var inflater = Inflater() + var isCorrupted: Boolean = false + private set + + data class Result(val data: ByteArray, val remainder: ByteArray?) + + fun decompress(input: ByteArray): Result? { + inflater.setInput(input, 0, input.size) + val output = ByteArrayOutputStream() + val buf = ByteArray(BUFFER_SIZE) + + while (!inflater.needsInput()) { + val count: Int + try { + count = inflater.inflate(buf, 0, buf.size) + } catch (_: DataFormatException) { + isCorrupted = true + inflater = Inflater() + return null + } + + if (inflater.finished()) { + if (count > 0) output.write(buf, 0, count) + val remaining = inflater.remaining + val remainder = if (remaining > 0) { + val pos = input.size - remaining + input.copyOfRange(pos, input.size) + } else null + inflater = Inflater() + val data = output.toByteArray() + return if (data.isEmpty()) null else Result(data, remainder) + } + + if (count > 0) output.write(buf, 0, count) + } + + val data = output.toByteArray() + return if (data.isEmpty()) null else Result(data, null) + } + + fun reset() { + inflater = Inflater() + isCorrupted = false + } + + companion object { + private const val BUFFER_SIZE = 256 + } +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/PumpEvent.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/PumpEvent.kt new file mode 100644 index 00000000..2da49bc7 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/PumpEvent.kt @@ -0,0 +1,17 @@ +package com.offsetnull.bt.service.net + +/** Events emitted by [DataPumperLoop] to its owner (Connection). */ +sealed interface PumpEvent { + /** Raw bytes received from the server (after decompression if active). */ + data class DataReceived(val data: ByteArray) : PumpEvent + /** Server closed the connection gracefully (EOF). */ + data object DisconnectedByPeer : PumpEvent + /** Connection lost due to I/O error. */ + data class Disconnected(val cause: Exception? = null) : PumpEvent + /** Informational/warning text (for display, not trigger processing). */ + data class Warning(val text: String) : PumpEvent + /** Fatal MCCP decompression error. */ + data object MccpFatalError : PumpEvent + /** Error requiring a dialog display. */ + data class DialogError(val message: String) : PumpEvent +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/RealSocketIO.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/RealSocketIO.kt new file mode 100644 index 00000000..82de2a3e --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/RealSocketIO.kt @@ -0,0 +1,71 @@ +package com.offsetnull.bt.service.net + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket + +@Suppress("MagicNumber") +class RealSocketIO(private val host: String, private val port: Int) : SocketIO { + private var socket: Socket? = null + private var reader: BufferedInputStream? = null + private var writer: BufferedOutputStream? = null + + fun connect(timeoutMs: Int = 14000) { + val s = Socket() + s.keepAlive = true + s.soTimeout = 0 + s.connect(InetSocketAddress(host, port), timeoutMs) + s.sendBufferSize = 1024 + socket = s + reader = BufferedInputStream(s.getInputStream()) + writer = BufferedOutputStream(s.getOutputStream()) + } + + override suspend fun read(): ByteArray = withContext(Dispatchers.IO) { + val r = reader ?: throw IOException("not connected") + val available = r.available() + if (available > 0) { + val buf = ByteArray(available) + val bytesRead = r.read(buf, 0, available) + if (bytesRead == -1) return@withContext ByteArray(0) + if (bytesRead < available) buf.copyOf(bytesRead) else buf + } else { + // Blocking read for a single byte to detect EOF + val b = r.read() + if (b == -1) { + ByteArray(0) // EOF + } else { + // Got one byte, check if more available now + val more = r.available() + if (more > 0) { + val buf = ByteArray(more + 1) + buf[0] = b.toByte() + r.read(buf, 1, more) + buf + } else { + byteArrayOf(b.toByte()) + } + } + } + } + + override suspend fun write(data: ByteArray) = withContext(Dispatchers.IO) { + val w = writer ?: throw IOException("not connected") + w.write(data) + w.flush() + } + + override fun close() { + runCatching { reader?.close() } + runCatching { writer?.close() } + runCatching { socket?.close() } + socket = null + } + + override val isConnected: Boolean + get() = socket?.isConnected == true && socket?.isClosed == false +} diff --git a/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/SocketIO.kt b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/SocketIO.kt new file mode 100644 index 00000000..1758cd44 --- /dev/null +++ b/BTLib/src/main/kotlin/com/offsetnull/bt/service/net/SocketIO.kt @@ -0,0 +1,19 @@ +package com.offsetnull.bt.service.net + +/** + * Abstraction over TCP socket I/O so that [DataPumperLoop] can be tested + * without real network connections. + */ +interface SocketIO { + /** Reads available bytes. Returns empty array if nothing available. Throws on error. */ + suspend fun read(): ByteArray + + /** Writes bytes to the socket. Throws on error. */ + suspend fun write(data: ByteArray) + + /** Closes the connection. Safe to call multiple times. */ + fun close() + + /** True if the connection is open. */ + val isConnected: Boolean +} diff --git a/BTLib/src/test/kotlin/com/offsetnull/bt/service/ConnectionDispatcherTest.kt b/BTLib/src/test/kotlin/com/offsetnull/bt/service/ConnectionDispatcherTest.kt new file mode 100644 index 00000000..d910eeca --- /dev/null +++ b/BTLib/src/test/kotlin/com/offsetnull/bt/service/ConnectionDispatcherTest.kt @@ -0,0 +1,167 @@ +package com.offsetnull.bt.service + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ConnectionDispatcherTest { + + @Test + fun `Process dispatches data to trigger manager`() { + val triggers = FakeTriggerManager() + val dispatcher = ConnectionDispatcher(triggerManager = triggers) + + val data = "You are standing in a forest.\n".toByteArray() + dispatcher.dispatch(ConnectionCommand.Process(data)) + + assertEquals(1, triggers.dispatched.size) + assertArrayEquals(data, triggers.dispatched[0]) + } + + @Test + fun `SendDataString encodes and sends to pump`() { + val pump = FakePump() + val dispatcher = ConnectionDispatcher(pump = pump, encoding = "UTF-8") + + dispatcher.dispatch(ConnectionCommand.SendDataString("look\n")) + + assertEquals(1, pump.sent.size) + assertArrayEquals("look\n".toByteArray(), pump.sent[0]) + } + + @Test + fun `SendDataBytes sends raw bytes to pump`() { + val pump = FakePump() + val dispatcher = ConnectionDispatcher(pump = pump) + + val data = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x01) + dispatcher.dispatch(ConnectionCommand.SendDataBytes(data)) + + assertEquals(1, pump.sent.size) + assertArrayEquals(data, pump.sent[0]) + } + + @Test + fun `Disconnected kills net threads and marks disconnected`() { + val pump = FakePump() + val lifecycle = FakeLifecycle() + val dispatcher = ConnectionDispatcher(pump = pump, lifecycle = lifecycle) + + dispatcher.dispatch(ConnectionCommand.Disconnected) + + assertTrue(lifecycle.netThreadsKilled) + assertTrue(lifecycle.disconnected) + } + + @Test + fun `TerminatedByPeer kills net threads and marks disconnected`() { + val pump = FakePump() + val lifecycle = FakeLifecycle() + val dispatcher = ConnectionDispatcher(pump = pump, lifecycle = lifecycle) + + dispatcher.dispatch(ConnectionCommand.TerminatedByPeer()) + + assertTrue(lifecycle.netThreadsKilled) + assertTrue(lifecycle.disconnectedByPeer) + } + + @Test + fun `BellReceived triggers vibrate when enabled`() { + val service = FakeServiceCallbacks(vibrateOnBell = true) + val dispatcher = ConnectionDispatcher(serviceCallbacks = service) + + dispatcher.dispatch(ConnectionCommand.BellReceived()) + + assertTrue(service.vibrated) + } + + @Test + fun `BellReceived does not vibrate when disabled`() { + val service = FakeServiceCallbacks(vibrateOnBell = false) + val dispatcher = ConnectionDispatcher(serviceCallbacks = service) + + dispatcher.dispatch(ConnectionCommand.BellReceived()) + + assertTrue(!service.vibrated) + } + + @Test + fun `ProcessorWarning dispatches text to window`() { + val display = FakeDisplay() + val dispatcher = ConnectionDispatcher(display = display) + + dispatcher.dispatch(ConnectionCommand.ProcessorWarning("Warning text")) + + assertEquals(listOf("Warning text"), display.sent) + } + + @Test + fun `Connected resets auto reconnect counter`() { + val lifecycle = FakeLifecycle() + val dispatcher = ConnectionDispatcher(lifecycle = lifecycle) + + dispatcher.dispatch(ConnectionCommand.Connected) + + assertTrue(lifecycle.reconnectReset) + } + + @Test + fun `ReloadSettings delegates to lifecycle`() { + val lifecycle = FakeLifecycle() + val dispatcher = ConnectionDispatcher(lifecycle = lifecycle) + + dispatcher.dispatch(ConnectionCommand.ReloadSettings) + + assertTrue(lifecycle.settingsReloaded) + } +} + +private class FakeTriggerManager : TriggerManagerHandle { + val dispatched = mutableListOf() + override fun dispatch(data: ByteArray) { dispatched.add(data) } +} + +private class FakePump : PumpHandle { + val sent = mutableListOf() + var compressionStarted = false + override fun send(data: ByteArray) { sent.add(data) } + override fun startCompression(trailingData: ByteArray?) { compressionStarted = true } +} + +private class FakeServiceCallbacks( + override val vibrateOnBell: Boolean = false, + override val notifyOnBell: Boolean = false, + override val displayOnBell: Boolean = false, +) : BellCallbacks { + var vibrated = false + var notified = false + var displayed = false + override fun doVibrateBell() { vibrated = true } + override fun doNotifyBell() { notified = true } + override fun doDisplayBell() { displayed = true } +} + +private class FakeDisplay : DisplayHandle { + val sent = mutableListOf() + override fun sendDataToWindow(text: String) { sent.add(text) } + override fun dispatchNoProcess(data: ByteArray) {} + override fun dispatchDialog(message: String) {} +} + +private class FakeLifecycle : LifecycleHandle { + var netThreadsKilled = false + var disconnected = false + var disconnectedByPeer = false + var reconnectReset = false + var settingsReloaded = false + var started = false + override fun killNetThreads() { netThreadsKilled = true } + override fun doDisconnect(byPeer: Boolean) { + if (byPeer) disconnectedByPeer = true else disconnected = true + } + override fun doReconnect() {} + override fun doStartup() { started = true } + override fun resetAutoReconnect() { reconnectReset = true } + override fun reloadSettings() { settingsReloaded = true } +} diff --git a/BTLib/src/test/kotlin/com/offsetnull/bt/service/ServiceDispatcherTest.kt b/BTLib/src/test/kotlin/com/offsetnull/bt/service/ServiceDispatcherTest.kt new file mode 100644 index 00000000..49f7bd07 --- /dev/null +++ b/BTLib/src/test/kotlin/com/offsetnull/bt/service/ServiceDispatcherTest.kt @@ -0,0 +1,88 @@ +package com.offsetnull.bt.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +class ServiceDispatcherTest { + + @Test + fun `newConnection creates Connection and stores it`() { + val dispatcher = ServiceDispatcher( + connectionFactory = { display, _, _ -> FakeConnection(display) } + ) + dispatcher.dispatch(ServiceCommand.NewConnection("TestMUD", "mud.example.com", 4000)) + + assertNotNull(dispatcher.connections["TestMUD"]) + } + + @Test + fun `newConnection sets active connection`() { + val dispatcher = ServiceDispatcher( + connectionFactory = { display, _, _ -> FakeConnection(display) } + ) + dispatcher.dispatch(ServiceCommand.NewConnection("TestMUD", "mud.example.com", 4000)) + + assertEquals("TestMUD", dispatcher.activeConnection) + } + + @Test + fun `duplicate newConnection does not replace existing`() { + val dispatcher = ServiceDispatcher( + connectionFactory = { display, _, _ -> FakeConnection(display) } + ) + dispatcher.dispatch(ServiceCommand.NewConnection("MUD1", "a.com", 1)) + val first = dispatcher.connections["MUD1"] + dispatcher.dispatch(ServiceCommand.NewConnection("MUD1", "b.com", 2)) + + assertEquals(first, dispatcher.connections["MUD1"]) + } + + @Test + fun `switchConnection updates active clutch`() { + val dispatcher = ServiceDispatcher( + connectionFactory = { display, _, _ -> FakeConnection(display) } + ) + dispatcher.dispatch(ServiceCommand.NewConnection("MUD1", "a.com", 1)) + dispatcher.dispatch(ServiceCommand.NewConnection("MUD2", "b.com", 2)) + dispatcher.dispatch(ServiceCommand.SwitchConnection("MUD1")) + + assertEquals("MUD1", dispatcher.activeConnection) + } + + @Test + fun `startup sends startup to active connection`() { + val events = mutableListOf() + val dispatcher = ServiceDispatcher( + connectionFactory = { display, _, _ -> + FakeConnection(display).also { it.onStartup = { events.add("startup:$display") } } + } + ) + dispatcher.dispatch(ServiceCommand.NewConnection("TestMUD", "mud.example.com", 4000)) + dispatcher.dispatch(ServiceCommand.Startup) + + assertEquals(listOf("startup:TestMUD"), events) + } + + @Test + fun `reloadSettings delegates to active connection`() { + val events = mutableListOf() + val dispatcher = ServiceDispatcher( + connectionFactory = { display, _, _ -> + FakeConnection(display).also { it.onReload = { events.add("reload:$display") } } + } + ) + dispatcher.dispatch(ServiceCommand.NewConnection("TestMUD", "mud.example.com", 4000)) + dispatcher.dispatch(ServiceCommand.ReloadSettings) + + assertEquals(listOf("reload:TestMUD"), events) + } +} + +private class FakeConnection(val display: String) : ConnectionHandle { + var onStartup: (() -> Unit)? = null + var onReload: (() -> Unit)? = null + override fun startup() { onStartup?.invoke() } + override fun reloadSettings() { onReload?.invoke() } + override fun initWindows() {} +} diff --git a/BTLib/src/test/kotlin/com/offsetnull/bt/service/net/DataPumperLoopTest.kt b/BTLib/src/test/kotlin/com/offsetnull/bt/service/net/DataPumperLoopTest.kt new file mode 100644 index 00000000..e6b3cdc0 --- /dev/null +++ b/BTLib/src/test/kotlin/com/offsetnull/bt/service/net/DataPumperLoopTest.kt @@ -0,0 +1,157 @@ +package com.offsetnull.bt.service.net + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.zip.Deflater + +class DataPumperLoopTest { + + private fun compress(input: ByteArray): ByteArray { + val deflater = Deflater() + deflater.setInput(input) + deflater.finish() + val output = ByteArray(input.size + 64) + val len = deflater.deflate(output) + deflater.end() + return output.copyOf(len) + } + + /** Collects events emitted by [DataPumperLoop]. */ + private class Collector { + val received = mutableListOf() + val events = mutableListOf() + } + + private fun fakeSocket(vararg chunks: ByteArray): FakeSocketIO = + FakeSocketIO(chunks.toMutableList()) + + @Test + fun `delivers incoming data to onData callback`() = runTest { + val data = "Hello from MUD\n".toByteArray() + val socket = fakeSocket(data) + val collector = Collector() + val loop = DataPumperLoop(socket) { event -> + when (event) { + is PumpEvent.DataReceived -> collector.received.add(event.data) + else -> collector.events.add(event) + } + } + + val job = launch { loop.run() } + advanceUntilIdle() + job.cancel() + + assertEquals(1, collector.received.size) + assertArrayEquals(data, collector.received[0]) + } + + @Test + fun `emits Disconnected on EOF`() = runTest { + val socket = fakeSocket() // no data, returns EOF immediately + val collector = Collector() + val loop = DataPumperLoop(socket) { event -> + when (event) { + is PumpEvent.DataReceived -> collector.received.add(event.data) + else -> collector.events.add(event) + } + } + + val job = launch { loop.run() } + advanceUntilIdle() + job.cancel() + + assertTrue(collector.events.any { it is PumpEvent.DisconnectedByPeer }) + } + + @Test + fun `emits Disconnected on IOException`() = runTest { + val socket = ErrorSocketIO() + val collector = Collector() + val loop = DataPumperLoop(socket) { event -> + when (event) { + is PumpEvent.DataReceived -> collector.received.add(event.data) + else -> collector.events.add(event) + } + } + + val job = launch { loop.run() } + advanceUntilIdle() + job.cancel() + + assertTrue(collector.events.any { it is PumpEvent.Disconnected }) + } + + @Test + fun `write sends data through socket`() = runTest { + val socket = fakeSocket() + val loop = DataPumperLoop(socket) { } + + val job = launch { loop.run() } + loop.send("test\n".toByteArray()) + advanceUntilIdle() + job.cancel() + + assertEquals(1, socket.written.size) + assertArrayEquals("test\n".toByteArray(), socket.written[0]) + } + + @Test + fun `startCompression enables decompression of subsequent data`() = runTest { + val original = "compressed data from server".toByteArray() + val compressed = compress(original) + val socket = fakeSocket(compressed) + val collector = Collector() + val loop = DataPumperLoop(socket) { event -> + when (event) { + is PumpEvent.DataReceived -> collector.received.add(event.data) + else -> collector.events.add(event) + } + } + + loop.startCompression(null) + + val job = launch { loop.run() } + advanceUntilIdle() + job.cancel() + + assertEquals(1, collector.received.size) + assertArrayEquals(original, collector.received[0]) + } +} + +/** Fake that yields pre-loaded chunks then signals EOF. */ +private class FakeSocketIO( + private val chunks: MutableList = mutableListOf(), +) : SocketIO { + val written = mutableListOf() + private var open = true + + override suspend fun read(): ByteArray { + if (chunks.isEmpty()) { + open = false + return ByteArray(0) + } + return chunks.removeFirst() + } + + override suspend fun write(data: ByteArray) { + written.add(data.copyOf()) + } + + override fun close() { open = false } + override val isConnected: Boolean get() = open +} + +/** Fake that throws IOException on first read. */ +private class ErrorSocketIO : SocketIO { + override suspend fun read(): ByteArray = throw IOException("connection reset") + override suspend fun write(data: ByteArray) = throw IOException("broken pipe") + override fun close() {} + override val isConnected: Boolean = true +} diff --git a/BTLib/src/test/kotlin/com/offsetnull/bt/service/net/MccpDecompressorTest.kt b/BTLib/src/test/kotlin/com/offsetnull/bt/service/net/MccpDecompressorTest.kt new file mode 100644 index 00000000..897b4600 --- /dev/null +++ b/BTLib/src/test/kotlin/com/offsetnull/bt/service/net/MccpDecompressorTest.kt @@ -0,0 +1,50 @@ +package com.offsetnull.bt.service.net + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.zip.Deflater + +class MccpDecompressorTest { + + private fun compress(input: ByteArray): ByteArray { + val deflater = Deflater() + deflater.setInput(input) + deflater.finish() + val output = ByteArray(input.size + 64) + val len = deflater.deflate(output) + deflater.end() + return output.copyOf(len) + } + + @Test + fun `decompresses valid zlib data`() { + val decompressor = MccpDecompressor() + val original = "Hello, MUD World!".toByteArray() + val compressed = compress(original) + + val result = decompressor.decompress(compressed) + + assertArrayEquals(original, result?.data) + assertNull(result?.remainder) + } + + @Test + fun `returns null on corrupt data and sets error flag`() { + val decompressor = MccpDecompressor() + val garbage = byteArrayOf(0x01, 0x02, 0x03, 0x04) + + val result = decompressor.decompress(garbage) + + assertNull(result) + assertTrue(decompressor.isCorrupted) + } + + @Test + fun `starts clean`() { + val decompressor = MccpDecompressor() + assertFalse(decompressor.isCorrupted) + } +} diff --git a/BT_Free/src/main/res/values/strings.xml b/BT_Free/src/main/res/values/strings.xml index ce62d687..437007af 100644 --- a/BT_Free/src/main/res/values/strings.xml +++ b/BT_Free/src/main/res/values/strings.xml @@ -8,6 +8,6 @@ BlowTorch com.happygoatstudios.bt BlowTorch - BlowTorch + WAMDROID BlowTorch is a mobile terminal emulator with features that aide in playing text based adventure games (MUDs). For more information on the capabilities of this client, please visit the online documentation. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85fb5593..ec7e125a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ lifecycle = "2.10.0" room = "2.8.4" coroutines = "1.10.2" datastore = "1.1.7" +junit5 = "5.12.2" [libraries] appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -35,6 +36,10 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +junit5-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +junit5-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "1.12.2" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle b/settings.gradle index 10d83ce1..158fd540 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,17 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } + } +} + +// Use local MTH checkout when available, fall back to JitPack otherwise. +def mthLocal = file('../mth/kotlin') +if (mthLocal.directory) { + includeBuild(mthLocal) { + dependencySubstitution { + substitute module('com.github.ncmud:mth') using project(':mth-core') + } } }