]> hydra-www.ietfng.org Git - acmetensortoys-ctfws-android/commitdiff
Move mobile MQTT to Service
authorNathaniel Wesley Filardo <nwf@cs.jhu.edu>
Tue, 14 Feb 2017 19:38:10 +0000 (14:38 -0500)
committerNathaniel Wesley Filardo <nwf@cs.jhu.edu>
Tue, 14 Feb 2017 19:38:10 +0000 (14:38 -0500)
While here, add initial Notification
There are almost surely bugs; I wrote this when I couldn't sleep

lib/src/main/java/com/acmetensortoys/ctfwstimer/lib/CtFwSGameState.java
mobile/build.gradle
mobile/src/main/AndroidManifest.xml
mobile/src/main/java/com/acmetensortoys/ctfwstimer/CtFwSDisplayLocal.java
mobile/src/main/java/com/acmetensortoys/ctfwstimer/MainActivity.java
mobile/src/main/java/com/acmetensortoys/ctfwstimer/MainActivityBuildHooks.java
mobile/src/main/java/com/acmetensortoys/ctfwstimer/MainService.java [new file with mode: 0644]
mobile/src/main/java/com/acmetensortoys/ctfwstimer/StringSettingDialogFragment.java
mobile/src/main/res/values/strings.xml
mobile/src/noplay/java/com/acmetensortoys/ctfwstimer/MainActivityBuildHooksImpl.java

index 2caf7b9bfa89669c5e77d663add0aa2ce59c3a28..c6f2ef127bb90ba210c9556c0a882efef93aff7f 100644 (file)
@@ -10,6 +10,17 @@ import java.util.Set;
 
 public class CtFwSGameState {
 
+    public interface TimerProvider {
+        long wallMS();
+        void postDelay(Runnable r, long delayMS);
+        void cancelPost(Runnable r);
+    }
+    private TimerProvider mT;
+
+    public CtFwSGameState (TimerProvider t) {
+        mT = t;
+    }
+
     // Game time
 
     private boolean configured = false;
@@ -39,11 +50,7 @@ public class CtFwSGameState {
                 }
                 break;
         }
