From 0153928bcd54f5c59ec967004131ad1562391f3b Mon Sep 17 00:00:00 2001 From: Eric House Date: Mon, 23 Dec 2019 08:45:55 -0800 Subject: [PATCH] support sending invites and moves via NFC Use low-level NFC, a combination of emulated card and reader mode, to work around Google's removal of "beaming" support from Android 10. App emulates a card by declaring support in its AndroidManifest. When a game is open that has data to send, it goes periodically into read mode. If two devices are touched while one is in read mode and the other isn't, they handshake and open a connection that should last until they're separated. The devices loop, sending messages back and forth with or without data (as available.) --- xwords4/android/app/build.gradle | 23 +- .../android/app/src/main/AndroidManifest.xml | 17 +- .../android/app/src/main/assets/changes.html | 2 +- .../org/eehouse/android/xw4/BTService.java | 4 +- .../eehouse/android/xw4/BoardDelegate.java | 107 +-- .../eehouse/android/xw4/CommsTransport.java | 5 +- .../android/xw4/ConnStatusHandler.java | 15 +- .../org/eehouse/android/xw4/DbgUtils.java | 20 +- .../java/org/eehouse/android/xw4/DevID.java | 19 + .../org/eehouse/android/xw4/GameListItem.java | 2 +- .../android/xw4/GamesListDelegate.java | 13 +- .../org/eehouse/android/xw4/MultiMsgSink.java | 9 +- .../org/eehouse/android/xw4/NBSProto.java | 6 +- .../eehouse/android/xw4/NFCCardService.java | 795 ++++++++++++++++++ .../org/eehouse/android/xw4/NFCUtils.java | 455 +++++++--- .../eehouse/android/xw4/NetLaunchInfo.java | 1 + .../org/eehouse/android/xw4/RelayService.java | 6 +- .../java/org/eehouse/android/xw4/Utils.java | 34 + .../org/eehouse/android/xw4/WiDirService.java | 6 +- .../eehouse/android/xw4/jni/CommsAddrRec.java | 2 + .../app/src/main/res/values/common_rsrc.xml | 1 - .../app/src/main/res/values/strings.xml | 4 +- .../app/src/main/res/xml/apduservice.xml | 11 + xwords4/common/comms.c | 17 +- 24 files changed, 1332 insertions(+), 242 deletions(-) create mode 100644 xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCCardService.java create mode 100644 xwords4/android/app/src/main/res/xml/apduservice.xml diff --git a/xwords4/android/app/build.gradle b/xwords4/android/app/build.gradle index b74c4495a..0e701a83f 100644 --- a/xwords4/android/app/build.gradle +++ b/xwords4/android/app/build.gradle @@ -4,6 +4,10 @@ def VERSION_NAME = '4.4.150' def FABRIC_API_KEY = System.getenv("FABRIC_API_KEY") def BUILD_INFO_NAME = "build-info.txt" +// AID must start with F (first 4 bits) and be at from 5 to 16 bytes long +def NFC_AID_XW4 = "FC8FF510B360" +def NFC_AID_XW4d = "FDDA0A3EB5E5" + boolean forFDroid = hasProperty('forFDroid') // Get the git revision we're using. Since fdroid modifies files as @@ -35,6 +39,7 @@ android { // default changes and .travis.yml can be kept in sync buildToolsVersion '27.0.3' defaultConfig { + // HostApduService requires 19. But is it a problem? minSdkVersion 14 targetSdkVersion 28 // must match ../build.gradle versionCode VERSION_CODE_BASE @@ -88,6 +93,8 @@ android { buildConfigField "boolean", "RELAYINVITE_SUPPORTED", "false" buildConfigField "String", "VARIANT_NAME", "\"Google Play Store\"" buildConfigField "int", "VARIANT_CODE", "1" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4" } xw4fdroid { @@ -101,10 +108,12 @@ android { buildConfigField "String", "VARIANT_NAME", "\"F-Droid\"" buildConfigField "int", "VARIANT_CODE", "2" buildConfigField "boolean", "FOR_FDROID", "true" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4" } xw4d { dimension "variant" - buildConfigField "String", "DB_NAME", "\"xwddb\""; + buildConfigField "String", "DB_NAME", "\"xwddb\"" applicationId "org.eehouse.android.xw4dbg" manifestPlaceholders = [ FABRIC_API_KEY: "$FABRIC_API_KEY", APP_ID: applicationId, ] resValue "string", "app_name", "CrossDbg" @@ -115,11 +124,13 @@ android { buildConfigField "int", "VARIANT_CODE", "3" buildConfigField "boolean", "REPORT_LOCKS", "true" buildConfigField "boolean", "MOVE_VIA_NFC", "true" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4d}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4d" } xw4dNoSMS { dimension "variant" - buildConfigField "String", "DB_NAME", "\"xwddb\""; + buildConfigField "String", "DB_NAME", "\"xwddb\"" applicationId "org.eehouse.android.xw4dbg" manifestPlaceholders = [ FABRIC_API_KEY: "$FABRIC_API_KEY", APP_ID: applicationId, ] resValue "string", "app_name", "CrossDbg" @@ -130,6 +141,8 @@ android { buildConfigField "int", "VARIANT_CODE", "4" buildConfigField "boolean", "REPORT_LOCKS", "true" buildConfigField "boolean", "MOVE_VIA_NFC", "true" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4d}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4d" } xw4SMS { @@ -142,6 +155,8 @@ android { buildConfigField "boolean", "RELAYINVITE_SUPPORTED", "false" buildConfigField "String", "VARIANT_NAME", "\"FOSS\"" buildConfigField "int", "VARIANT_CODE", "5" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4" } // WARNING: "all" breaks things. Seems to be a keyword. Need @@ -390,8 +405,8 @@ task makeBuildAssets() { out += "date: ${date}\n" // I want the variant, but that's harder. Here's a quick hack from SO. - String target = gradle.startParameter.taskNames[-1] - out += "target: ${target}\n" + // String target = gradle.startParameter.taskNames[0] + // out += "target: ${target}\n" String diff = "git diff".execute().text.trim() if (diff) { diff --git a/xwords4/android/app/src/main/AndroidManifest.xml b/xwords4/android/app/src/main/AndroidManifest.xml index 6916ec874..4043fd8ab 100644 --- a/xwords4/android/app/src/main/AndroidManifest.xml +++ b/xwords4/android/app/src/main/AndroidManifest.xml @@ -32,10 +32,10 @@ - + - - - - - - @@ -213,5 +207,14 @@ + + + + + + + diff --git a/xwords4/android/app/src/main/assets/changes.html b/xwords4/android/app/src/main/assets/changes.html index eb0a0bc68..ab832c830 100644 --- a/xwords4/android/app/src/main/assets/changes.html +++ b/xwords4/android/app/src/main/assets/changes.html @@ -39,7 +39,7 @@

Next up

    -
  • Look for a workaround to allow NFC on Android 10
  • +
  • Fix email invitations
  • Support duplicate-style play (popular in France)
  • Improve play-by-data-sms workaround using NBSProxy
  • diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BTService.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BTService.java index c0ba93ad0..4bc093d54 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BTService.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BTService.java @@ -771,14 +771,14 @@ public class BTService extends XWJIService { { Context context = XWApp.getContext(); ConnStatusHandler - .updateStatusOut( context, null, CommsConnType.COMMS_CONN_BT, success ); + .updateStatusOut( context, CommsConnType.COMMS_CONN_BT, success ); } private static void updateStatusIn( boolean success ) { Context context = XWApp.getContext(); ConnStatusHandler - .updateStatusIn( context, null, CommsConnType.COMMS_CONN_BT, success ); + .updateStatusIn( context, CommsConnType.COMMS_CONN_BT, success ); } private static class KillerIn extends Thread implements AutoCloseable { diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardDelegate.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardDelegate.java index 8ec61e6b9..a0a0bc594 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardDelegate.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardDelegate.java @@ -72,12 +72,13 @@ import org.eehouse.android.xw4.jni.XwJNI.GamePtr; import org.eehouse.android.xw4.jni.XwJNI; import org.eehouse.android.xw4.loc.LocUtils; import org.eehouse.android.xw4.TilePickAlert.TilePickState; +import org.eehouse.android.xw4.NFCCardService.Wrapper; public class BoardDelegate extends DelegateBase implements TransportProcs.TPMsgHandler, View.OnClickListener, DwnldDelegate.DownloadFinishedListener, ConnStatusHandler.ConnStatusCBacks, - NFCUtils.NFCActor { + Wrapper.Procs { private static final String TAG = BoardDelegate.class.getSimpleName(); private static final int SCREEN_ON_TIME = 10 * 60 * 1000; // 10 mins @@ -126,6 +127,8 @@ public class BoardDelegate extends DelegateBase private DBAlert m_inviteAlert; private boolean m_haveStartedShowing; + private Wrapper mNFCWrapper; + public class TimerRunnable implements Runnable { private int m_why; private int m_when; @@ -170,7 +173,7 @@ public class BoardDelegate extends DelegateBase private boolean alertOrderAt( StartAlertOrder ord ) { boolean result = m_mySIS.mAlertOrder == ord; - Log.d( TAG, "alertOrderAt(%s) => %b", ord, result ); + // Log.d( TAG, "alertOrderAt(%s) => %b", ord, result ); return result; } @@ -558,6 +561,9 @@ public class BoardDelegate extends DelegateBase m_isFirstLaunch = null == savedInstanceState; getBundledData( savedInstanceState ); + int devID = DevID.getNFCDevID( m_activity ); + mNFCWrapper = Wrapper.init( m_activity, this, devID ); + m_utils = new BoardUtilCtxt(); m_timers = new TimerRunnable[4]; // needs to be in sync with // XWTimerReason @@ -601,9 +607,6 @@ public class BoardDelegate extends DelegateBase m_jniThreadRef.setDaemonOnce( true ); m_jniThreadRef.startOnce(); - // Don't seem to need to unregister... - NFCUtils.register( m_activity, BoardDelegate.this ); - setBackgroundColor(); setKeepScreenOn(); @@ -633,6 +636,7 @@ public class BoardDelegate extends DelegateBase protected void onResume() { super.onResume(); + Wrapper.setResumed( mNFCWrapper, true ); if ( null != m_jniThreadRef ) { doResume( false ); } else { @@ -642,6 +646,7 @@ public class BoardDelegate extends DelegateBase protected void onPause() { + Wrapper.setResumed( mNFCWrapper, false ); closeIfFinishing( false ); m_handler = null; ConnStatusHandler.setHandler( null ); @@ -1098,7 +1103,8 @@ public class BoardDelegate extends DelegateBase launchLookup( m_mySIS.words, m_gi.dictLang ); break; case NFC_TO_SELF: - GamesListDelegate.sendNFCToSelf( m_activity, makeNFCMessage() ); + Assert.assertFalse( BuildConfig.DEBUG ); + // GamesListDelegate.sendNFCToSelf( m_activity, makeNFCMessage() ); break; case DROP_RELAY_ACTION: dropConViaAndRestart(CommsConnType.COMMS_CONN_RELAY); @@ -1515,22 +1521,49 @@ public class BoardDelegate extends DelegateBase } ////////////////////////////////////////////////// - // NFCUtils.NFCActor + // ConnStatusHandler.ConnStatusCBacks ////////////////////////////////////////////////// @Override - public String makeNFCMessage() + public void invalidateParent() { - Log.d( TAG, "makeNFCMessage(): m_mySIS.nMissing: %d", m_mySIS.nMissing ); - String data = null; + runOnUiThread(new Runnable() { + @Override + public void run() { + m_view.invalidate(); + } + }); + } + + @Override + public void onStatusClicked() + { + onStatusClicked( m_jniGamePtr ); + } + + @Override + public Handler getHandler() + { + return m_handler; + } + + //////////////////////////////////////////////////////////// + // NFCCardService.Wrapper.Procs + //////////////////////////////////////////////////////////// + @Override + public void onReadingChange( boolean nowReading ) + { + // Do we need this? + } + + private byte[] getInvite() + { + byte[] result = null; if ( 0 < m_mySIS.nMissing // Isn't there a better test?? && DeviceRole.SERVER_ISSERVER == m_gi.serverRole ) { - Log.d( TAG, "makeNFCMessage(): invite case" ); NetLaunchInfo nli = new NetLaunchInfo( m_gi ); Assert.assertTrue( 0 <= m_nGuestDevs ); nli.forceChannel = 1 + m_nGuestDevs; - Assert.assertTrue( m_connTypes.contains( CommsConnType.COMMS_CONN_NFC ) ); - for ( Iterator iter = m_connTypes.iterator(); iter.hasNext(); ) { CommsConnType typ = iter.next(); @@ -1558,46 +1591,9 @@ public class BoardDelegate extends DelegateBase typ.toString() ); } } - data = nli.makeLaunchJSON(); - if ( null != data ) { - recordInviteSent( InviteMeans.NFC, null ); - } - } else if ( BuildConfig.MOVE_VIA_NFC ) { - Log.d( TAG, "makeNFCMessage(): move case" ); - byte[][] msgs = XwJNI.comms_getPending( m_jniGamePtr ); - data = NFCUtils.makeMsgsJSON( m_gi.gameID, msgs ); - } else { - Log.d( TAG, "makeNFCMessage(): other (bad!!) case" ); - Assert.assertFalse( BuildConfig.DEBUG ); + result = nli.asByteArray(); } - Log.d( TAG, "makeNFCMessage() => %s", data ); - return data; - } - - ////////////////////////////////////////////////// - // ConnStatusHandler.ConnStatusCBacks - ////////////////////////////////////////////////// - @Override - public void invalidateParent() - { - runOnUiThread(new Runnable() { - @Override - public void run() { - m_view.invalidate(); - } - }); - } - - @Override - public void onStatusClicked() - { - onStatusClicked( m_jniGamePtr ); - } - - @Override - public Handler getHandler() - { - return m_handler; + return result; } private void launchPhoneNumberInvite( int nMissing, SentInvitesInfo info, @@ -2269,8 +2265,15 @@ public class BoardDelegate extends DelegateBase if ( null == m_jniThread ) { m_jniThread = m_jniThreadRef.retain(); m_gi = m_jniThread.getGI(); + m_summary = m_jniThread.getSummary(); + Wrapper.setGameID( mNFCWrapper, m_gi.gameID ); + byte[] invite = getInvite(); + if ( null != invite ) { + NFCUtils.addInvitationFor( invite, m_gi.gameID ); + } + m_view.startHandling( m_activity, m_jniThread, m_connTypes ); handleViaThread( JNICmd.CMD_START ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/CommsTransport.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/CommsTransport.java index 9cbab9934..2b1eeb1b7 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/CommsTransport.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/CommsTransport.java @@ -181,7 +181,7 @@ public class CommsTransport implements TransportProcs, addIncoming(); } ConnStatusHandler. - updateStatusIn( m_context, null, + updateStatusIn( m_context, CommsConnType.COMMS_CONN_RELAY, 0 <= nRead ); } @@ -190,7 +190,7 @@ public class CommsTransport implements TransportProcs, if ( null != m_bytesOut ) { int nWritten = channel.write( m_bytesOut ); ConnStatusHandler. - updateStatusOut( m_context, null, + updateStatusOut( m_context, CommsConnType.COMMS_CONN_RELAY, 0 < nWritten ); } @@ -444,6 +444,7 @@ public class CommsTransport implements TransportProcs, .sendPacket( context, addr.p2p_addr, gameID, buf ); break; case COMMS_CONN_NFC: + nSent = NFCUtils.addMsgFor( buf, gameID ); break; default: Assert.fail(); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConnStatusHandler.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConnStatusHandler.java index bd3513dc4..1b85b209f 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConnStatusHandler.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConnStatusHandler.java @@ -178,10 +178,6 @@ public class ConnStatusHandler { R.string.connstat_net_fmt, connTypes.toString( context, true ))); for ( CommsConnType typ : connTypes.getTypes() ) { - if ( ! typ.isSelectable() ) { - continue; - } - sb.append( String.format( "\n\n*** %s ", typ.longName( context ) ) ); String did = addDebugInfo( context, gamePtr, addr, typ ); if ( null != did ) { @@ -363,12 +359,23 @@ public class ConnStatusHandler { updateStatusImpl( context, cbacks, connType, success, true ); } + public static void updateStatusIn( Context context, CommsConnType connType, + boolean success ) + { + updateStatusImpl( context, null, connType, success, true ); + } + public static void updateStatusOut( Context context, ConnStatusCBacks cbacks, CommsConnType connType, boolean success ) { updateStatusImpl( context, cbacks, connType, success, false ); } + public static void updateStatusOut( Context context, CommsConnType connType, boolean success ) + { + updateStatusImpl( context, null, connType, success, false ); + } + private static void updateStatusImpl( Context context, ConnStatusCBacks cbacks, CommsConnType connType, boolean success, boolean isIn ) diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DbgUtils.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DbgUtils.java index ba0c50222..e4fcc33ff 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DbgUtils.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DbgUtils.java @@ -158,14 +158,18 @@ public class DbgUtils { // return TextUtils.join( ", ", asStrs ); // } - // public static String hexDump( byte[] bytes ) - // { - // StringBuilder dump = new StringBuilder(); - // for ( byte byt : bytes ) { - // dump.append( String.format( "%02x ", byt ) ); - // } - // return dump.toString(); - // } + public static String hexDump( byte[] bytes ) + { + String result = ""; + if ( null != bytes ) { + StringBuilder dump = new StringBuilder(); + for ( byte byt : bytes ) { + dump.append( String.format( "%02x ", byt ) ); + } + result = dump.toString(); + } + return result; + } private static List sLockHolders = new ArrayList<>(); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DevID.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DevID.java index 2778b3187..ac36e8936 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DevID.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DevID.java @@ -37,6 +37,7 @@ public class DevID { private static final String DEVID_KEY = "DevID.devid_key"; private static final String DEVID_ACK_KEY = "key_relay_regid_ackd2"; private static final String FCM_REGVERS_KEY = "key_fcmvers_regid"; + private static final String NFC_DEVID_KEY = "key_nfc_devid"; private static String s_relayDevID; private static int s_asInt; @@ -145,4 +146,22 @@ public class DevID { { DBUtils.setBoolFor( context, DEVID_ACK_KEY, false ); } + + // Just a random number I hang onto as long as possible + private static int[] sNFCDevID = {0}; + public static int getNFCDevID( Context context ) + { + synchronized ( sNFCDevID ) { + if ( 0 == sNFCDevID[0] ) { + int devid = DBUtils.getIntFor( context, NFC_DEVID_KEY, 0 ); + if ( 0 == devid ) { + devid = Utils.nextRandomInt(); + DBUtils.setIntFor( context, NFC_DEVID_KEY, devid ); + } + sNFCDevID[0] = devid; + } + Log.d( TAG, "getNFCDevID() => %d", sNFCDevID[0] ); + return sNFCDevID[0]; + } + } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameListItem.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameListItem.java index 5d8b342da..9390244f8 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameListItem.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameListItem.java @@ -249,7 +249,7 @@ public class GameListItem extends LinearLayout case R.string.game_summary_field_empty: break; case R.string.game_summary_field_gameid: - value = String.format( "%X", m_summary.gameID ); + value = String.format( "%d", m_summary.gameID ); break; case R.string.game_summary_field_rowid: value = String.format( "%d", m_rowid ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GamesListDelegate.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GamesListDelegate.java index 6c66aebc3..ba91df632 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GamesListDelegate.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GamesListDelegate.java @@ -64,6 +64,7 @@ import org.eehouse.android.xw4.loc.LocUtils; import java.io.Serializable; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.HashSet; import java.util.Iterator; @@ -2353,14 +2354,14 @@ public class GamesListDelegate extends ListDelegateBase private boolean tryNFCIntent( Intent intent ) { boolean result = false; - String data = NFCUtils.getFromIntent( intent ); + byte[] data = NFCUtils.getFromIntent( intent ); if ( null != data ) { NetLaunchInfo nli = NetLaunchInfo.makeFrom( m_activity, data ); if ( null != nli && nli.isValid() ) { startNewNetGame( nli ); result = true; } else { - NFCUtils.receiveMsgs( m_activity, data ); + Assert.assertFalse( BuildConfig.DEBUG ); } } return result; @@ -2843,10 +2844,12 @@ public class GamesListDelegate extends ListDelegateBase ; } - public static void sendNFCToSelf( Context context, String data ) + public static void postNFCInvite( Context context, byte[] data ) { - Intent intent = makeSelfIntent( context ); - NFCUtils.populateIntent( intent, data ); + Intent intent = makeSelfIntent( context ) + .addFlags( Intent.FLAG_ACTIVITY_NEW_TASK ) + ; + NFCUtils.populateIntent( context, intent, data ); context.startActivity( intent ); } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiMsgSink.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiMsgSink.java index bb1a7cdf5..999e03c74 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiMsgSink.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiMsgSink.java @@ -79,6 +79,12 @@ public class MultiMsgSink implements TransportProcs { .sendPacket( m_context, addr.p2p_addr, gameID, buf ); } + int sendViaNFC( byte[] buf, int gameID ) + { + Log.d( TAG, "sendViaNFC(gameID=%d, len=%d)", gameID, buf.length ); + return NFCUtils.addMsgFor( buf, gameID ); + } + public int numSent() { return m_sentSet.size(); @@ -108,12 +114,13 @@ public class MultiMsgSink implements TransportProcs { break; case COMMS_CONN_NFC: Log.d( TAG, "transportSend(): got for NFC" ); + nSent = sendViaNFC( buf, gameID ); break; default: Assert.fail(); break; } - Log.i( TAG, "transportSend(): sent %d msgs for game %d/%x via %s", + Log.i( TAG, "transportSend(): sent %d bytes for game %d/%x via %s", nSent, gameID, gameID, typ.toString() ); if ( 0 < nSent ) { Log.d( TAG, "transportSend: adding %s", msgID ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NBSProto.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NBSProto.java index 65c6100ca..2394824ee 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NBSProto.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NBSProto.java @@ -66,8 +66,7 @@ public class NBSProto { DbgUtils.showf( context, "Got msg %d", s_nReceived ); } - ConnStatusHandler.updateStatusIn( context, null, - CommsConnType.COMMS_CONN_SMS, + ConnStatusHandler.updateStatusIn( context, CommsConnType.COMMS_CONN_SMS, true ); } @@ -380,8 +379,7 @@ public class NBSProto { DbgUtils.showf( context, "Sent msg %d", s_nSent ); } - ConnStatusHandler.updateStatusOut( context, null, - CommsConnType.COMMS_CONN_SMS, + ConnStatusHandler.updateStatusOut( context, CommsConnType.COMMS_CONN_SMS, success ); } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCCardService.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCCardService.java new file mode 100644 index 000000000..f87d624df --- /dev/null +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCCardService.java @@ -0,0 +1,795 @@ +/* -*- compile-command: "find-and-gradle.sh inXw4dDeb"; -*- */ +/* + * Copyright 2019 by Eric House (xwords@eehouse.org). All rights reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ + +package org.eehouse.android.xw4; + +import android.app.Activity; +import android.content.Context; +import android.nfc.NfcAdapter; +import android.nfc.Tag; +import android.nfc.cardemulation.HostApduService; +import android.nfc.tech.IsoDep; +import android.os.Bundle; +import android.text.TextUtils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eehouse.android.xw4.NFCUtils.MsgToken; +import org.eehouse.android.xw4.jni.CommsAddrRec.CommsConnType; + +public class NFCCardService extends HostApduService { + private static final String TAG = NFCCardService.class.getSimpleName(); + private static final boolean USE_BIGINTEGER = true; + private static final int LEN_OFFSET = 4; + private static final byte VERSION_1 = (byte)0x01; + + private int mMyDevID; + + private static enum HEX_STR { + DEFAULT_CLA( "00" ) + , SELECT_INS( "A4" ) + , STATUS_FAILED( "6F00" ) + , CLA_NOT_SUPPORTED( "6E00" ) + , INS_NOT_SUPPORTED( "6D00" ) + , STATUS_SUCCESS( "9000" ) + , CMD_MSG_PART( "70FC" ) + ; + + private byte[] mBytes; + private HEX_STR( String hex ) { mBytes = Utils.hexStr2ba(hex); } + private byte[] asBA() { return mBytes; } + private boolean matchesFrom( byte[] src ) + { + return matchesFrom( src, 0 ); + } + private boolean matchesFrom( byte[] src, int offset ) + { + boolean result = offset + mBytes.length <= src.length; + for ( int ii = 0; result && ii < mBytes.length; ++ii ) { + result = src[offset + ii] == mBytes[ii]; + } + // Log.d( TAG, "%s.matchesFrom(%s) => %b", this, src, result ); + return result; + } + int length() { return asBA().length; } + } + + private static int sNextMsgID = 0; + private static synchronized int getNextMsgID() + { + return ++sNextMsgID; + } + + private static byte[] numTo( int num ) + { + byte[] result; + if ( USE_BIGINTEGER ) { + BigInteger bi = BigInteger.valueOf( num ); + byte[] bibytes = bi.toByteArray(); + result = new byte[1 + bibytes.length]; + result[0] = (byte)bibytes.length; + System.arraycopy( bibytes, 0, result, 1, bibytes.length ); + } else { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream( baos ); + try { + dos.writeInt( num ); + dos.flush(); + } catch ( IOException ioe ) { + Assert.assertFalse( BuildConfig.DEBUG ); + } + result = baos.toByteArray(); + } + // Log.d( TAG, "numTo(%d) => %s", num, DbgUtils.hexDump(result) ); + return result; + } + + private static int numFrom( ByteArrayInputStream bais ) throws IOException + { + int biLen = bais.read(); + // Log.d( TAG, "numFrom(): read biLen: %d", biLen ); + byte[] bytes = new byte[biLen]; + bais.read( bytes ); + BigInteger bi = new BigInteger( bytes ); + int result = bi.intValue(); + + // Log.d( TAG, "numFrom() => %d", result ); + return result; + } + + private static int numFrom( byte[] bytes, int start, int out[] ) + { + int result; + if ( USE_BIGINTEGER ) { + byte biLen = bytes[start]; + byte[] rest = Arrays.copyOfRange( bytes, start + 1, start + 1 + biLen ); + BigInteger bi = new BigInteger(rest); + out[0] = bi.intValue(); + result = biLen + 1; + } else { + ByteArrayInputStream bais = new ByteArrayInputStream( bytes, start, + bytes.length - start ); + DataInputStream dis = new DataInputStream( bais ); + try { + out[0] = dis.readInt(); + } catch ( IOException ioe ) { + Log.e( TAG, "from readInt(): %s", ioe.getMessage() ); + } + result = bais.available() - start; + } + return result; + } + + private static void testNumThing() + { + Log.d( TAG, "testNumThing() starting" ); + + int[] out = {0}; + for ( int ii = 1; ii > 0 && ii < Integer.MAX_VALUE; ii *= 2 ) { + byte[] tmp = numTo( ii ); + numFrom( tmp, 0, out ); + if ( ii != out[0] ) { + Log.d( TAG, "testNumThing(): %d failed; got %d", ii, out[0] ); + break; + } else { + Log.d( TAG, "testNumThing(): %d ok", ii ); + } + } + Log.d( TAG, "testNumThing() DONE" ); + } + + private static class QueueElem { + Context context; + byte[] msg; + QueueElem( Context pContext, byte[] pMsg ) { + context = pContext; + msg = pMsg; + } + } + + private static LinkedBlockingQueue sQueue = null; + + private synchronized static void addToMsgThread( Context context, byte[] msg ) + { + if ( 0 < msg.length ) { + QueueElem elem = new QueueElem( context, msg ); + if ( null == sQueue ) { + sQueue = new LinkedBlockingQueue<>(); + new Thread( new Runnable() { + @Override + public void run() { + Log.d( TAG, "addToMsgThread(): run starting" ); + for ( ; ; ) { + try { + QueueElem elem = sQueue.take(); + NFCUtils.receiveMsgs( elem.context, elem.msg ); + updateStatus( elem.context, true ); + } catch ( InterruptedException ie ) { + break; + } + } + Log.d( TAG, "addToMsgThread(): run exiting" ); + } + } ).start(); + } + sQueue.add( elem ); + // } else { + // // This is very common right now + // Log.d( TAG, "addToMsgThread(): dropping 0-length msg" ); + } + } + + private static void updateStatus( Context context, boolean in ) + { + if ( in ) { + ConnStatusHandler + .updateStatusIn( context, CommsConnType.COMMS_CONN_NFC, true ); + } else { + ConnStatusHandler + .updateStatusOut( context, CommsConnType.COMMS_CONN_NFC, true ); + } + } + + // Remove this once we don't need logging to confirm stuff's loading + @Override + public void onCreate() + { + super.onCreate(); + mMyDevID = DevID.getNFCDevID( this ); + Log.d( TAG, "onCreate() got mydevid %d", mMyDevID ); + } + + private int mGameID; + + @Override + public byte[] processCommandApdu( byte[] apdu, Bundle extras ) + { + // Log.d( TAG, "processCommandApdu(%s)", DbgUtils.hexDump(apdu ) ); + + HEX_STR resStr = HEX_STR.STATUS_FAILED; + boolean isAidCase = false; + + if ( null != apdu ) { + if ( HEX_STR.CMD_MSG_PART.matchesFrom( apdu ) ) { + resStr = HEX_STR.STATUS_SUCCESS; + int[] msgID = {0}; + byte[] all = reassemble( this, apdu, msgID, HEX_STR.CMD_MSG_PART ); + if ( null != all ) { + addToMsgThread( this, all ); + setLatestAck( msgID[0] ); + } + } else { + Log.d( TAG, "processCommandApdu(): aid case?" ); + if ( ! HEX_STR.DEFAULT_CLA.matchesFrom( apdu ) ) { + resStr = HEX_STR.CLA_NOT_SUPPORTED; + } else if ( ! HEX_STR.SELECT_INS.matchesFrom( apdu, 1 ) ) { + resStr = HEX_STR.INS_NOT_SUPPORTED; + } else if ( LEN_OFFSET >= apdu.length ) { + Log.d( TAG, "processCommandApdu(): apdu too short" ); + // Not long enough for length byte + } else { + try { + ByteArrayInputStream bais + = new ByteArrayInputStream( apdu, LEN_OFFSET, + apdu.length - LEN_OFFSET ); + byte aidLen = (byte)bais.read(); + Log.d( TAG, "aidLen=%d", aidLen ); + if ( bais.available() >= aidLen + 1 ) { + byte[] aidPart = new byte[aidLen]; + bais.read( aidPart ); + String aidStr = Utils.ba2HexStr( aidPart ); + if ( BuildConfig.NFC_AID.equals( aidStr ) ) { + byte minVersion = (byte)bais.read(); + byte maxVersion = (byte)bais.read(); + if ( minVersion == VERSION_1 ) { + int devID = numFrom( bais ); + Log.d( TAG, "processCommandApdu(): read " + + "remote devID: %d", devID ); + mGameID = numFrom( bais ); + Log.d( TAG, "read gameID: %d", mGameID ); + if ( 0 < bais.available() ) { + Log.d( TAG, "processCommandApdu(): " + + "leaving anything behind?" ); + } + resStr = HEX_STR.STATUS_SUCCESS; + isAidCase = true; + } else { + Log.e( TAG, "unexpected version %d; I'm too old?", + minVersion ); + } + } else { + Log.e( TAG, "aid mismatch: got %s but wanted %s", + aidStr, BuildConfig.NFC_AID ); + } + } + } catch ( IOException ioe ) { + Assert.assertFalse( BuildConfig.DEBUG ); + } + } + } + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + baos.write( resStr.asBA() ); + if ( HEX_STR.STATUS_SUCCESS == resStr ) { + if ( isAidCase ) { + baos.write( VERSION_1 ); // min + baos.write( numTo( mMyDevID ) ); + } else { + MsgToken token = NFCUtils.getMsgsFor( mGameID ); + byte[][] tmp = wrapMsg( token, Short.MAX_VALUE ); + Assert.assertTrue( 1 == tmp.length || !BuildConfig.DEBUG ); + baos.write( tmp[0] ); + } + } + } catch ( IOException ioe ) { + Assert.assertFalse( BuildConfig.DEBUG ); + } + byte[] result = baos.toByteArray(); + + Log.d( TAG, "processCommandApdu(%s) => %s", DbgUtils.hexDump( apdu ), + DbgUtils.hexDump( result ) ); + // this comes out of transceive() below!!! + return result; + } // processCommandApdu + + @Override + public void onDeactivated( int reason ) + { + String str = ""; + switch ( reason ) { + case HostApduService.DEACTIVATION_LINK_LOSS: + str = "DEACTIVATION_LINK_LOSS"; + break; + case HostApduService.DEACTIVATION_DESELECTED: + str = "DEACTIVATION_DESELECTED"; + break; + } + + Log.d( TAG, "onDeactivated(reason=%s)", str ); + } + + private static Map sSentTokens = new HashMap<>(); + private static void removeSentMsgs( Context context, int ack ) + { + MsgToken msgs = null; + if ( 0 != ack ) { + Log.d( TAG, "removeSentMsgs(msgID=%d)", ack ); + synchronized ( sSentTokens ) { + msgs = sSentTokens.remove( ack ); + Log.d( TAG, "removeSentMsgs(): removed %s, now have %s", msgs, keysFor() ); + } + updateStatus( context, false ); + } + if ( null != msgs ) { + msgs.removeSentMsgs(); + } + } + + private static void remember( int msgID, MsgToken msgs ) + { + if ( 0 != msgID ) { + Log.d( TAG, "remember(msgID=%d)", msgID ); + synchronized ( sSentTokens ) { + sSentTokens.put( msgID, msgs ); + Log.d( TAG, "remember(): now have %s", keysFor() ); + } + } + } + + private static String keysFor() + { + String result = ""; + if ( BuildConfig.DEBUG ) { + result = TextUtils.join( ",", sSentTokens.keySet() ); + } + return result; + } + + private static byte[][] sParts = null; + private static int sMsgID = 0; + private synchronized static byte[] reassemble( Context context, byte[] part, + int[] msgIDOut, HEX_STR cmd ) + { + return reassemble( context, part, msgIDOut, cmd.length() ); + } + + private synchronized static byte[] reassemble( Context context, byte[] part, + int[] msgIDOut, int offset ) + { + part = Arrays.copyOfRange( part, offset, part.length ); + return reassemble( context, part, msgIDOut ); + } + + private synchronized static byte[] reassemble( Context context, + byte[] part, int[] msgIDOut ) + { + byte[] result = null; + try { + ByteArrayInputStream bais = new ByteArrayInputStream( part ); + + final int cur = bais.read(); + final int count = bais.read(); + if ( 0 == cur ) { + sMsgID = numFrom( bais ); + int ack = numFrom( bais ); + removeSentMsgs( context, ack ); + } + + boolean inSequence = true; + if ( sParts == null ) { + if ( 0 == cur ) { + sParts = new byte[count][]; + } else { + Log.e( TAG, "reassemble(): out-of-order message 1" ); + inSequence = false; + } + } else if ( cur >= count || count != sParts.length || null != sParts[cur] ) { + // result = HEX_STR.STATUS_FAILED; + inSequence = false; + Log.e( TAG, "reassemble(): out-of-order message 2" ); + } + + if ( !inSequence ) { + sParts = null; // so we can try again later + } else { + // write rest into array + byte[] rest = new byte[bais.available()]; + bais.read( rest, 0, rest.length ); + sParts[cur] = rest; + // Log.d( TAG, "addOrProcess(): added elem %d: %s", cur, DbgUtils.hexDump( rest ) ); + + // Done? Process!! + if ( cur + 1 == count ) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + for ( int ii = 0; ii < sParts.length; ++ii ) { + baos.write( sParts[ii] ); + } + sParts = null; + + result = baos.toByteArray(); + msgIDOut[0] = sMsgID; + if ( 0 != sMsgID ) { + Log.d( TAG, "reassemble(): done reassembling msgID=%d: %s", + msgIDOut[0], DbgUtils.hexDump(result) ); + } + } + } + } catch ( IOException ioe ) { + Assert.assertFalse( BuildConfig.DEBUG ); + } + return result; + } + + private static AtomicInteger sLatestAck = new AtomicInteger(0); + private static int getLatestAck() + { + int result = sLatestAck.getAndSet(0); + if ( 0 != result ) { + Log.d( TAG, "getLatestAck() => %d", result ); + } + return result; + } + + private static void setLatestAck( int ack ) + { + if ( 0 != ack ) { + Log.e( TAG, "setLatestAck(%d)", ack ); + } + int oldVal = sLatestAck.getAndSet( ack ); + if ( 0 != oldVal ) { + Log.e( TAG, "setLatestAck(%d): dropping ack msgID %d", ack, oldVal ); + } + } + + private static final int HEADER_SIZE = 10; + private static byte[][] wrapMsg( MsgToken token, int maxLen ) + { + byte[] msg = token.getMsgs(); + final int length = null == msg ? 0 : msg.length; + final int msgID = (0 == length) ? 0 : getNextMsgID(); + if ( 0 < msgID ) { + Log.d( TAG, "wrapMsg(%s); msgID=%d", DbgUtils.hexDump( msg ), msgID ); + } + final int count = 1 + (length / (maxLen - HEADER_SIZE)); + byte[][] result = new byte[count][]; + try { + int offset = 0; + for ( int ii = 0; ii < count; ++ii ) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write( HEX_STR.CMD_MSG_PART.asBA() ); + baos.write( (byte)ii ); + baos.write( (byte)count ); + if ( 0 == ii ) { + baos.write( numTo( msgID ) ); + int latestAck = getLatestAck(); + baos.write( numTo( latestAck ) ); + } + Assert.assertTrue( HEADER_SIZE >= baos.toByteArray().length ); + + int thisLen = Math.min( maxLen - HEADER_SIZE, length - offset ); + if ( 0 < thisLen ) { + // Log.d( TAG, "writing %d bytes starting from offset %d", + // thisLen, offset ); + baos.write( msg, offset, thisLen ); + offset += thisLen; + } + byte[] tmp = baos.toByteArray(); + // Log.d( TAG, "wrapMsg(): adding res[%d]: %s", ii, DbgUtils.hexDump(tmp) ); + result[ii] = tmp; + } + remember( msgID, token ); + } catch ( IOException ioe ) { + Assert.assertFalse( BuildConfig.DEBUG ); + } + return result; + } + + public static class Wrapper implements NfcAdapter.ReaderCallback, + NFCUtils.HaveDataListener { + private Activity mActivity; + private boolean mHaveData; + private Procs mProcs; + private NfcAdapter mAdapter; + private int mMinMS = 300; + private int mMaxMS = 500; + private boolean mConnected = false; + private int mMyDevID; + + public interface Procs { + void onReadingChange( boolean nowReading ); + } + + public static Wrapper init( Activity activity, Procs procs, int devID ) + { + Wrapper instance = null; + if ( null != NfcAdapter.getDefaultAdapter( activity ) ) { + instance = new Wrapper( activity, procs, devID ); + } + Log.d( TAG, "Wrapper.init(devID=%d) => %s", devID, instance ); + return instance; + } + + static void setResumed( Wrapper instance, boolean resumed ) + { + if ( null != instance ) { + instance.setResumed( resumed ); + } + } + + static void setGameID( Wrapper instance, int gameID ) + { + if ( null != instance ) { + instance.setGameID( gameID ); + } + } + + private Wrapper( Activity activity, Procs procs, int devID ) + { + mActivity = activity; + mProcs = procs; + mMyDevID = devID; + mAdapter = NfcAdapter.getDefaultAdapter( activity ); + } + + private void setResumed( boolean resumed ) + { + if ( resumed ) { + startReadModeThread(); + } else { + stopReadModeThread(); + } + } + + @Override + public void onHaveDataChanged( boolean haveData ) + { + if ( mHaveData != haveData ) { + mHaveData = haveData; + Log.d( TAG, "onHaveDataChanged(): mHaveData now %b", mHaveData ); + interruptThread(); + } + } + + private boolean haveData() + { + boolean result = mHaveData; + // Log.d( TAG, "haveData() => %b", result ); + return result; + } + + private int mGameID; + private void setGameID( int gameID ) + { + Log.d( TAG, "setGameID(%d)", gameID ); + mGameID = gameID; + NFCUtils.setHaveDataListener( gameID, this ); + interruptThread(); + } + + private void interruptThread() + { + synchronized ( mThreadRef ) { + if ( null != mThreadRef[0] ) { + mThreadRef[0].interrupt(); + } + } + } + + @Override + public void onTagDiscovered( Tag tag ) + { + mConnected = true; + IsoDep isoDep = IsoDep.get( tag ); + try { + isoDep.connect(); + int maxLen = isoDep.getMaxTransceiveLength(); + Log.d( TAG, "onTagDiscovered() connected; max len: %d", maxLen ); + byte[] aidBytes = Utils.hexStr2ba( BuildConfig.NFC_AID ); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write( Utils.hexStr2ba( "00A40400" ) ); + baos.write( (byte)aidBytes.length ); + baos.write( aidBytes ); + baos.write( VERSION_1 ); // min + baos.write( VERSION_1 ); // max + baos.write( numTo( mMyDevID ) ); + baos.write( numTo( mGameID ) ); + byte[] msg = baos.toByteArray(); + Assert.assertTrue( msg.length < maxLen || !BuildConfig.DEBUG ); + byte[] response = isoDep.transceive( msg ); + + // The first reply from transceive() is special. If it starts + // with STATUS_SUCCESS then it also includes the version we'll + // be using to communicate, either what we sent over or + // something lower (for older code on the other side), and the + // remote's deviceID + if ( HEX_STR.STATUS_SUCCESS.matchesFrom( response ) ) { + int offset = HEX_STR.STATUS_SUCCESS.length(); + byte version = response[offset++]; + if ( version == VERSION_1 ) { + int[] out = {0}; + offset += numFrom( response, offset, out ); + Log.d( TAG, "onTagDiscovered(): read remote devID: %d", + out[0] ); + runMessageLoop( isoDep, maxLen ); + } else { + Log.e( TAG, "onTagDiscovered(): remote sent version %d, " + + "not %d; exiting", version, VERSION_1 ); + } + } + isoDep.close(); + } catch ( IOException ioe ) { + Log.e( TAG, "got ioe: " + ioe.getMessage() ); + } + + mConnected = false; + interruptThread(); // make sure we leave read mode! + Log.d( TAG, "onTagDiscovered() DONE" ); + } + + private void runMessageLoop( IsoDep isoDep, int maxLen ) throws IOException + { + outer: + for ( ; ; ) { + MsgToken token = NFCUtils.getMsgsFor( mGameID ); + // PENDING: no need for this Math.min thing once well tested + byte[][] toFit = wrapMsg( token, Math.min( 50, maxLen ) ); + for ( int ii = 0; ii < toFit.length; ++ii ) { + byte[] one = toFit[ii]; + Assert.assertTrue( one.length < maxLen || !BuildConfig.DEBUG ); + byte[] response = isoDep.transceive( one ); + if ( ! receiveAny( response ) ) { + break outer; + } + } + } + } + + private boolean receiveAny( byte[] response ) + { + boolean statusOK = HEX_STR.STATUS_SUCCESS.matchesFrom( response ); + if ( statusOK ) { + int offset = HEX_STR.STATUS_SUCCESS.length(); + if ( HEX_STR.CMD_MSG_PART.matchesFrom( response, offset ) ) { + int[] msgID = {0}; + byte[] all = reassemble( mActivity, response, msgID, + offset + HEX_STR.CMD_MSG_PART.length() ); + if ( null != all ) { + addToMsgThread( mActivity, all ); + setLatestAck( msgID[0] ); + } + } + } + Log.d( TAG, "receiveAny(%s) => %b", DbgUtils.hexDump( response ), statusOK ); + return statusOK; + } + + private class ReadModeThread extends Thread { + private boolean mShouldStop = false; + private boolean mInReadMode = false; + private final int mFlags = NfcAdapter.FLAG_READER_NFC_A + | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK; + + @Override + public void run() + { + Log.d( TAG, "ReadModeThread.run() starting" ); + Random random = new Random(); + + while ( !mShouldStop ) { + boolean wantReadMode = mConnected || !mInReadMode && haveData(); + if ( wantReadMode && !mInReadMode ) { + mAdapter.enableReaderMode( mActivity, Wrapper.this, mFlags, null ); + } else if ( mInReadMode && !wantReadMode ) { + mAdapter.disableReaderMode( mActivity ); + } + mInReadMode = wantReadMode; + Log.d( TAG, "run(): inReadMode now: %b", mInReadMode ); + + // Now sleep. If we aren't going to want to toggle read + // mode soon, sleep until interrupted by a state change, + // e.g. getting data or losing connection. + long intervalMS = Long.MAX_VALUE; + if ( (mInReadMode && !mConnected) || haveData() ) { + intervalMS = mMinMS + (Math.abs(random.nextInt()) + % (mMaxMS - mMinMS)); + } + try { + Thread.sleep( intervalMS ); + } catch ( InterruptedException ie ) { + Log.d( TAG, "run interrupted" ); + } + // toggle(); + // try { + // // How long to sleep. + // int intervalMS = mMinMS + (Math.abs(mRandom.nextInt()) + // % (mMaxMS - mMinMS)); + // // Log.d( TAG, "sleeping for %d ms", intervalMS ); + // Thread.sleep( intervalMS ); + // } catch ( InterruptedException ie ) { + // Log.d( TAG, "run interrupted" ); + // } + } + + // Kill read mode on the way out + if ( mInReadMode ) { + mAdapter.disableReaderMode( mActivity ); + mInReadMode = false; + } + + // Clear the reference only if it's me + synchronized ( mThreadRef ) { + if ( mThreadRef[0] == this ) { + mThreadRef[0] = null; + } + } + Log.d( TAG, "ReadModeThread.run() exiting" ); + } + + public void doStop() + { + mShouldStop = true; + interrupt(); + } + } + + private ReadModeThread[] mThreadRef = {null}; + private void startReadModeThread() + { + synchronized ( mThreadRef ) { + if ( null == mThreadRef[0] ) { + mThreadRef[0] = new ReadModeThread(); + mThreadRef[0].start(); + } + } + } + + private void stopReadModeThread() + { + ReadModeThread thread; + synchronized ( mThreadRef ) { + thread = mThreadRef[0]; + mThreadRef[0] = null; + } + + if ( null != thread ) { + thread.doStop(); + try { + thread.join(); + } catch ( InterruptedException ex ) { + Log.d( TAG, "stopReadModeThread(): %s", ex ); + } + } + } + } +} diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCUtils.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCUtils.java index 02be6b688..56772b53a 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCUtils.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NFCUtils.java @@ -25,20 +25,27 @@ import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; -import android.nfc.NdefMessage; -import android.nfc.NdefRecord; import android.nfc.NfcAdapter; import android.nfc.NfcEvent; import android.nfc.NfcManager; import android.os.Build; import android.os.Parcelable; -import org.json.JSONArray; -import org.json.JSONObject; -import org.json.JSONException; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; -import org.eehouse.android.xw4.loc.LocUtils; +import org.eehouse.android.xw4.MultiService.MultiEvent; import org.eehouse.android.xw4.jni.CommsAddrRec; +import org.eehouse.android.xw4.loc.LocUtils; public class NFCUtils { private static final String TAG = NFCUtils.class.getSimpleName(); @@ -46,53 +53,14 @@ public class NFCUtils { private static final String NFC_TO_SELF_ACTION = "org.eehouse.nfc_to_self"; private static final String NFC_TO_SELF_DATA = "nfc_data"; - private static final String MSGS = "MSGS"; - private static final String GAMEID = "GAMEID"; + private static final byte MESSAGE = 0x01; + private static final byte INVITE = 0x02; + private static final byte REPLY = 0x03; - public interface NFCActor { - String makeNFCMessage(); - } + private static final byte REPLY_NOGAME = 0x00; - private static boolean s_inSDK; + private static boolean s_inSDK = 14 <= Build.VERSION.SDK_INT; private static boolean[] s_nfcAvail; - private static SafeNFC s_safeNFC; - static { - s_inSDK = 14 <= Build.VERSION.SDK_INT - && Build.VERSION.SDK_INT <= Build.VERSION_CODES.P; - if ( s_inSDK ) { - s_safeNFC = new SafeNFCImpl(); - } - } - - private static interface SafeNFC { - public void register( Activity activity, NFCActor actor ); - } - - private static class SafeNFCImpl implements SafeNFC { - public void register( final Activity activity, final NFCActor actor ) - { - NfcManager manager = - (NfcManager)activity.getSystemService( Context.NFC_SERVICE ); - if ( null != manager ) { - NfcAdapter adapter = manager.getDefaultAdapter(); - if ( null != adapter ) { - NfcAdapter.CreateNdefMessageCallback cb = - new NfcAdapter.CreateNdefMessageCallback() { - public NdefMessage createNdefMessage( NfcEvent evt ) - { - NdefMessage msg = null; - String data = actor.makeNFCMessage(); - if ( null != data ) { - msg = makeMessage( activity, data ); - } - return msg; - } - }; - adapter.setNdefPushMessageCallback( cb, activity ); - } - } - } - } // Return array of two booleans, the first indicating whether the // device supports NFC and the second whether it's on. Only the @@ -108,38 +76,32 @@ public class NFCUtils { if ( s_nfcAvail[0] ) { s_nfcAvail[1] = getNFCAdapter( context ).isEnabled(); } + // Log.d( TAG, "nfcAvail() => {%b,%b}", s_nfcAvail[0], s_nfcAvail[1] ); return s_nfcAvail; } - public static String getFromIntent( Intent intent ) + public static byte[] getFromIntent( Intent intent ) { - String result = null; + byte[] result = null; String action = intent.getAction(); - if ( NfcAdapter.ACTION_NDEF_DISCOVERED.equals( action ) ) { - Parcelable[] rawMsgs = - intent.getParcelableArrayExtra( NfcAdapter.EXTRA_NDEF_MESSAGES ); - // only one message sent during the beam - NdefMessage msg = (NdefMessage)rawMsgs[0]; - // record 0 contains the MIME type, record 1 is the AAR, if present - result = new String( msg.getRecords()[0].getPayload() ); - } else if ( NFC_TO_SELF_ACTION.equals( action ) ) { - result = intent.getStringExtra( NFC_TO_SELF_DATA ); + if ( NFC_TO_SELF_ACTION.equals( action ) ) { + result = intent.getByteArrayExtra( NFC_TO_SELF_DATA ); } + Log.d( TAG, "getFromIntent() => %s", result ); return result; } - public static void populateIntent( Intent intent, String data ) + public static void populateIntent( Context context, Intent intent, + byte[] data ) { - intent.setAction( NFC_TO_SELF_ACTION ) - .putExtra( NFC_TO_SELF_DATA, data ); - } - - public static void register( Activity activity, NFCActor actor ) - { - if ( null != s_safeNFC ) { - s_safeNFC.register( activity, actor ); + NetLaunchInfo nli = NetLaunchInfo.makeFrom( context, data ); + if ( null != nli ) { + intent.setAction( NFC_TO_SELF_ACTION ) + .putExtra( NFC_TO_SELF_DATA, data ); + } else { + Assert.assertFalse( BuildConfig.DEBUG ); } } @@ -162,19 +124,6 @@ public class NFCUtils { .create(); } - private static NdefMessage makeMessage( Activity activity, String data ) - { - String mimeType = LocUtils.getString( activity, R.string.xwords_nfc_mime ); - NdefMessage msg = new NdefMessage( new NdefRecord[] { - new NdefRecord(NdefRecord.TNF_MIME_MEDIA, - mimeType.getBytes(), new byte[0], - data.getBytes()) - ,NdefRecord. - createApplicationRecord( activity.getPackageName() ) - }); - return msg; - } - private static NfcAdapter getNFCAdapter( Context context ) { NfcManager manager = @@ -182,44 +131,303 @@ public class NFCUtils { return manager.getDefaultAdapter(); } - static String makeMsgsJSON( int gameID, byte[][] msgs ) + private static byte[] formatMsgs( int gameID, List msgs ) { - String result = null; + return formatMsgs( gameID, msgs.toArray( new byte[msgs.size()][] ) ); + } - JSONArray arr = new JSONArray(); - for ( byte[] msg : msgs ) { - arr.put( Utils.base64Encode( msg ) ); - } - - try { - JSONObject obj = new JSONObject(); - obj.put( MSGS, arr ); - obj.put( GAMEID, gameID ); - - result = obj.toString(); - } catch ( JSONException ex ) { - Assert.assertFalse( BuildConfig.DEBUG ); + private static byte[] formatMsgs( int gameID, byte[][] msgs ) + { + byte[] result = null; + + if ( null != msgs && 0 < msgs.length ) { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream( baos ); + dos.writeInt( gameID ); + Log.d( TAG, "formatMsgs(): wrote gameID: %d", gameID ); + dos.flush(); + baos.write( msgs.length ); + for ( int ii = 0; ii < msgs.length; ++ii ) { + byte[] msg = msgs[ii]; + short len = (short)msg.length; + baos.write( len & 0xFF ); + baos.write( (len >> 8) & 0xFF ); + baos.write( msg ); + } + result = baos.toByteArray(); + } catch ( IOException ioe ) { + Assert.assertFalse( BuildConfig.DEBUG ); + } } + Log.d( TAG, "formatMsgs(gameID=%d) => %s", gameID, DbgUtils.hexDump( result ) ); return result; } - static boolean receiveMsgs( Context context, String data ) + private static byte[][] unformatMsgs( byte[] data, int start, int[] gameID ) { - Log.d( TAG, "receiveMsgs()" ); - int gameID[] = {0}; - byte[][] msgs = msgsFrom( data, gameID ); - boolean success = null != msgs && 0 < msgs.length; - if ( success ) { - NFCServiceHelper helper = new NFCServiceHelper( context ); - long[] rowids = DBUtils.getRowIDsFor( context, gameID[0] ); - for ( long rowid : rowids ) { - NFCMsgSink sink = new NFCMsgSink( context, rowid ); - for ( byte[] msg : msgs ) { - helper.receiveMessage( rowid, sink, msg ); + byte[][] result = null; + try { + ByteArrayInputStream bais + = new ByteArrayInputStream( data, start, data.length ); + DataInputStream dis = new DataInputStream( bais ); + gameID[0] = dis.readInt(); + Log.d( TAG, "unformatMsgs(): read gameID: %d", gameID[0] ); + int count = bais.read(); + Log.d( TAG, "unformatMsgs(): read count: %d", count ); + result = new byte[count][]; + + for ( int ii = 0; ii < count; ++ii ) { + short len = (short)bais.read(); + len |= (int)(bais.read() << 8); + Log.d( TAG, "unformatMsgs(): read len %d for msg %d", len, ii ); + byte[] msg = new byte[len]; + int nRead = bais.read( msg ); + Assert.assertTrue( nRead == msg.length ); + result[ii] = msg; + } + } catch ( IOException ex ) { + Log.d( TAG, "ex: %s: %s", ex, ex.getMessage() ); + result = null; + gameID[0] = 0; + } + Log.d( TAG, "unformatMsgs() => %s (len=%d)", result, + null == result ? 0 : result.length ); + return result; + } + + interface HaveDataListener { + void onHaveDataChanged( boolean nowHaveData ); + } + + public static class MsgToken { + private MsgsStore mStore; + private byte[][] mMsgs; + private int mGameID; + + private MsgToken( MsgsStore store, int gameID ) + { + mStore = store; + mGameID = gameID; + mMsgs = mStore.getMsgsFor( gameID ); + } + + byte[] getMsgs() + { + return formatMsgs( mGameID, mMsgs ); + } + + void removeSentMsgs() + { + mStore.removeSentMsgs( mGameID, mMsgs ); + } + } + + private static class MsgsStore { + private Map> mListeners + = new HashMap<>(); + private static Map> mMsgMap = new HashMap<>(); + + void setHaveDataListener( int gameID, HaveDataListener listener ) + { + Assert.assertFalse( gameID == 0 ); + WeakReference ref = new WeakReference<>(listener); + synchronized ( mListeners ) { + mListeners.put( gameID, ref ); + } + + byte[][] msgs = getMsgsFor( gameID ); + listener.onHaveDataChanged( null != msgs && 0 < msgs.length ); + } + + private int addMsgFor( int gameID, byte typ, byte[] msg ) + { + Boolean nowHaveData = null; + + synchronized ( mMsgMap ) { + if ( !mMsgMap.containsKey( gameID ) ) { + mMsgMap.put( gameID, new ArrayList() ); + } + List msgs = mMsgMap.get( gameID ); + + byte[] full = new byte[msg.length + 1]; + full[0] = typ; + System.arraycopy( msg, 0, full, 1, msg.length ); + + // Can't use msgs.contains() because it uses equals() + boolean isDuplicate = false; + for ( byte[] curMsg : msgs ) { + if ( Arrays.equals( curMsg, full ) ) { + isDuplicate = true; + break; + } + } + + if ( !isDuplicate ) { + msgs.add( full ); + nowHaveData = 0 < msgs.size(); + Log.d( TAG, "addMsgFor(gameID=%d): added %s; now have %d msgs", + gameID, DbgUtils.hexDump(msg), msgs.size() ); + } + } + + reportHaveData( gameID, nowHaveData ); + + return msg.length; + } + + private byte[][] getMsgsFor( int gameID ) + { + Assert.assertFalse( gameID == 0 ); + byte[][] result = null; + synchronized ( mMsgMap ) { + if ( mMsgMap.containsKey( gameID ) ) { + List msgs = mMsgMap.get( gameID ); + result = msgs.toArray( new byte[msgs.size()][] ); + } + } + Log.d( TAG, "getMsgsFor() => %d msgs", result == null ? 0 : result.length ); + return result; + } + + private void removeSentMsgs( int gameID, byte[][] msgs ) + { + Boolean nowHaveData = null; + if ( null != msgs ) { + synchronized ( mMsgMap ) { + if ( mMsgMap.containsKey( gameID ) ) { + List list = mMsgMap.get( gameID ); + // Log.d( TAG, "removeSentMsgs(%d): size before: %d", gameID, + // list.size() ); + int origSize = list.size(); + for ( byte[] msg : msgs ) { + list.remove( msg ); + } + if ( 0 < origSize ) { + Log.d( TAG, "removeSentMsgs(%d): size was %d, now %d", gameID, + origSize, list.size() ); + } + nowHaveData = 0 < list.size(); + } + } + } + reportHaveData( gameID, nowHaveData ); + } + + private void reportHaveData( int gameID, Boolean nowHaveData ) + { + Log.d( TAG, "reportHaveData(" + nowHaveData + ")" ); + if ( null != nowHaveData ) { + HaveDataListener proc = null; + synchronized ( mListeners ) { + WeakReference ref = mListeners.get( gameID ); + if ( null != ref ) { + proc = ref.get(); + if ( null == proc ) { + mListeners.remove( gameID ); + } + } else { + Log.d( TAG, "reportHaveData(): no listener for %d", gameID ); + } + } + if ( null != proc ) { + proc.onHaveDataChanged( nowHaveData ); + } + } + } + + static byte[] split( byte[] msg, byte[] headerOut ) + { + headerOut[0] = msg[0]; + byte[] result = Arrays.copyOfRange( msg, 1, msg.length ); + Log.d( TAG, "split(%s) => %d/%s", DbgUtils.hexDump( msg ), + headerOut[0], DbgUtils.hexDump( result ) ); + return result; + } + } + private static MsgsStore sMsgsStore = new MsgsStore(); + + static void setHaveDataListener( int gameID, HaveDataListener listener ) + { + sMsgsStore.setHaveDataListener( gameID, listener ); + } + + static int addMsgFor( byte[] msg, int gameID ) + { + return sMsgsStore.addMsgFor( gameID, MESSAGE, msg ); + } + + static int addInvitationFor( byte[] msg, int gameID ) + { + return sMsgsStore.addMsgFor( gameID, INVITE, msg ); + } + + static int addReplyFor( byte[] msg, int gameID ) + { + return sMsgsStore.addMsgFor( gameID, REPLY, msg ); + } + + static MsgToken getMsgsFor( int gameID ) + { + MsgToken token = new MsgToken( sMsgsStore, gameID ); + return token; + } + + static void receiveMsgs( Context context, byte[] data ) + { + receiveMsgs( context, data, 0 ); + } + + static void receiveMsgs( Context context, byte[] data, int offset ) + { + // Log.d( TAG, "receiveMsgs(gameID=%d, %s, offset=%d)", gameID, + // DbgUtils.hexDump(data), offset ); + DbgUtils.assertOnUIThread( false ); + int[] gameID = {0}; + byte[][] msgs = unformatMsgs( data, offset, gameID ); + if ( null != msgs ) { + NFCServiceHelper helper = new NFCServiceHelper( context ); + for ( byte[] msg : msgs ) { + byte[] typ = {0}; + byte[] body = MsgsStore.split( msg, typ ); + switch ( typ[0] ) { + case MESSAGE: + long[] rowids = DBUtils.getRowIDsFor( context, gameID[0] ); + if ( null == rowids || 0 == rowids.length ) { + addReplyFor( new byte[]{REPLY_NOGAME}, gameID[0] ); + } else { + for ( long rowid : rowids ) { + NFCMsgSink sink = new NFCMsgSink( context, rowid ); + helper.receiveMessage( rowid, sink, body ); + } + } + break; + case INVITE: + GamesListDelegate.postNFCInvite( context, body ); + break; + case REPLY: + switch( body[0] ) { + case REPLY_NOGAME: + // PENDING Don't enable this until deviceID is being + // checked. Otherwise it'll happen every time I tap my + // device against another that doesn't have my game, + // which could be common. + // helper.postEvent( MultiEvent.MESSAGE_NOGAME, gameID ); + Log.e( TAG, "receiveMsgs(): not calling helper.postEvent( " + + "MultiEvent.MESSAGE_NOGAME, gameID );" ); + break; + default: + Log.e( TAG, "unexpected reply %d", body[0] ); + Assert.assertFalse( BuildConfig.DEBUG ); + break; + } + break; + default: + Assert.assertFalse( BuildConfig.DEBUG ); + break; } } } - return success; } private static class NFCServiceHelper extends XWServiceHelper { @@ -248,7 +456,7 @@ public class NFCUtils { private void receiveMessage( long rowid, NFCMsgSink sink, byte[] msg ) { - Log.d( TAG, "receiveMessage()" ); + Log.d( TAG, "receiveMessage(rowid=%d, len=%d)", rowid, msg.length ); receiveMessage( rowid, sink, msg, mAddr ); } } @@ -259,27 +467,4 @@ public class NFCUtils { super( context, rowid ); } } - - private static byte[][] msgsFrom( String json, /*out*/ int[] gameID ) - { - byte[][] result = null; - try { - JSONObject obj = new JSONObject( json ); - gameID[0] = obj.getInt( GAMEID ); - JSONArray arr = obj.getJSONArray( MSGS ); - if ( null != arr ) { - result = new byte[arr.length()][]; - for ( int ii = 0; ii < arr.length(); ++ii ) { - String str = arr.getString( ii ); - result[ii] = Utils.base64Decode( str ); - } - } - } catch ( JSONException ex ) { - Assert.assertFalse( BuildConfig.DEBUG ); - result = null; - } - Log.d( TAG, "msgsFrom() => %s", (Object)result ); - return result; - } - } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NetLaunchInfo.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NetLaunchInfo.java index 2a5bd78bc..183d9edce 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NetLaunchInfo.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NetLaunchInfo.java @@ -294,6 +294,7 @@ public class NetLaunchInfo implements Serializable { addP2PInfo( context ); break; case COMMS_CONN_NFC: + addNFCInfo(); break; default: Assert.fail(); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/RelayService.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/RelayService.java index db684dd54..f49343e3a 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/RelayService.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/RelayService.java @@ -735,9 +735,9 @@ public class RelayService extends XWJIService Log.e( TAG, "fail sending to %s", udpSocket ); Log.ex( TAG, ex ); Log.i( TAG, "Restarting threads to force new socket" ); - ConnStatusHandler.updateStatusOut( service, null, - CommsConnType.COMMS_CONN_RELAY, - true ); + ConnStatusHandler + .updateStatusOut( service, CommsConnType.COMMS_CONN_RELAY, + true ); closeUDPSocket( udpSocket ); service.m_handler.post( new Runnable() { diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Utils.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Utils.java index f0a355c51..69fb6c28e 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Utils.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Utils.java @@ -615,6 +615,40 @@ public class Utils { return Looper.getMainLooper().equals(Looper.myLooper()); } + // But see hexArray above + private static final String HEX_CHARS = "0123456789ABCDEF"; + private static char[] HEX_CHARS_ARRAY = HEX_CHARS.toCharArray(); + + public static String ba2HexStr( byte[] input ) + { + StringBuffer sb = new StringBuffer(); + + for ( byte byt : input ) { + sb.append(HEX_CHARS_ARRAY[(byt >> 4) & 0x0F]); + sb.append(HEX_CHARS_ARRAY[byt & 0x0F]); + } + + String result = sb.toString(); + return result; + } + + public static byte[] hexStr2ba( String data ) + { + data = data.toUpperCase(); + Assert.assertTrue( 0 == data.length() % 2 ); + byte[] result = new byte[data.length() / 2]; + + for (int ii = 0; ii < data.length(); ii += 2 ) { + int one = HEX_CHARS.indexOf(data.charAt(ii)); + Assert.assertTrue( one >= 0 ); + int two = HEX_CHARS.indexOf(data.charAt(ii + 1)); + Assert.assertTrue( two >= 0 ); + result[ii/2] = (byte)((one << 4) | two); + } + + return result; + } + public static String base64Encode( byte[] in ) { return Base64.encodeToString( in, Base64.NO_WRAP ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/WiDirService.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/WiDirService.java index 315f095c5..f795ddfb8 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/WiDirService.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/WiDirService.java @@ -172,15 +172,15 @@ public class WiDirService extends XWService { private static void updateStatusOut( boolean success ) { ConnStatusHandler - .updateStatusOut( XWApp.getContext(), null, + .updateStatusOut( XWApp.getContext(), CommsConnType.COMMS_CONN_P2P, success ); } private static void updateStatusIn( boolean success ) { ConnStatusHandler - .updateStatusIn( XWApp.getContext(), null, - CommsConnType.COMMS_CONN_P2P, success ); + .updateStatusIn( XWApp.getContext(), CommsConnType.COMMS_CONN_P2P, + success ); } public static void init( Context context ) diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommsAddrRec.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommsAddrRec.java index d419e496d..8a04996af 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommsAddrRec.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommsAddrRec.java @@ -80,6 +80,8 @@ public class CommsAddrRec { id = R.string.invite_choice_data_sms; break; case COMMS_CONN_P2P: id = R.string.invite_choice_p2p; break; + case COMMS_CONN_NFC: + id = R.string.invite_choice_nfc; break; default: Assert.assertFalse( BuildConfig.DEBUG ); } diff --git a/xwords4/android/app/src/main/res/values/common_rsrc.xml b/xwords4/android/app/src/main/res/values/common_rsrc.xml index daa93c61c..39cbd0426 100644 --- a/xwords4/android/app/src/main/res/values/common_rsrc.xml +++ b/xwords4/android/app/src/main/res/values/common_rsrc.xml @@ -152,7 +152,6 @@ eehouse.org - application/org.eehouse.android.xw4 eehouse.org application/x-xwordsinvite diff --git a/xwords4/android/app/src/main/res/values/strings.xml b/xwords4/android/app/src/main/res/values/strings.xml index f2a7aafd0..901969f6a 100644 --- a/xwords4/android/app/src/main/res/values/strings.xml +++ b/xwords4/android/app/src/main/res/values/strings.xml @@ -2397,5 +2397,7 @@ they\'re committed as moves -- by long-tapping, same as committed words.\n\nUse this feature to check the validity of words you\'re thinking of playing, or to look up an unfamiliar word provided as a - hint. + hint. + + For transmitting CrossWords moves diff --git a/xwords4/android/app/src/main/res/xml/apduservice.xml b/xwords4/android/app/src/main/res/xml/apduservice.xml new file mode 100644 index 000000000..adf18e767 --- /dev/null +++ b/xwords4/android/app/src/main/res/xml/apduservice.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/xwords4/common/comms.c b/xwords4/common/comms.c index bbf0cd805..825184a80 100644 --- a/xwords4/common/comms.c +++ b/xwords4/common/comms.c @@ -2647,26 +2647,27 @@ comms_getStats( CommsCtxt* comms, XWStreamCtxt* stream ) (XP_UCHAR*)"msg queue len: %d\n", comms->queueLen ); stream_catString( stream, buf ); + XP_U16 indx = 0; for ( elem = comms->msgQueueHead; !!elem; elem = elem->next ) { XP_SNPRINTF( buf, sizeof(buf), - " - channelNo=%.4X; msgID=" XP_LD "; len=%d\n", - elem->channelNo, elem->msgID, elem->len ); + "%d: - channelNo=%.4X; msgID=" XP_LD "; len=%d\n", + indx++, elem->channelNo, elem->msgID, elem->len ); stream_catString( stream, buf ); } for ( rec = comms->recs; !!rec; rec = rec->next ) { - XP_SNPRINTF( (XP_UCHAR*)buf, sizeof(buf), - (XP_UCHAR*)" Stats for channel: %.4X\n", + XP_SNPRINTF( (XP_UCHAR*)buf, sizeof(buf), + (XP_UCHAR*)"Stats for channel %.4X\n", rec->channelNo ); stream_catString( stream, buf ); - XP_SNPRINTF( (XP_UCHAR*)buf, sizeof(buf), - (XP_UCHAR*)"Last msg sent: " XP_LD "\n", + XP_SNPRINTF( (XP_UCHAR*)buf, sizeof(buf), + (XP_UCHAR*)" Last msg sent: " XP_LD "; ", rec->nextMsgID ); stream_catString( stream, buf ); - XP_SNPRINTF( (XP_UCHAR*)buf, sizeof(buf), - (XP_UCHAR*)"Last msg received: %d\n", + XP_SNPRINTF( (XP_UCHAR*)buf, sizeof(buf), + (XP_UCHAR*)"last msg received: %d\n", rec->lastMsgRcd ); stream_catString( stream, buf ); }