-        if (!isMessageTimeWithin(lastMsgTimestamp)) {
-            msgs.clear();
-            notifyMessages();
-        }
-        notifyConfig();
+        notifyConfigEtAl();
     }
     public String toMqttConfigMessage() {
         if (!configured) {
@@ -54,11 +61,11 @@ public class CtFwSGameState {
     }
     public void deconfigure() {
         this.configured = false;
-        notifyConfig();
+        notifyConfigEtAl();
     }
     public void setEndT(long endT) {
         this.endT = endT;
-        notifyConfig();
+        notifyConfigEtAl();
     }
 
     public class Now {
@@ -66,18 +73,23 @@ public class CtFwSGameState {
         public boolean stop = false;
         public int round = 0;  // 0 for setup
         public long roundStart = 0, roundEnd = 0; // NTP seconds
+
+        public long wallMS; // timestamp at object creation: NTP time * 1000 (i.e. msec)
     }
-    public Now getNow(long now) {
+    public Now getNow(long wallMS) {
         Now res = new Now();
+        res.wallMS = wallMS;
+
+        long now = wallMS/1000;
         if (!configured) {
-            res.rationale = "Game not configured";
+            res.rationale = "Game not configured!";
             res.stop = true;
         } else if (endT >= startT) {
             res.rationale = "Game over!";
             res.stop = true;
-        } else if (now <= startT) {
+        } else if (now < startT) {
             res.rationale = "Start time in the future!";
-            res.roundStart = startT;
+            res.roundStart = res.roundEnd = startT;
         }
         if (res.rationale != null) {
             return res;
@@ -173,7 +185,7 @@ public class CtFwSGameState {
         } catch (NoSuchElementException nse) {
             // Maybe they forgot a time stamp.  That's not ideal, but... fake it?
             // XXX Back off a bit, for time sync reasons
-            lastMsgTimestamp = System.currentTimeMillis()/1000 - 30;
+            lastMsgTimestamp = mT.wallMS()/1000 - 30;
             msgs.add(new Msg(lastMsgTimestamp, str));
             notifyMessages();
             return;
@@ -193,9 +205,9 @@ public class CtFwSGameState {
 
     public interface Observer {
         void onCtFwSConfigure(CtFwSGameState game);
+        void onCtFwSNow(CtFwSGameState game, Now now);
         void onCtFwSFlags(CtFwSGameState game);
         void onCtFwSMessage(CtFwSGameState game, List<Msg> msgs);
-
     }
     final private Set<Observer> mObsvs = new HashSet<>();
     private void notifyFlags() {
@@ -208,10 +220,33 @@ public class CtFwSGameState {
             for (Observer o : mObsvs) { o.onCtFwSMessage(this, msgs); }
         }
     }
-    private void notifyConfig() {
+    private void notifyConfigEtAl() {
+        if (!isMessageTimeWithin(lastMsgTimestamp)) {
+            msgs.clear();
+            notifyMessages();
+        }
         synchronized(this) {
             for (Observer o : mObsvs) { o.onCtFwSConfigure(this); }
         }
+        notifyNow();
+    }
+    private final Runnable futureNotifyNow = new Runnable() {
+        @Override
+        public void run() {
+            notifyNow();
+        }
+    };
+    private void notifyNow() {
+        mT.cancelPost(futureNotifyNow);
+        Now n = getNow(mT.wallMS());
+        synchronized(this) {
+            for (Observer o : mObsvs) {
+                o.onCtFwSNow(this, n);
+            }
+            if (n.rationale == null || !n.stop) {
+                mT.postDelay(futureNotifyNow, n.roundEnd*1000 - n.wallMS);
+            }
+        }
     }
     public void registerObserver(Observer d) {
         synchronized(this) { mObsvs.add(d); }
@@ -219,5 +254,4 @@ public class CtFwSGameState {
     public void unregisterObserver(Observer d) {
         synchronized(this) { mObsvs.remove(d); }
     }
-
 }
index 7a763c9e0e00ca7d8aca5e28c56dff724a358087..a8103c59b4a76f25bec7aa902e3810099ffd38ed 100644 (file)
@@ -7,7 +7,7 @@ android {
         applicationId "com.acmetensortoys.ctfwstimer"
         minSdkVersion 19
         targetSdkVersion 25
-        versionCode 1
+        versionCode 3
         versionName "1.0"
         testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
     }
@@ -31,6 +31,9 @@ android {
             dimension "play"
         }
     }
+
+    // TODO: Should filter out play+debug since it will overflow dex method
+    // limits (yikes)
 }
 
 repositories {
@@ -51,6 +54,7 @@ dependencies {
     compile 'com.android.support:appcompat-v7:25.1.1'
     compile 'com.android.support:support-v4:25.1.1'
 
+    // Needed for wear data synchronization; blech
     playCompile 'com.google.android.gms:play-services:10.0.1'
 
     compile 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.1.1-SNAPSHOT'
index b23d07bd9027e023252d4e0c181fa073659e44a8..50aa44732aa0d398cd049bffeca106491737286f 100644 (file)
@@ -5,11 +5,11 @@
     <!-- Required for Paho MQTT client -->
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.INTERNET" />
-    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
 
-    <!-- Seems like a reasonable idea? -->
-    <!--     <uses-permission android:name="android.permission.VIBRATE" /> -->
+    <!-- Seems like a reasonable idea -->
+    <uses-permission android:name="android.permission.VIBRATE" />
+
     <application
         android:allowBackup="true"
         android:icon="@mipmap/ic_shield_1"
 
         <service android:name="org.eclipse.paho.android.service.MqttService" />
 
-        <activity android:name=".AboutActivity"></activity>
+        <activity android:name=".AboutActivity" />
+
+        <service
+            android:name=".MainService"
+            android:enabled="true"
+            android:exported="false"></service>
     </application>
 
 </manifest>
\ No newline at end of file
index a89eabf26ea15185a86b637639b0ad4440e37c65..b19e454688367b8e3868f6fe294cdcb404ecc4fb 100644 (file)
@@ -27,51 +27,25 @@ class CtFwSDisplayLocal implements CtFwSGameState.Observer {
     }
 
     private Runnable mProber;
-
     @Override
     public void onCtFwSConfigure(final CtFwSGameState gs) {
-        final long nowMS = System.currentTimeMillis();
-        long nowET = SystemClock.elapsedRealtime(); // Chronometer timebase
-        final long tbcf = nowMS - nowET; // time base correction factor ("when we booted"-ish)
-
-        final CtFwSGameState.Now now = gs.getNow(nowMS / 1000);
-        final Runnable prober = new Runnable() {
-            @Override
-            public void run() {
-                onCtFwSConfigure(gs);
-            }
-        };
+    }
 
+    @Override
+    public void onCtFwSNow(final CtFwSGameState gs, final CtFwSGameState.Now now) {
+        // time base correction factor ("when we booted"-ish)
+        final long tbcf = System.currentTimeMillis() - SystemClock.elapsedRealtime();
 
-        Log.d("CtFwS", "Display game state; nowMS=" + nowMS + " r=" + now.round + " rs=" + now.roundStart + " re=" + now.roundEnd);
+        Log.d("CtFwS", "Display game state; nowMS=" + now.wallMS + " r=" + now.round + " rs=" + now.roundStart + " re=" + now.roundEnd);
 
         if (now.rationale != null) {
             Log.d("CtFwS", "Rationale: " + now.rationale + " stop=" + now.stop);
-
             // TODO: display rationale somewhere, probably by hiding the game state!
-
             doReset();
-
-            if (mProber != null) {
-                mHandler.removeCallbacks(mProber);
-            }
-            if (!now.stop) {
-                mProber = prober;
-                mHandler.postDelayed(mProber, now.roundStart*1000 - nowMS);
-            }
-
             return;
         }
-
         // Otherwise, it's game on!
 
-        // Schedule a callback around the time of the next round; if we're early,
-        // that's fine, we'll schedule it again.  If we're late, it'll be glitchy,
-        // but that's fine.
-        mHandler.removeCallbacks(mProber);
-        mProber = prober;
-        mHandler.postDelayed(mProber, now.roundEnd * 1000 - nowMS);
-
         {
             final TextView tv_jb = (TextView) (mAct.findViewById(R.id.tv_jailbreak));
             tv_jb.post(new Runnable() {
@@ -84,7 +58,7 @@ class CtFwSDisplayLocal implements CtFwSGameState.Observer {
                     } else {
                         tv_jb.setText(
                                 String.format(mAct.getResources().getString(R.string.ctfws_jailbreak),
-                                now.round));
+                                now.round, gs.getRounds() - 1));
                     }
                 }
             });
index 277ce4212cb5d807cbab2a1620af84bdc22493a4..190dc05935cb8762acd97962004e5cedfbcd830e 100644 (file)
 package com.acmetensortoys.ctfwstimer;
 
 import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
 import android.content.Intent;
+import android.content.ServiceConnection;
 import android.content.SharedPreferences;
 import android.os.Handler;
-import android.support.annotation.Nullable;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
 import android.support.annotation.StringRes;
 import android.support.v4.app.DialogFragment;
 import android.support.v7.app.AppCompatActivity;
 import android.os.Bundle;
-import android.util.Log;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.TextView;
 
-import org.eclipse.paho.android.service.MqttAndroidClient;
-import org.eclipse.paho.client.mqttv3.IMqttActionListener;
-import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
-import org.eclipse.paho.client.mqttv3.IMqttToken;
-import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
-import org.eclipse.paho.client.mqttv3.MqttClient;
-import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
-import org.eclipse.paho.client.mqttv3.MqttException;
-import org.eclipse.paho.client.mqttv3.MqttMessage;
-
-import com.acmetensortoys.ctfwstimer.lib.CtFwSGameState;
-
 public class MainActivity extends AppCompatActivity {
 
-    private MqttAndroidClient mMqc;
-
-    private final CtFwSGameState mCgs = new CtFwSGameState();
-    private CtFwSCallbacksMQTT mCtfwscbs ; // set in onCreate
+    // TODO surely this belongs somewhere else
+    private static final String defserver = "tcp://ctfws-mqtt.ietfng.org:1883";
 
     private MainActivityBuildHooks mabh = new MainActivityBuildHooksImpl();
 
-    private TextView mTvSU; // set in onCreate
-    private TextView mTvSS; // set in onCreate
-    private void setServerStateText(@StringRes final int resid) {
-        mTvSS.post(new Runnable() {
-            @Override
-            public void run() { mTvSS.setText(resid); }
-        });
-    }
-
-    /*
-    // Trace MQTT state
-    private final MqttTraceHandler mqttth = new MqttTraceHandler() {
-        @Override
-        public void traceDebug(String tag, String message) {
-            Log.d("CtFwSMqtt:"+tag,message);
-        }
-
-        @Override
-        public void traceError(String tag, String message) {
-            Log.e("CtFwSMqtt:"+tag,message);
-        }
-
-        @Override
-        public void traceException(String tag, String message, Exception e) {
-            Log.e("CtFwSMqtt:"+tag,message,e);
-        }
-    };
-    */
-    // We'll use this common callback object for our subscriptions below
-    private final IMqttActionListener subal = new IMqttActionListener() {
-        @Override
-        public void onSuccess(IMqttToken asyncActionToken) {
-            Log.d("CtFwS", "Sub OK: " + asyncActionToken);
-        }
-
-        @Override
-        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
-            Log.e("CtFws", "Sub Fail: " + asyncActionToken, exception);
-        }
-    };
-    // And this handles making our subscriptions for us
-    private final MqttCallbackExtended mqttcb = new MqttCallbackExtended() {
-        @Override
-        public void connectComplete(boolean reconnect, String serverURI) {
-            Log.d("CtFwS", "Conn OK 2 srv=" + serverURI + " reconn=" + reconnect);
-            try {
-                String p = "ctfws/game/";
-                mMqc.subscribe(p+"config"        , 2, null, subal, mCtfwscbs.onConfig);
-                mMqc.subscribe(p+"endtime"       , 2, null, subal, mCtfwscbs.onEnd);
-                mMqc.subscribe(p+"flags"         , 2, null, subal, mCtfwscbs.onFlags);
-                mMqc.subscribe(p+"message"       , 2, null, subal, mCtfwscbs.onMessage);
-                mMqc.subscribe(p+"message/player", 2, null, subal, mCtfwscbs.onPlayerMessage);
-                setServerStateText(R.string.mqtt_subbed);
-            } catch (MqttException e) {
-                Log.e("CtFwS", "Exn Sub", e);
-            }
-        }
-
-        @Override
-        public void connectionLost(Throwable cause) {
-            Log.d("CtFwS", "Conn Lost: " + cause, cause);
-            setServerStateText(R.string.mqtt_disconn);
-
-        }
-
-        @Override
-        public void messageArrived(String topic, MqttMessage message) throws Exception {
-            Log.d("CtFwS", "Message(Generic) " + topic + " : '" + message + "'" );
-        }
-
-        @Override
-        public void deliveryComplete(IMqttDeliveryToken token) {
-            // Unused, as we never publish
-            Log.d("CtFwS", "Delivery OK");
-        }
-    };
-    // And this handles yet more about connecting
-    private final IMqttActionListener mqttal = new IMqttActionListener() {
-        @Override
-        public void onSuccess(IMqttToken asyncActionToken) {
-            Log.d("CtFwS", "Conn OK 1");
-            setServerStateText(R.string.mqtt_conn);
-        }
-
+    private MainService.LocalBinder mSrvBinder; // set once connection completed
+    private MainService.Observer mSrvObs = new MainService.Observer() {
         @Override
-        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
-            Log.e("CtFws", "Conn Fail", exception);
-            setServerStateText(R.string.mqtt_disconn);
-        }
-    };
-
-    private synchronized void doMqtt(@Nullable String server) {
-        // Hang up on an existing connection, if we have one
-        synchronized (this) {
-            if (mMqc != null) {
-                if (mMqc.isConnected()) {
-                    try {
-                        mMqc.disconnect();
-                        Log.d("CtFwS", "domqtt disconnected");
-                    } catch (MqttException me) {
-                        Log.e("CtFwS", "domqtt disconn exn", me);
+        public void onMqttServerChanged(MainService.LocalBinder b, final String sURL) {
+            mTvSU.post(new Runnable() {
+                @Override
+                public void run() {
+                    if (sURL == null) {
+                        mTvSU.setText(R.string.string_null);
+                    } else {
+                        mTvSU.setText(sURL);
                     }
                 }
-            }
-            mMqc = null;
-            mCgs.deconfigure();
-        }
-
-        // If that's all we were told to do, we're done
-        if (server == null) {
-            mTvSU.setText(R.string.string_null);
-            return;
+            });
         }
 
-        mTvSU.setText(server);
-
-        // Make our MQTT client and grab callbacks on *everything in sight*
-        //
-        // XXX For reasons beyond my understanding, we have to use a new client ID every time
-        // or we won't resubscribe.  I think this is github issue eclipse/paho.mqtt.android#170
-        // but heavens only knows.  Whatever, this works for the moment and doesn't leave
-        // stragglers on my server as far as I can tell.
-        MqttAndroidClient mqc = new MqttAndroidClient(this,server,MqttClient.generateClientId());
-        mqc.setCallback(mqttcb);
-        /*
-        // Debugging aid: trace the paho client internals
-        mqc.setTraceCallback(mqttth);
-        mqc.setTraceEnabled(true);
-        */
-
-        // Ahem.  Now then.  Connect with *more callbacks*, which will fire off our
-        // subscription requests, which of course have *yet more* callbacks, which
-        // react to messages sent to us.  Have we lost the thread yet?
-        try {
-            MqttConnectOptions mco = new MqttConnectOptions();
-            mco.setCleanSession(true);
-            mco.setAutomaticReconnect(true);
-            mco.setKeepAliveInterval(180); // seconds
-            synchronized (this) {
-                if (BuildConfig.DEBUG && mMqc != null) { throw new AssertionError(); }
-                mMqc = mqc;
-            }
-            mqc.connect(mco, null, mqttal);
-            Log.d("CtFwS", "Connect dispatched");
-        } catch (MqttException e) {
-            Log.e("CtFwS", "Conn Exn", e);
-        }
-    }
-
-    // Must hold strongly since Android only holds weakly once registered.
-    private final SharedPreferences.OnSharedPreferenceChangeListener mOSPCL
-            = new SharedPreferences.OnSharedPreferenceChangeListener() {
         @Override
-        public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
-            switch(key) {
-                case "server":
-                    String s = sharedPreferences.getString(key,null);
-                    if (s != null) { doMqtt(s); }
-                    break;
+        public void onMqttServerEvent(MainService.LocalBinder b, MainService.MqttServerEvent mse) {
+            switch(mse) {
+                case MSE_CONN: setServerStateText(R.string.mqtt_conn); break;
+                case MSE_DISCONN: setServerStateText(R.string.mqtt_disconn); break;
+                case MSE_SUB: setServerStateText(R.string.mqtt_subbed);
             }
         }
     };
 
+    private CtFwSDisplayLocal mCdl; // set in onStart
+    private TextView mTvSU; // set in onStart
+    private TextView mTvSS; // set in onStart
+    private void setServerStateText(@StringRes final int resid) {
+        mTvSS.post(new Runnable() {
+            @Override
+            public void run() { mTvSS.setText(resid); }
+        });
+    }
+
     @Override
     protected void onCreate(Bundle savedInstanceState) {
-        String defserver = "tcp://ctfws-mqtt.ietfng.org:1883";
-
         super.onCreate(savedInstanceState);
         setContentView(R.layout.activity_main);
 
-        mTvSU = (TextView) findViewById(R.id.tv_mqtt_server_uri);
-        mTvSS = (TextView) findViewById(R.id.tv_mqtt_state);
-
-        CtFwSDisplayLocal mCdl = new CtFwSDisplayLocal(this, new Handler());
-        mCgs.registerObserver(mCdl);
-
-        mabh.onCreate(mCgs);
-
-        mCtfwscbs = new CtFwSCallbacksMQTT(mCgs);
-
-        SharedPreferences sp = getPreferences(MODE_PRIVATE);
+        SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
         if (sp.getString("server", null) == null) {
             sp.edit().putString("server", defserver).apply();
         }
@@ -225,10 +75,43 @@ public class MainActivity extends AppCompatActivity {
             throw new AssertionError("Shared Preferences not sticking!");
         }
 
-        synchronized(this) {
-            sp.registerOnSharedPreferenceChangeListener(mOSPCL);
-            doMqtt(sp.getString("server", defserver));
+        mTvSU = (TextView) findViewById(R.id.tv_mqtt_server_uri);
+        mTvSS = (TextView) findViewById(R.id.tv_mqtt_state);
+
+        mCdl = new CtFwSDisplayLocal(this, new Handler());
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+
+        bindService(new Intent(this, MainService.class), new ServiceConnection() {
+            @Override
+            public void onServiceConnected(ComponentName name, IBinder service) {
+                mSrvBinder = (MainService.LocalBinder) service;
+                mSrvBinder.getGameState().registerObserver(mCdl);
+                mSrvBinder.registerObserver(mSrvObs);
+                // Fake an initial server event so we draw some text
+                mSrvObs.onMqttServerEvent(mSrvBinder, mSrvBinder.getServerState());
+                mabh.onStart(MainActivity.this, mSrvBinder);
+                mSrvBinder.connect(false);
+            }
+
+            @Override
+            public void onServiceDisconnected(ComponentName name) {
+                mSrvBinder = null;
+            }
+        }, Context.BIND_AUTO_CREATE);
+    }
+
+    @Override
+    protected void onStop() {
+        if (mSrvBinder != null) {
+            mSrvBinder.getGameState().unregisterObserver(mCdl);
+            mSrvBinder.unregisterObserver(mSrvObs);
         }
+
+        super.onStop();
     }
 
     // Every good application needs an easter egg
@@ -246,7 +129,7 @@ public class MainActivity extends AppCompatActivity {
 
     // Kick the mqtt layer on a click on the status stuff
     public void onclick_connmeta(View v) {
-        doMqtt(getPreferences(MODE_PRIVATE).getString("server",null));
+        mSrvBinder.connect(true);
     }
 
     // TODO should we be using onClick instead for routing?
@@ -256,7 +139,7 @@ public class MainActivity extends AppCompatActivity {
             case R.id.menu_mqtt :
                 DialogFragment d =
                         StringSettingDialogFragment.newInstance(
-                                R.layout.server_dialog, R.id.server_text, "server");
+                                R.layout.server_dialog, R.id.server_text, "server", defserver);
                 d.show(getSupportFragmentManager(),"serverdialog");
                 return true;
             case R.id.menu_about :
index 63bea1738393de8b74a5126793f71e829c8fb063..8d8098ab0885fd609c7028a81af0a7df3e1c8350 100644 (file)
@@ -6,5 +6,5 @@ import com.acmetensortoys.ctfwstimer.lib.CtFwSGameState;
 // per build flavor.  This will be used when, for example, we kick on Google Play for Wear
 // interaction and want to push messages out to the wearable data network.
 interface MainActivityBuildHooks {
-    void onCreate(CtFwSGameState cgs);
+    void onStart(MainActivity ma, MainService.LocalBinder b);
 }
diff --git a/mobile/src/main/java/com/acmetensortoys/ctfwstimer/MainService.java b/mobile/src/main/java/com/acmetensortoys/ctfwstimer/MainService.java
new file mode 100644 (file)
index 0000000..467ac8d
--- /dev/null
@@ -0,0 +1,323 @@
+package com.acmetensortoys.ctfwstimer;
+
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
+import android.support.annotation.Nullable;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+
+import com.acmetensortoys.ctfwstimer.lib.CtFwSGameState;
+
+import org.eclipse.paho.android.service.MqttAndroidClient;
+import org.eclipse.paho.android.service.MqttTraceHandler;
+import org.eclipse.paho.client.mqttv3.IMqttActionListener;
+import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
+import org.eclipse.paho.client.mqttv3.IMqttToken;
+import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.MqttMessage;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class MainService extends Service {
+    // Android stuff
+    private static final int NOTE_ID_USER = 1;
+    private Handler mHandler; // set in OnCreate
+
+    // The reason we're here!
+    private final CtFwSGameState mCgs
+            = new CtFwSGameState(new CtFwSGameState.TimerProvider() {
+        @Override
+        public long wallMS() {
+            return System.currentTimeMillis();
+        }
+
+        @Override
+        public void postDelay(Runnable r, long delayMS) {
+            mHandler.postDelayed(r, delayMS);
+        }
+
+        @Override
+        public void cancelPost(Runnable r) {
+            mHandler.removeCallbacks(r);
+        }
+    });
+    private CtFwSCallbacksMQTT mCtfwscbs = new CtFwSCallbacksMQTT(mCgs);
+
+    public MainService() {
+        mCgs.registerObserver(mCgsObserver);
+    }
+
+    // MQTT client management
+
+    private MqttAndroidClient mMqc;
+    // Trace MQTT state
+    private final MqttTraceHandler mqttth = new MqttTraceHandler() {
+        @Override
+        public void traceDebug(String tag, String message) {
+            Log.d("CtFwSMqtt:" + tag, message);
+        }
+
+        @Override
+        public void traceError(String tag, String message) {
+            Log.e("CtFwSMqtt:" + tag, message);
+        }
+
+        @Override
+        public void traceException(String tag, String message, Exception e) {
+            Log.e("CtFwSMqtt:" + tag, message, e);
+        }
+    };
+
+    // We'll use this common callback object for our subscriptions below
+    private final IMqttActionListener subal = new IMqttActionListener() {
+        @Override
+        public void onSuccess(IMqttToken asyncActionToken) {
+            Log.d("CtFwS", "Sub OK: " + asyncActionToken);
+        }
+
+        @Override
+        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
+            Log.e("CtFws", "Sub Fail: " + asyncActionToken, exception);
+            setMSE(MqttServerEvent.MSE_CONN);
+        }
+    };
+    // And this handles making our subscriptions for us
+    private final MqttCallbackExtended mqttcb = new MqttCallbackExtended() {
+        @Override
+        public void connectComplete(boolean reconnect, String serverURI) {
+            Log.d("CtFwS", "Conn OK 2 srv=" + serverURI + " reconn=" + reconnect);
+            try {
+                String p = "ctfws/game/";
+                mMqc.subscribe(p+"config"        , 2, null, subal, mCtfwscbs.onConfig);
+                mMqc.subscribe(p+"endtime"       , 2, null, subal, mCtfwscbs.onEnd);
+                mMqc.subscribe(p+"flags"         , 2, null, subal, mCtfwscbs.onFlags);
+                mMqc.subscribe(p+"message"       , 2, null, subal, mCtfwscbs.onMessage);
+                mMqc.subscribe(p+"message/player", 2, null, subal, mCtfwscbs.onPlayerMessage);
+                setMSE(MqttServerEvent.MSE_SUB);
+            } catch (MqttException e) {
+                Log.e("CtFwS", "Exn Sub", e);
+            }
+        }
+
+        @Override
+        public void connectionLost(Throwable cause) {
+            Log.d("CtFwS", "Conn Lost: " + cause, cause);
+            setMSE(MqttServerEvent.MSE_DISCONN);
+
+        }
+
+        @Override
+        public void messageArrived(String topic, MqttMessage message) throws Exception {
+            Log.d("CtFwS", "Message(Generic) " + topic + " : '" + message + "'" );
+        }
+
+        @Override
+        public void deliveryComplete(IMqttDeliveryToken token) {
+            // Unused, as we never publish
+            Log.d("CtFwS", "Delivery OK");
+        }
+    };
+    // And this handles yet more about connecting
+    private final IMqttActionListener mqttal = new IMqttActionListener() {
+        @Override
+        public void onSuccess(IMqttToken asyncActionToken) {
+            Log.d("CtFwS", "Conn OK 1");
+            setMSE(MqttServerEvent.MSE_CONN);
+        }
+
+        @Override
+        public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
+            Log.e("CtFws", "Conn Fail", exception);
+            setMSE(MqttServerEvent.MSE_DISCONN);
+        }
+    };
+
+    private synchronized void doMqtt(@Nullable String server) {
+        // Hang up on an existing connection, if we have one
+        synchronized (this) {
+            if (mMqc != null) {
+                if (mMqc.isConnected()) {
+                    try {
+                        mMqc.disconnect();
+                        Log.d("CtFwS", "domqtt disconnected");
+                    } catch (MqttException me) {
+                        Log.e("CtFwS", "domqtt disconn exn", me);
+                    }
+                }
+            }
+            mMqc = null;
+        }
+
+        // If we're deliberately disconnecting, tell the service about it.  Otherwise, we'll
+        // just keep doing what we're doing until we get some message telling us to do something
+        // else. :)
+        if (server == null) {
+            mCgs.deconfigure();
+        }
+
+        notifyServerChanged(server);
+
+        // If disconnecting is all we were told to do, we're done.
+        if (server == null) {
+            return;
+        }
+
+        // Make our MQTT client and grab callbacks on *everything in sight*
+        //
+        // XXX For reasons beyond my understanding, we have to use a new client ID every time
+        // or we won't resubscribe.  I think this is github issue eclipse/paho.mqtt.android#170
+        // but heavens only knows.  Whatever, this works for the moment and doesn't leave
+        // stragglers on my server as far as I can tell.
+        MqttAndroidClient mqc = new MqttAndroidClient(this,server, MqttClient.generateClientId());
+        mqc.setCallback(mqttcb);
+        /*
+        // Debugging aid: trace the paho client internals
+        mqc.setTraceCallback(mqttth);
+        mqc.setTraceEnabled(true);
+        */
+
+        // Ahem.  Now then.  Connect with *more callbacks*, which will fire off our
+        // subscription requests, which of course have *yet more* callbacks, which
+        // react to messages sent to us.  Have we lost the thread yet?
+        try {
+            MqttConnectOptions mco = new MqttConnectOptions();
+            mco.setCleanSession(true);
+            mco.setAutomaticReconnect(true);
+            mco.setKeepAliveInterval(180); // seconds
+            synchronized (this) {
+                if (BuildConfig.DEBUG && mMqc != null) { throw new AssertionError(); }
+                mMqc = mqc;
+            }
+            mqc.connect(mco, null, mqttal);
+            Log.d("CtFwS", "Connect dispatched");
+        } catch (MqttException e) {
+            Log.e("CtFwS", "Conn Exn", e);
+        }
+    }
+
+    // Must hold strongly since Android only holds weakly once registered.
+    private final SharedPreferences.OnSharedPreferenceChangeListener mOSPCL
+            = new SharedPreferences.OnSharedPreferenceChangeListener() {
+        @Override
+        public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+            switch(key) { case "server": doMqtt(sharedPreferences.getString(key,null)); break; }
+        }
+    };
+
+    // MQTT Observers
+    public enum MqttServerEvent {
+        MSE_DISCONN,
+        MSE_CONN,
+        MSE_SUB,
+    }
+    private MqttServerEvent mMSE = MqttServerEvent.MSE_DISCONN;
+    public interface Observer {
+        void onMqttServerChanged(LocalBinder b, String sURL);
+        void onMqttServerEvent(LocalBinder b, MqttServerEvent mse);
+    }
+    private final Set<Observer> mObsvs = new HashSet<>();
+    private void setMSE(MqttServerEvent mse) {
+        synchronized(this) {
+            mMSE = mse;
+            for (Observer o : mObsvs) { o.onMqttServerEvent(mBinder, mse); }
+        }
+    }
+    private void notifyServerChanged(String sURL) {
+        synchronized(this) {
+            for (Observer o : mObsvs) { o.onMqttServerChanged(mBinder, sURL); }
+        }
+    }
+
+    // User-facing notification
+    // TODO Move to its own display module?
+    private NotificationCompat.Builder userNoteBuilder;
+    private CtFwSGameState.Observer mCgsObserver = new CtFwSGameState.Observer() {
+        @Override
+        public void onCtFwSConfigure(CtFwSGameState game) { }
+
+        @Override
+        public void onCtFwSNow(CtFwSGameState game, CtFwSGameState.Now now) {
+            userNoteBuilder.setWhen((now.roundEnd+1)*1000);
+            userNoteBuilder.setUsesChronometer(true);
+            if (now.rationale == null || !now.stop) {
+                // game is afoot!
+                userNoteBuilder.setContentTitle(
+                        now.rationale == null ? "Game is afoot!" : now.rationale);
+                userNoteBuilder.setContentText(
+                        now.round == 0 ? "Setup phase" : ("Round " + now.round));
+                startForeground(NOTE_ID_USER, userNoteBuilder.build());
+            } else {
+                // game no longer afoot
+                stopForeground(true);
+            }
+        }
+
+        @Override
+        public void onCtFwSFlags(CtFwSGameState game) { }
+
+        @Override
+        public void onCtFwSMessage(CtFwSGameState game, List<CtFwSGameState.Msg> msgs) { }
+    };
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+
+        mHandler = new Handler();
+
+
+        userNoteBuilder = new NotificationCompat.Builder(MainService.this)
+                .setSmallIcon(R.drawable.shield1)
+                .setContentIntent(PendingIntent.getActivity(MainService.this, 0,
+                        new Intent(MainService.this, MainActivity.class), 0));
+
+        SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
+        synchronized(this) {
+            sp.registerOnSharedPreferenceChangeListener(mOSPCL);
+            doMqtt(sp.getString("server", null));
+        }
+    }
+
+    public class LocalBinder extends Binder {
+        CtFwSGameState getGameState() {
+            return mCgs;
+        }
+        MqttServerEvent getServerState() {
+            return mMSE;
+        }
+
+        // It should not be necessary to call this execpt at the beginning or to force a reconnect;
+        // most everything else you might want in a connect method is handled by the
+        // OnSharedPreferenceChangeListener listener above.
+        void connect(boolean force) {
+            if (force || mMSE != MqttServerEvent.MSE_CONN) {
+                doMqtt(PreferenceManager
+                        .getDefaultSharedPreferences(MainService.this)
+                        .getString("server", null));
+            }
+        }
+        void registerObserver(Observer o) {
+            synchronized(this) { mObsvs.add(o); }
+        }
+        void unregisterObserver(Observer o) {
+            synchronized(this) { mObsvs.remove(o); }
+        }
+    }
+    private LocalBinder mBinder = new LocalBinder();
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return mBinder;
+    }
+}
index 08b049d80e6a3279e4f5ad241923d287d76884a2..bf17eb566b82c1951aec4ecfeb2dc9f7015eb48b 100644 (file)
@@ -6,6 +6,7 @@ import android.content.Context;
 import android.content.DialogInterface;
 import android.content.SharedPreferences;
 import android.os.Bundle;
+import android.preference.PreferenceManager;
 import android.support.annotation.NonNull;
 import android.support.v4.app.DialogFragment;
 import android.view.LayoutInflater;
@@ -18,15 +19,17 @@ public class StringSettingDialogFragment extends DialogFragment
     private final static String ARG_LRES_IX = "lres"; // layout id
     private final static String ARG_VRES_IX = "vres"; // text view id
     private final static String ARG_PREF_IX = "pref"; // preference name
+    private final static String ARG_DEFL_IX = "def";   // optional default
 
     private TextView mTv;
 
-    public static StringSettingDialogFragment newInstance(int lres, int vres, String pref) {
+    public static StringSettingDialogFragment newInstance(int lres, int vres, String pref, String def) {
         StringSettingDialogFragment ssdf = new StringSettingDialogFragment();
         Bundle args = new Bundle();
         args.putInt   (ARG_LRES_IX, lres);
         args.putInt   (ARG_VRES_IX, vres);
         args.putString(ARG_PREF_IX, pref);
+        if (def != null) { args.putString(ARG_DEFL_IX, def); }
         ssdf.setArguments(args);
         return ssdf;
     }
@@ -39,7 +42,7 @@ public class StringSettingDialogFragment extends DialogFragment
         View v = li.inflate(a.getInt(ARG_LRES_IX), null);
 
         mTv = (TextView)v.findViewById(a.getInt(ARG_VRES_IX));
-        final SharedPreferences sp = getActivity().getPreferences(Context.MODE_PRIVATE);
+        final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getActivity());
         sp.registerOnSharedPreferenceChangeListener(this);
         onSharedPreferenceChanged(sp,a.getString(ARG_PREF_IX));
 
@@ -56,13 +59,34 @@ public class StringSettingDialogFragment extends DialogFragment
                         // NOP
                     }
                 });
+
+        final String def = a.getString(ARG_DEFL_IX);
+        if (def != null) {
+            adb.setNeutralButton(R.string.dialog_reset, new DialogInterface.OnClickListener() {
+                @Override
+                public void onClick(DialogInterface dialog, int which) {
+                    mTv.post(new Runnable() {
+                        @Override
+                        public void run() {
+                            mTv.setText(def);
+                        }
+                    });
+                }
+            });
+        }
+
         return adb.create();
     }
 
     @Override
-    public void onSharedPreferenceChanged(SharedPreferences sp, String p) {
+    public void onSharedPreferenceChanged(final SharedPreferences sp, final String p) {
         if (p != null && getArguments().getString(ARG_PREF_IX).equals(p)) {
-            mTv.setText(sp.getString(p, ""));
+            mTv.post(new Runnable() {
+                @Override
+                public void run() {
+                    mTv.setText(sp.getString(p, ""));
+                }
+            });
         }
     }
 }
index e9bf3dca9820f013270a1e1282b323b7dd7d604b..e53e1476e9e46f009031a35d4cfd40ba0535b890 100644 (file)
@@ -3,7 +3,7 @@
 
     <string name="ctfws_gameend">Game\nEnd</string>
     <string name="ctfws_gamestart">Game\nStart</string>
-    <string name="ctfws_jailbreak">Jailbreak %1$d</string>
+    <string name="ctfws_jailbreak">Jailbreak\n%1$d of %2$d</string>
     <string name="ctfws_flags">%1$d Flags:</string>
 
     <string name="header_gamestate">Game State:</string>
@@ -12,6 +12,7 @@
 
     <string name="dialog_ok">OK</string>
     <string name="dialog_cancel">Cancel</string>
+    <string name="dialog_reset">Default</string>
 
     <string name="menutext_about">About</string>
     <string name="menutext_mqtt">Set MQTT Server</string>
index 88b0d04764821511d79c9c6ff8ec25705235378b..6fe3d90142722dd0733cc138dab3108ef299763c 100644 (file)
@@ -1,10 +1,8 @@
 package com.acmetensortoys.ctfwstimer;
 
-import com.acmetensortoys.ctfwstimer.lib.CtFwSGameState;
-
-public class MainActivityBuildHooksImpl implements MainActivityBuildHooks {
+class MainActivityBuildHooksImpl implements MainActivityBuildHooks {
     @Override
-    public void onCreate(CtFwSGameState cgs) {
-        // No-op
+    public void onStart(MainActivity ma, MainService.LocalBinder b) {
+        // NOP
     }
-}
\ No newline at end of file
+}