diff --git a/xwords4/android/app/build.gradle b/xwords4/android/app/build.gradle index d7d5b6d67..e26731bd8 100644 --- a/xwords4/android/app/build.gradle +++ b/xwords4/android/app/build.gradle @@ -4,9 +4,28 @@ def VERSION_NAME = '4.4.154' def FABRIC_API_KEY = System.getenv("FABRIC_API_KEY") def BUILD_INFO_NAME = "build-info.txt" +// Not all variants use the same BT_UUID. Those with the same talk to +// each other +def XW_UUID = '"7be0d084-ff89-4d6d-9c78-594773a6f963"' // from comms.h +def XWD_UUID = '"b079b640-35fe-11e5-a432-0002a5d5c51b"' // from comms.h +def XWDUP_UUID = '"92602f84-1bc5-11ea-978f-2e728ce88125"' +def BT_UUIDS = [ + 'xw4fdroidDebug' : XW_UUID, + 'xw4fdroidRelease' : XW_UUID, + 'xw4NoSMSDebug' : XW_UUID, + 'xw4NoSMSRelease' : XW_UUID, + 'xw4SMSDebug' : XW_UUID, + 'xw4SMSRelease' : XW_UUID, + + 'xw4dupDebug' : XWDUP_UUID, + 'xw4dupRelease' : XWDUP_UUID, + 'xw4dupNoSMSDebug' : XWDUP_UUID, + 'xw4dupNoSMSRelease' : XWDUP_UUID, +] + // AID must start with F (first 4 bits) and be from 5 to 16 bytes long def NFC_AID_XW4 = "FC8FF510B360" -def NFC_AID_XW4d = "FDDA0A3EB5E5" +def NFC_AID_XW4dup = "F8960736B33C" boolean forFDroid = hasProperty('forFDroid') @@ -79,7 +98,7 @@ android { buildConfigField "boolean", "UDP_ENABLED", "true" buildConfigField "boolean", "REPORT_LOCKS", "false" buildConfigField "boolean", "LOG_LIFECYLE", "false" - buildConfigField "boolean", "MOVE_VIA_NFC", "false" + buildConfigField "String", "KEY_FCMID", "\"FBMService_fcmid\"" buildConfigField "boolean", "ATTACH_SUPPORTED", "false" } @@ -111,38 +130,37 @@ android { buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4}\"" resValue "string", "nfc_aid", "$NFC_AID_XW4" } - xw4d { + xw4dup { dimension "variant" - buildConfigField "String", "DB_NAME", "\"xwddb\"" - applicationId "org.eehouse.android.xw4dbg" + buildConfigField "String", "DB_NAME", "\"xwddb\""; + applicationId "org.eehouse.android.xw4dup" manifestPlaceholders = [ FABRIC_API_KEY: "$FABRIC_API_KEY", APP_ID: applicationId, ] - resValue "string", "app_name", "CrossDbg" + resValue "string", "app_name", "CrossDup" resValue "string", "nbs_port", "3345" buildConfigField "boolean", "WIDIR_ENABLED", "true" buildConfigField "boolean", "RELAYINVITE_SUPPORTED", "true" buildConfigField "String", "VARIANT_NAME", "\"Dev/Debug\"" 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" + buildConfigField "String", "KEY_FCMID", "\"FBMService_fcmid1\"" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4dup}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4dup" } - xw4dNoSMS { + xw4dupNoSMS { dimension "variant" + applicationId "org.eehouse.android.xw4dup" 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" + resValue "string", "app_name", "CrossDup" resValue "string", "nbs_port", "3345" buildConfigField "boolean", "WIDIR_ENABLED", "true" buildConfigField "boolean", "RELAYINVITE_SUPPORTED", "true" buildConfigField "String", "VARIANT_NAME", "\"Dev/Debug NoSMS\"" 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" + buildConfigField "String", "NFC_AID", "\"${NFC_AID_XW4dup}\"" + resValue "string", "nfc_aid", "$NFC_AID_XW4dup" } xw4SMS { @@ -223,20 +241,20 @@ android { jniLibs.srcDir "../libs-xw4NoSMSDebug" } } - xw4d { + xw4dup { release { - jniLibs.srcDir "../libs-xw4dRelease" + jniLibs.srcDir "../libs-xw4dupRelease" } debug { - jniLibs.srcDir "../libs-xw4dDebug" + jniLibs.srcDir "../libs-xw4dupDebug" } } - xw4dNoSMS { + xw4dupNoSMS { release { - jniLibs.srcDir "../libs-xw4dNoSMSRelease" + jniLibs.srcDir "../libs-xw4dupNoSMSRelease" } debug { - jniLibs.srcDir "../libs-xw4dNoSMSDebug" + jniLibs.srcDir "../libs-xw4dupNoSMSDebug" } } xw4SMS { @@ -284,10 +302,10 @@ dependencies { // 2.6.8 is probably as far forward as I can go without upping my // min-supported SDK version - xw4dImplementation('com.crashlytics.sdk.android:crashlytics:2.6.3@aar') { // rm-for-fdroid + xw4dupImplementation('com.crashlytics.sdk.android:crashlytics:2.6.3@aar') { // rm-for-fdroid transitive = true // rm-for-fdroid } // rm-for-fdroid - xw4dNoSMSImplementation('com.crashlytics.sdk.android:crashlytics:2.6.3@aar') { // rm-for-fdroid + xw4dupNoSMSImplementation('com.crashlytics.sdk.android:crashlytics:2.6.3@aar') { // rm-for-fdroid transitive = true // rm-for-fdroid } // rm-for-fdroid @@ -357,12 +375,13 @@ afterEvaluate { String nameLC = variant.getBuildType().getName().toLowerCase() String lib = "libs-${variant.name}" String ndkBuildTask = "ndkBuild${variantCaps}" + String btUUID = BT_UUIDS[variant.name] task "$ndkBuildTask"(type: Exec) { - workingDir '../' - commandLine './scripts/ndkbuild.sh', '-j3', - "BUILD_TARGET=${nameLC}", "INITIAL_CLIENT_VERS=$INITIAL_CLIENT_VERS", - "VARIANT=${FLAVOR}", "NDK_LIBS_OUT=${lib}", - "NDK_OUT=./obj-${variant.name}" + workingDir '../' + commandLine './scripts/ndkbuild.sh', '-j3', + "BUILD_TARGET=${nameLC}", "INITIAL_CLIENT_VERS=$INITIAL_CLIENT_VERS", + "VARIANT=${FLAVOR}", "NDK_LIBS_OUT=${lib}", + "NDK_OUT=./obj-${variant.name}", "XW_BT_UUID=\"${btUUID}\"" } String compileTask = "compile${variantCaps}Ndk" @@ -383,18 +402,18 @@ afterEvaluate { String copyStringsTask = "copyStringsXw4D" task "$copyStringsTask"(type: Exec) { workingDir './' - environment.put('APPNAME', 'CrossDbg') + environment.put('APPNAME', 'CrossDup') commandLine 'make', '-f', '../scripts/Variant.mk', - "src/xw4d/res/values/strings.xml" + "src/xw4dup/res/values/strings.xml" } preBuild.dependsOn copyStringsTask String copyStringsTaskNoSMS = "copyStringsXw4DNoSMS" task "$copyStringsTaskNoSMS"(type: Exec) { workingDir './' - environment.put('APPNAME', 'CrossDbg') + environment.put('APPNAME', 'CrossDup') commandLine 'make', '-f', '../scripts/Variant.mk', - "src/xw4dNoSMS/res/values/strings.xml" + "src/xw4dupNoSMS/res/values/strings.xml" } preBuild.dependsOn copyStringsTaskNoSMS } diff --git a/xwords4/android/app/src/main/AndroidManifest.xml b/xwords4/android/app/src/main/AndroidManifest.xml index 4043fd8ab..8d1e0d07b 100644 --- a/xwords4/android/app/src/main/AndroidManifest.xml +++ b/xwords4/android/app/src/main/AndroidManifest.xml @@ -138,6 +138,7 @@ + diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardCanvas.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardCanvas.java index cd6adf173..9725b64ff 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardCanvas.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardCanvas.java @@ -80,6 +80,7 @@ public class BoardCanvas extends Canvas implements DrawCtx { private CommonPrefs m_prefs; private int m_lastSecsLeft; private int m_lastTimerPlayer; + private boolean m_lastTimerTurnDone; private boolean m_inTrade; private boolean m_darkOnLight; private Drawable m_origin; @@ -334,18 +335,48 @@ public class BoardCanvas extends Canvas implements DrawCtx { } @Override + // public void drawTimer( Rect rect, int player, int secondsLeft, + // boolean turnDone ) + // { + // if ( m_lastSecsLeft != secondsLeft + // || m_lastTimerPlayer != player + // || m_lastTimerTurnDone != turnDone ) { + // if ( null != m_activity && null != m_jniThread ) { + // Rect rectCopy = new Rect(rect); + // m_lastSecsLeft = secondsLeft; + // m_lastTimerPlayer = player; + // m_lastTimerTurnDone = turnDone; + + // String negSign = secondsLeft < 0? "-" : ""; + // secondsLeft = Math.abs( secondsLeft ); + // String time = String.format( "%s%d:%02d", negSign, + // secondsLeft/60, secondsLeft%60 ); + + // fillRectOther( rectCopy, CommonPrefs.COLOR_BACKGRND ); + + // int color = m_playerColors[player]; + // if ( turnDone ) { + // color &= NOT_TURN_ALPHA; + // } + // m_fillPaint.setColor( color ); public void drawTimer( Rect rect, final int player, - int secondsLeft ) + int secondsLeft, final boolean turnDone ) { - if ( m_lastSecsLeft != secondsLeft || m_lastTimerPlayer != player ) { + Activity activity = m_activity; + if ( null == activity ) { + // Do nothing + } else if ( m_lastSecsLeft != secondsLeft + || m_lastTimerPlayer != player + || m_lastTimerTurnDone != turnDone ) { final Rect rectCopy = new Rect(rect); final int secondsLeftCopy = secondsLeft; - m_activity.runOnUiThread( new Runnable() { + activity.runOnUiThread( new Runnable() { @Override public void run() { if ( null != m_jniThread ) { m_lastSecsLeft = secondsLeftCopy; m_lastTimerPlayer = player; + m_lastTimerTurnDone = turnDone; String negSign = secondsLeftCopy < 0? "-":""; int secondsLeft = Math.abs( secondsLeftCopy ); @@ -519,9 +550,9 @@ public class BoardCanvas extends Canvas implements DrawCtx { @Override public void score_pendingScore( Rect rect, int score, int playerNum, - int curTurn, int flags ) + boolean curTurn, int flags ) { - Log.d( TAG, "pendingScore(playerNum=%d, curTurn=%d)", + Log.d( TAG, "pendingScore(playerNum=%d, curTurn=%b)", playerNum, curTurn ); int otherIndx = (0 == (flags & CELL_ISCURSOR)) @@ -530,7 +561,7 @@ public class BoardCanvas extends Canvas implements DrawCtx { fillRectOther( rect, otherIndx ); int playerColor = m_playerColors[playerNum]; - if ( playerNum != curTurn ) { + if ( !curTurn ) { playerColor &= NOT_TURN_ALPHA; } m_fillPaint.setColor( playerColor ); 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 b05f32151..4e0e770d9 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 @@ -1,6 +1,6 @@ /* -*- compile-command: "find-and-gradle.sh inXw4dDeb"; -*- */ /* - * Copyright 2009 - 2017 by Eric House (xwords@eehouse.org). All rights + * Copyright 2009 - 2019 by Eric House (xwords@eehouse.org). All rights * reserved. * * This program is free software; you can redistribute it and/or @@ -33,6 +33,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.text.TextUtils; +import android.text.format.DateUtils; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; @@ -59,6 +60,7 @@ import org.eehouse.android.xw4.jni.CommsAddrRec.CommsConnTypeSet; import org.eehouse.android.xw4.jni.CommsAddrRec; import org.eehouse.android.xw4.jni.CurGameInfo.DeviceRole; import org.eehouse.android.xw4.jni.CurGameInfo; +import org.eehouse.android.xw4.jni.DUtilCtxt; import org.eehouse.android.xw4.jni.GameSummary; import org.eehouse.android.xw4.jni.JNIThread.JNICmd; import org.eehouse.android.xw4.jni.JNIThread; @@ -85,6 +87,8 @@ public class BoardDelegate extends DelegateBase private static final String SAVE_MYSIS = TAG + "/MYSIS"; + static final String PAUSER_KEY = TAG + "/pauser"; + private Activity m_activity; private BoardView m_view; private GamePtr m_jniGamePtr; @@ -366,6 +370,31 @@ public class BoardDelegate extends DelegateBase } break; + case ASK_DUP_PAUSE: { + final boolean isPause = (Boolean)params[0]; + final ConfirmPauseView pauseView = + ((ConfirmPauseView)inflate( R.layout.pause_view )) + .setIsPause( isPause ); + int buttonId = isPause ? R.string.board_menu_game_pause + : R.string.board_menu_game_unpause; + dialog = ab + .setTitle(isPause ? R.string.pause_title : R.string.unpause_title) + .setView( pauseView ) + .setPositiveButton( buttonId, new OnClickListener() { + @Override + public void + onClick( DialogInterface dlg, + int whichButton ) { + String msg = pauseView.getMsg(); + handleViaThread( isPause ? JNICmd.CMD_PAUSE + : JNICmd.CMD_UNPAUSE, msg ); + } + }) + .setNegativeButton( android.R.string.cancel, null ) + .create(); + } + break; + case QUERY_ENDGAME: dialog = ab.setTitle( R.string.query_title ) .setMessage( R.string.ids_endnow ) @@ -565,8 +594,8 @@ public class BoardDelegate extends DelegateBase mNFCWrapper = Wrapper.init( m_activity, this, devID ); m_utils = new BoardUtilCtxt(); - m_timers = new TimerRunnable[4]; // needs to be in sync with - // XWTimerReason + // needs to be in sync with XWTimerReason + m_timers = new TimerRunnable[UtilCtxt.NUM_TIMERS_PLUS_ONE]; m_view = (BoardView)findViewById( R.id.board_view ); if ( ! ABUtils.haveActionBar() ) { m_tradeButtons = findViewById( R.id.exchange_buttons ); @@ -644,6 +673,7 @@ public class BoardDelegate extends DelegateBase } } + @Override protected void onPause() { Wrapper.setResumed( mNFCWrapper, false ); @@ -672,7 +702,6 @@ public class BoardDelegate extends DelegateBase if ( null != m_jniThreadRef ) { m_jniThreadRef.release(); m_jniThreadRef = null; - // Assert.assertNull( m_jniThreadRef ); // firing } GamesListDelegate.boardDestroyed( m_rowid ); super.onDestroy(); @@ -770,7 +799,11 @@ public class BoardDelegate extends DelegateBase @Override protected void setTitle() { - setTitle( GameUtils.getName( m_activity, m_rowid ) ); + String title = GameUtils.getName( m_activity, m_rowid ); + if ( null != m_gi && m_gi.inDuplicateMode ) { + title = LocUtils.getString( m_activity, R.string.dupe_title_fmt, title ); + } + setTitle( title ); } private void initToolbar() @@ -849,6 +882,11 @@ public class BoardDelegate extends DelegateBase m_gsi.canTrade ); Utils.setItemVisible( menu, R.id.board_menu_undo_last, m_gsi.canUndo ); + + Utils.setItemVisible( menu, R.id.board_menu_game_pause, + m_gsi.canPause ); + Utils.setItemVisible( menu, R.id.board_menu_game_unpause, + m_gsi.canUnpause ); } Utils.setItemVisible( menu, R.id.board_menu_trade_cancel, inTrade ); @@ -905,7 +943,7 @@ public class BoardDelegate extends DelegateBase JNICmd cmd = JNICmd.CMD_NONE; Runnable proc = null; - int id = item.getItemId(); + final int id = item.getItemId(); switch ( id ) { case R.id.board_menu_done: int nTiles = XwJNI.model_getNumTilesInTray( m_jniGamePtr, @@ -985,6 +1023,11 @@ public class BoardDelegate extends DelegateBase .show(); break; + case R.id.board_menu_game_pause: + case R.id.board_menu_game_unpause: + getConfirmPause( R.id.board_menu_game_pause == id ); + break; + // small devices only case R.id.board_menu_dict: String dictName = m_gi.dictName( m_view.getCurPlayer() ); @@ -1600,13 +1643,13 @@ public class BoardDelegate extends DelegateBase private void deleteAndClose( int gameID ) { - if ( gameID == m_gi.gameID ) { - GameUtils.deleteGame( m_activity, m_jniThread.getLock(), false ); - waitCloseGame( false ); - finish(); + if ( null != m_gi && gameID == m_gi.gameID ) { + GameUtils.deleteGame( m_activity, m_jniThread.getLock(), false, false ); } else { Log.e( TAG, "deleteAndClose() called with wrong gameID %d", gameID ); } + waitCloseGame( false ); + finish(); } private void askDropRelay() @@ -1757,6 +1800,19 @@ public class BoardDelegate extends DelegateBase handleViaThread( JNICmd.CMD_REMAINING, R.string.tiles_left_title ); } + @Override + public void timerSelected( boolean inDuplicateMode, final boolean canPause ) + { + if ( inDuplicateMode ) { + runOnUiThread( new Runnable() { + @Override + public void run() { + getConfirmPause( canPause ); + } + } ); + } + } + @Override public void setIsServer( boolean isServer ) { @@ -1843,6 +1899,7 @@ public class BoardDelegate extends DelegateBase int inHowLong; switch ( why ) { case UtilCtxt.TIMER_COMMS: + case UtilCtxt.TIMER_DUP_TIMERCHECK: inHowLong = when * 1000; break; case UtilCtxt.TIMER_TIMERTICK: @@ -1942,6 +1999,20 @@ public class BoardDelegate extends DelegateBase showDialogFragment( DlgID.QUERY_TRADE, dlgBytes ); } + @Override + public void notifyDupStatus( boolean amHost, final String msg ) + { + final int key = amHost ? R.string.key_na_dupstatus_host + : R.string.key_na_dupstatus_guest; + runOnUiThread( new Runnable() { + @Override + public void run() { + makeNotAgainBuilder( msg, key ) + .show(); + } + } ); + } + @Override public void userError( int code ) { @@ -2166,6 +2237,38 @@ public class BoardDelegate extends DelegateBase } } ); } + + @Override + public String formatPauseHistory( int pauseTyp, int player, + int whenPrev, int whenCur, String msg ) + { + Log.d( TAG, "formatPauseHistory(prev: %d, cur: %d)", whenPrev, whenCur ); + String result = null; + String name = 0 > player ? null : m_gi.players[player].name; + switch ( pauseTyp ) { + case DUtilCtxt.UNPAUSED: + String interval = DateUtils + .formatElapsedTime( whenCur - whenPrev ) + .toString(); + result = LocUtils.getString( m_activity, R.string.history_unpause_fmt, + name, interval ); + break; + case DUtilCtxt.PAUSED: + result = LocUtils.getString( m_activity, R.string.history_pause_fmt, + name ); + break; + case DUtilCtxt.AUTOPAUSED: + result = LocUtils.getString( m_activity, R.string.history_autopause ); + break; + } + + if ( null != msg ) { + result += " " + LocUtils + .getString( m_activity, R.string.history_msg_fmt, msg ); + } + + return result; + } } // class BoardUtilCtxt private void doResume( boolean isStart ) @@ -2225,11 +2328,6 @@ public class BoardDelegate extends DelegateBase invalidateOptionsMenuIf(); } break; - case JNIThread.GOT_WORDS: - CurGameInfo gi = m_jniThreadRef.getGI(); - launchLookup( wordsToArray((String)msg.obj), - gi.dictLang ); - break; case JNIThread.GAME_OVER: if ( m_isFirstLaunch ) { runOnUiThread( new Runnable() { @@ -2249,6 +2347,16 @@ public class BoardDelegate extends DelegateBase showToast( getQuantityString( R.plurals.resent_msgs_fmt, nSent, nSent ) ); break; + + case JNIThread.GOT_PAUSE: + runOnUiThread( new Runnable() { + @Override + public void run() { + makeOkOnlyBuilder( (String)msg.obj ) + .show(); + } + } ); + break; } } }; @@ -2303,10 +2411,11 @@ public class BoardDelegate extends DelegateBase } } if ( 0 != flags ) { - DBUtils.setMsgFlags( m_rowid, GameSummary.MSG_FLAGS_NONE ); + DBUtils.setMsgFlags( m_activity, m_rowid, + GameSummary.MSG_FLAGS_NONE ); } - Utils.cancelNotification( m_activity, (int)m_rowid ); + Utils.cancelNotification( m_activity, m_rowid ); askNBSPermissions(); @@ -2315,6 +2424,11 @@ public class BoardDelegate extends DelegateBase tickle( isStart ); tryInvites(); } + + Bundle args = getArguments(); + if ( args.getBoolean( PAUSER_KEY, false ) ) { + getConfirmPause( true ); + } } } // resumeGame @@ -2547,6 +2661,11 @@ public class BoardDelegate extends DelegateBase names, locs ); } + private void getConfirmPause( boolean isPause ) + { + showDialogFragment( DlgID.ASK_DUP_PAUSE, isPause ); + } + private void closeIfFinishing( boolean force ) { if ( null == m_handler ) { diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardView.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardView.java index 00e06ec0b..3466b4c80 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardView.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/BoardView.java @@ -209,7 +209,7 @@ public class BoardView extends View implements BoardHandler, SyncedDraw { } else { Bitmap bitmap = s_bitmap; if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ) { - bitmap = Bitmap.createBitmap(bitmap); + bitmap = Bitmap.createBitmap( bitmap ); } canvas.drawBitmap( bitmap, 0, 0, new Paint() ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Channels.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Channels.java index 1c65c247f..38b9d426c 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Channels.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/Channels.java @@ -20,22 +20,27 @@ package org.eehouse.android.xw4; -import android.os.Build; -import android.content.Context; -import java.util.HashSet; -import java.util.Set; import android.app.NotificationChannel; import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; public class Channels { + private static final String TAG = Channels.class.getSimpleName(); - enum ID { - NBSPROXY(R.string.nbsproxy_channel_expl, - NotificationManager.IMPORTANCE_LOW), - GAME_EVENT(R.string.gameevent_channel_expl, - NotificationManager.IMPORTANCE_LOW), - SERVICE_STALL(R.string.servicestall_channel_expl, - NotificationManager.IMPORTANCE_LOW); + public enum ID { + NBSPROXY( R.string.nbsproxy_channel_expl ) + ,GAME_EVENT( R.string.gameevent_channel_expl ) + ,SERVICE_STALL( R.string.servicestall_channel_expl ) + ,DUP_TIMER_RUNNING( R.string.dup_timer_expl ) + ,DUP_PAUSED( R.string.dup_paused_expl ) + ; private int mExpl; private int mImportance; @@ -45,7 +50,15 @@ public class Channels { mImportance = imp; } + private ID( int expl ) + { + this( expl, NotificationManager.IMPORTANCE_LOW ); + } + public int getDesc() { return mExpl; } + public int idFor( long rowid ) { + return notificationId( rowid, this ); + } private int getImportance() { return mImportance; } } @@ -71,4 +84,63 @@ public class Channels { } return name; } + + private static final String IDS_KEY = TAG + "/ids_key"; + + private static class IdsData implements Serializable { + HashMap> mMap = new HashMap<>(); + HashSet mInts = new HashSet<>(); + + int newID() + { + int result; + for ( ; ; ) { + int one = Utils.nextRandomInt(); + if ( !mInts.contains( one ) ) { + mInts.add( one ); + result = one; + break; + } + } + return result; + } + } + private static IdsData sData; + + // I want each rowid to be able to have a notification active for it for + // each channel. So let's try generating and storing random ints. + private static int notificationId( long rowid, ID channel ) + { + Context context = XWApp.getContext(); + int result; + synchronized ( Channels.class ) { + if ( null == sData ) { + sData = (IdsData)DBUtils.getSerializableFor( context, IDS_KEY ); + if ( null == sData ) { + sData = new IdsData(); + } + } + } + + synchronized ( sData ) { + boolean dirty = false; + if ( ! sData.mMap.containsKey( channel ) ) { + sData.mMap.put( channel, new HashMap() ); + dirty = true; + } + Map map = sData.mMap.get( channel ); + if ( ! map.containsKey( rowid ) ) { + map.put( rowid, sData.newID() ); + dirty = true; + } + + if ( dirty ) { + DBUtils.setSerializableFor( context, IDS_KEY, sData ); + } + + result = map.get( rowid ); + } + Log.d( TAG, "notificationId(%s, %d) => %d", channel, rowid, result ); + return result; + } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConfirmPauseView.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConfirmPauseView.java new file mode 100644 index 000000000..476cbed8c --- /dev/null +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/ConfirmPauseView.java @@ -0,0 +1,165 @@ +/* -*- 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.text.Editable; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import java.util.HashSet; + +// Edit text should start out empty + +public class ConfirmPauseView extends LinearLayout + implements View.OnClickListener, OnItemSelectedListener, EditWClear.TextWatcher { + private static final String TAG = ConfirmPauseView.class.getSimpleName(); + private static final String PAUSE_MSGS_KEY = TAG + "/pause_msgs"; + private static final String UNPAUSE_MSGS_KEY = TAG + "/unpause_msgs"; + + private Boolean mIsPause; + private boolean mInflateFinished; + private boolean mInited; + private HashSet mSavedMsgs; + private Button mForgetButton; + private Button mRememberButton; + private Spinner mSpinner; + private EditWClear mMsgEdit; + + public ConfirmPauseView( Context context, AttributeSet as ) { + super( context, as ); + } + + @Override + protected void onFinishInflate() + { + mInflateFinished = true; + initIfReady(); + } + + private void initIfReady() + { + if ( !mInited && mInflateFinished && null != mIsPause ) { + mInited = true; + + Context context = getContext(); + + int id = mIsPause ? R.string.pause_expl : R.string.unpause_expl; + ((TextView)findViewById(R.id.confirm_pause_expl)) + .setText( id ); + + mForgetButton = (Button)findViewById( R.id.pause_forget_msg ); + mForgetButton.setOnClickListener( this ); + mRememberButton = (Button)findViewById( R.id.pause_save_msg ); + mRememberButton.setOnClickListener( this ); + mSpinner = (Spinner)findViewById( R.id.saved_msgs ); + mMsgEdit = (EditWClear)findViewById( R.id.msg_edit ); + mMsgEdit.addTextChangedListener( this ); + + String key = mIsPause ? PAUSE_MSGS_KEY : UNPAUSE_MSGS_KEY; + mSavedMsgs = (HashSet)DBUtils + .getSerializableFor( context, key ); + if ( null == mSavedMsgs ) { + mSavedMsgs = new HashSet<>(); + } + + populateSpinner(); + mSpinner.setOnItemSelectedListener( this ); + setMsg( "" ); + // onTextChanged( "" ); + } + } + + private void populateSpinner() + { + final ArrayAdapter adapter = + new ArrayAdapter<>( getContext(), android.R.layout.simple_spinner_item ); + for ( String msg : mSavedMsgs ) { + adapter.add( msg ); + } + mSpinner.setAdapter( adapter ); + } + + @Override + public void onItemSelected( AdapterView parent, View spinner, + int position, long id ) + { + String msg = (String)parent.getAdapter().getItem( position ); + setMsg( msg ); + onTextChanged( msg ); + } + + @Override + public void onNothingSelected( AdapterView p ) {} + + @Override + public void onClick( View view ) + { + Log.d( TAG, "onClick() called" ); + String msg = getMsg(); + if ( view == mRememberButton && 0 < msg.length() ) { + mSavedMsgs.add( msg ); + } else if ( view == mForgetButton ) { + mSavedMsgs.remove( msg ); + setMsg( "" ); + } else { + Assert.assertFalse( BuildConfig.DEBUG ); + } + String key = mIsPause ? PAUSE_MSGS_KEY : UNPAUSE_MSGS_KEY; + DBUtils.setSerializableFor( getContext(), key, mSavedMsgs ); + populateSpinner(); + } + + // from EditWClear.TextWatcher + @Override + public void onTextChanged( String msg ) + { + Log.d( TAG, "onTextChanged(%s)", msg ); + boolean hasText = 0 < msg.length(); + boolean matches = mSavedMsgs.contains( msg ); + mForgetButton.setEnabled( hasText && matches ); + mRememberButton.setEnabled( hasText && !matches ); + } + + ConfirmPauseView setIsPause( boolean isPause ) + { + mIsPause = isPause; + initIfReady(); + return this; + } + + String getMsg() + { + return mMsgEdit.getText().toString(); + } + + private void setMsg( String msg ) + { + mMsgEdit.setText( msg ); + } +} diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBHelper.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBHelper.java index d13738faa..9e81a6113 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBHelper.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBHelper.java @@ -1,6 +1,6 @@ /* -*- compile-command: "find-and-gradle.sh inXw4dDeb"; -*- */ /* - * Copyright 2009-2016 by Eric House (xwords@eehouse.org). All + * Copyright 2009 - 2019 by Eric House (xwords@eehouse.org). All * rights reserved. * * This program is free software; you can redistribute it and/or @@ -56,7 +56,7 @@ public class DBHelper extends SQLiteOpenHelper { private int addedVersion() { return mAddedVersion; } } private static final String DB_NAME = BuildConfig.DB_NAME; - private static final int DB_VERSION = 29; + private static final int DB_VERSION = 30; public static final String GAME_NAME = "GAME_NAME"; public static final String VISID = "VISID"; @@ -90,6 +90,7 @@ public class DBHelper extends SQLiteOpenHelper { public static final String SEED = "SEED"; public static final String SMSPHONE = "SMSPHONE"; // unused -- so far public static final String LASTMOVE = "LASTMOVE"; + public static final String NEXTDUPTIMER = "NEXTDUPTIMER"; public static final String NEXTNAG = "NEXTNAG"; public static final String GROUPID = "GROUPID"; public static final String NPACKETSPENDING = "NPACKETSPENDING"; @@ -159,6 +160,7 @@ public class DBHelper extends SQLiteOpenHelper { ,{ REMOTEDEVS, "TEXT" } ,{ EXTRAS, "TEXT" } // json data, most likely ,{ LASTMOVE, "INTEGER DEFAULT 0" } + ,{ NEXTDUPTIMER, "INTEGER DEFAULT 0" } ,{ NEXTNAG, "INTEGER DEFAULT 0" } ,{ GROUPID, "INTEGER" } // HASMSGS: sqlite doesn't have bool; use 0 and 1 @@ -347,6 +349,10 @@ public class DBHelper extends SQLiteOpenHelper { if ( !madeChatTable ) { addColumn( db, TABLE_NAMES.CHAT, s_chatsSchema, CHATTIME ); } + case 29: + if ( !madeSumTable ) { + addSumColumn( db, NEXTDUPTIMER ); + } break; default: diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBUtils.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBUtils.java index 3c7ab8322..25f53e470 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBUtils.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DBUtils.java @@ -85,7 +85,8 @@ public class DBUtils { }; public static interface DBChangeListener { - public void gameSaved( long rowid, GameChangeType change ); + public void gameSaved( Context context, long rowid, + GameChangeType change ); } private static HashSet s_listeners = new HashSet(); @@ -146,7 +147,7 @@ public class DBUtils { DBHelper.SCORES, DBHelper.LASTPLAY_TIME, DBHelper.REMOTEDEVS, DBHelper.LASTMOVE, DBHelper.NPACKETSPENDING, - DBHelper.EXTRAS, + DBHelper.EXTRAS, DBHelper.NEXTDUPTIMER, }; String selection = String.format( ROW_ID_FMT, lock.getRowid() ); @@ -194,6 +195,8 @@ public class DBUtils { summary.gameOver = tmp != 0; summary.lastMoveTime = cursor.getInt(cursor.getColumnIndex(DBHelper.LASTMOVE)); + summary.dupTimerExpires = + cursor.getInt(cursor.getColumnIndex(DBHelper.NEXTDUPTIMER)); String str = cursor .getString(cursor.getColumnIndex(DBHelper.EXTRAS)); summary.setExtras( str ); @@ -298,6 +301,7 @@ public class DBUtils { values.put( DBHelper.GAMEID, summary.gameID ); values.put( DBHelper.GAME_OVER, summary.gameOver? 1 : 0 ); values.put( DBHelper.LASTMOVE, summary.lastMoveTime ); + values.put( DBHelper.NEXTDUPTIMER, summary.dupTimerExpires ); // Don't overwrite extras! Sometimes this method is called from // JNIThread which has created the summary from common code that @@ -357,7 +361,7 @@ public class DBUtils { long result = update( TABLE_NAMES.SUM, values, selection ); Assert.assertTrue( result >= 0 ); } - notifyListeners( rowid, GameChangeType.GAME_CHANGED ); + notifyListeners( context, rowid, GameChangeType.GAME_CHANGED ); invalGroupsCache(); } @@ -663,10 +667,10 @@ public class DBUtils { updateRow( null, TABLE_NAMES.SUM, rowid, values ); } - public static void setMsgFlags( long rowid, int flags ) + public static void setMsgFlags( Context context, long rowid, int flags ) { setSummaryInt( rowid, DBHelper.HASMSGS, flags ); - notifyListeners( rowid, GameChangeType.GAME_CHANGED ); + notifyListeners( context, rowid, GameChangeType.GAME_CHANGED ); } public static void setExpanded( long rowid, boolean expanded ) @@ -730,7 +734,7 @@ public class DBUtils { Assert.assertTrue( result >= 0 ); - notifyListeners( rowid, GameChangeType.GAME_CHANGED ); + notifyListeners( context, rowid, GameChangeType.GAME_CHANGED ); } } @@ -742,7 +746,7 @@ public class DBUtils { synchronized( s_dbHelper ) { long result = update( TABLE_NAMES.SUM, values, null ); - notifyListeners( ROWIDS_ALL, GameChangeType.GAME_CHANGED ); + notifyListeners( context, ROWIDS_ALL, GameChangeType.GAME_CHANGED ); } } @@ -1066,7 +1070,7 @@ public class DBUtils { lock = GameLock.tryLock( rowid ); Assert.assertNotNull( lock ); - notifyListeners( rowid, GameChangeType.GAME_CREATED ); + notifyListeners( context, rowid, GameChangeType.GAME_CREATED ); } invalGroupsCache(); // then again after @@ -1092,7 +1096,7 @@ public class DBUtils { setCached( rowid, null ); // force reread if ( ROWID_NOTFOUND != rowid ) { // Means new game? - notifyListeners( rowid, GameChangeType.GAME_CHANGED ); + notifyListeners( context, rowid, GameChangeType.GAME_CHANGED ); } invalGroupsCache(); return rowid; @@ -1154,7 +1158,7 @@ public class DBUtils { deleteCurChatsSync( s_db, rowid ); } - notifyListeners( lock.getRowid(), GameChangeType.GAME_DELETED ); + notifyListeners( context, lock.getRowid(), GameChangeType.GAME_DELETED ); invalGroupsCache(); } @@ -1775,7 +1779,47 @@ public class DBUtils { values.put( DBHelper.GROUPID, groupID ); updateRow( context, TABLE_NAMES.SUM, rowid, values ); invalGroupsCache(); - notifyListeners( rowid, GameChangeType.GAME_MOVED ); + notifyListeners( context, rowid, GameChangeType.GAME_MOVED ); + } + + public static Map getDupModeGames( Context context ) + { + return getDupModeGames( context, ROWID_NOTFOUND ); + } + + // Return all games whose DUP_MODE_MASK bit is set. Return also (as map + // value) the nextTimer value, which will be negative if the game's + // paused. As a bit of a hack, set it to 0 if the local player has already + // committed his turn so caller (DupeModeTimer) will know not to show a + // notification. + public static Map getDupModeGames( Context context, long rowid ) + { + // select giflags from summaries where 0x100 & giflags != 0; + Map result = new HashMap<>(); + String[] columns = { ROW_ID, DBHelper.NEXTDUPTIMER, DBHelper.TURN_LOCAL }; + String selection = String.format( "%d & %s != 0", + GameSummary.DUP_MODE_MASK, + DBHelper.GIFLAGS ); + if ( ROWID_NOTFOUND != rowid ) { + selection += String.format( " AND %s = %d", ROW_ID, rowid ); + } + + initDB( context ); + synchronized( s_dbHelper ) { + Cursor cursor = query( TABLE_NAMES.SUM, columns, selection ); + int count = cursor.getCount(); + int indxRowid = cursor.getColumnIndex( ROW_ID ); + int indxTimer = cursor.getColumnIndex( DBHelper.NEXTDUPTIMER ); + int indxIsLocal = cursor.getColumnIndex( DBHelper.TURN_LOCAL ); + while ( cursor.moveToNext() ) { + boolean isLocal = 0 != cursor.getInt( indxIsLocal ); + int timer = isLocal ? cursor.getInt( indxTimer ) : 0; + result.put( cursor.getLong( indxRowid ), timer ); + } + cursor.close(); + } + Log.d( TAG, "getDupModeGames(%d) => %s", rowid, result ); + return result; } private static String getChatHistoryStr( Context context, long rowid ) @@ -2592,12 +2636,13 @@ public class DBUtils { } } - private static void notifyListeners( long rowid, GameChangeType change ) + private static void notifyListeners( Context context, long rowid, + GameChangeType change ) { synchronized( s_listeners ) { Iterator iter = s_listeners.iterator(); while ( iter.hasNext() ) { - iter.next().gameSaved( rowid, change ); + iter.next().gameSaved( context, rowid, change ); } } } 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 e4fcc33ff..e7f107ca5 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 @@ -99,18 +99,23 @@ public class DbgUtils { Log.d( tag, stackTrace ); } - static String extrasToString( Intent intent ) + static String extrasToString( Bundle extras ) { - Bundle bundle = intent.getExtras(); ArrayList al = new ArrayList(); - if ( null != bundle ) { - for ( String key : bundle.keySet() ) { - al.add( key + ":" + bundle.get(key) ); + if ( null != extras ) { + for ( String key : extras.keySet() ) { + al.add( key + ":" + extras.get(key) ); } } return TextUtils.join( ", ", al ); } + static String extrasToString( Intent intent ) + { + Bundle bundle = intent.getExtras(); + return extrasToString( bundle ); + } + public static void dumpCursor( Cursor cursor ) { String dump = DatabaseUtils.dumpCursorToString( cursor ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictLangCache.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictLangCache.java index 8c239ccce..9c6995154 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictLangCache.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictLangCache.java @@ -45,8 +45,8 @@ import java.util.Set; public class DictLangCache { private static final String TAG = DictLangCache.class.getSimpleName(); - private static String[] s_langNames; - private static HashMap s_langCodes; + private static Map s_langNames; + private static Map s_langCodes; private static int s_adaptedLang = -1; private static LangsArrayAdapter s_langsAdapter; @@ -151,11 +151,12 @@ public class DictLangCache { public static String getLangName( Context context, int code ) { - String[] namesArray = getLangNames( context ); - if ( code < 0 || code >= namesArray.length ) { - code = 0; + Map namesArray = getLangNames( context ); + String name = namesArray.get( code ); + if ( null == name ) { + name = namesArray.get( 0 ); } - return namesArray[code]; + return name; } // This populates the cache and will take significant time if it's @@ -248,7 +249,7 @@ public class DictLangCache { for ( DictAndLoc dal : dals ) { DictInfo info = getInfo( context, dal ); int langCode = info.langCode; - if ( langCode >= s_langNames.length ) { + if ( !s_langNames.containsKey( langCode ) ) { langCode = 0; } if ( null != info && code == langCode ) { @@ -305,13 +306,11 @@ public class DictLangCache { public static int getLangLangCode( Context context, String lang ) { - int code = 0; - String[] namesArray = getLangNames( context ); - for ( int ii = 0; ii < namesArray.length; ++ii ) { - if ( namesArray[ii].equals( lang ) ) { - code = ii; - break; - } + getLangNames( context ); /* inits s_langCodes */ + + Integer code = s_langCodes.get( lang ); + if ( null == code ) { + code = 0; } return code; } @@ -431,16 +430,24 @@ public class DictLangCache { return s_dictsAdapter; } - public static String[] getLangNames( Context context ) + private static Map getLangNames( Context context ) { if ( null == s_langNames ) { Resources res = context.getResources(); - s_langNames = res.getStringArray( R.array.language_names ); + String[] names = res.getStringArray( R.array.language_names ); - s_langCodes = new HashMap(); - for ( int ii = 0; ii < s_langNames.length; ++ii ) { - s_langCodes.put( s_langNames[ii], ii ); + s_langCodes = new HashMap<>(); + s_langNames = new HashMap<>(); + for ( int ii = 0; ii < names.length; ++ii ) { + String name = names[ii]; + s_langCodes.put( name, ii ); + s_langNames.put( ii, name ); } + + // Hex is out-of-order, so can't be in the res-based array. Hard + // code it: it's a hack anyway. + s_langCodes.put( "Hex", 127 ); + s_langNames.put( 127, "Hex" ); } return s_langNames; } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictsDelegate.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictsDelegate.java index aefc2cea2..766a39095 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictsDelegate.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DictsDelegate.java @@ -503,7 +503,7 @@ public class DictsDelegate extends ListDelegateBase int lang = args.getInt( DICT_LANG_EXTRA, 0 ); if ( 0 < lang ) { - m_filterLang = DictLangCache.getLangNames( m_activity )[lang]; + m_filterLang = DictLangCache.getLangName( m_activity, lang ); m_closedLangs.remove( m_filterLang ); } String name = args.getString( DICT_NAME_EXTRA ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DlgID.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DlgID.java index d8d9b7759..226600b65 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DlgID.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DlgID.java @@ -67,6 +67,7 @@ public enum DlgID { , GAMES_LIST_NEWGAME , CHANGE_CONN , GAMES_LIST_NAME_REMATCH + , ASK_DUP_PAUSE ; private boolean m_addToStack; diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DupeModeTimer.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DupeModeTimer.java new file mode 100644 index 000000000..55aab7c3d --- /dev/null +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/DupeModeTimer.java @@ -0,0 +1,294 @@ +/* -*- 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.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.SystemClock; + +import java.text.DateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import org.eehouse.android.xw4.DBUtils.GameChangeType; +import org.eehouse.android.xw4.jni.CurGameInfo; +import org.eehouse.android.xw4.jni.JNIThread; +import org.eehouse.android.xw4.jni.XwJNI.GamePtr; +import org.eehouse.android.xw4.jni.XwJNI; +import org.eehouse.android.xw4.loc.LocUtils; + +/** + * This class owns the problem of timers in duplicate-mode games. Unlike the + * existing timers that run only when a game is open and visible, they run + * with the clock for any game where it's a local player's turn. So this + * module needs to be aware of all games in that state and to be counting + * their timers down at all times. For each game for which a timer's running + * it's either 1) sending updates to the game (if it's open) OR 2) keeping an + * unhideable notification open with the relevant time counting down. + */ + +public class DupeModeTimer extends BroadcastReceiver { + private static final String TAG = DupeModeTimer.class.getSimpleName(); + + private static final Channels.ID sMyChannel = Channels.ID.DUP_TIMER_RUNNING; + + private static RowidQueue sQueue; + private static Map sDirtyVals = new HashMap<>(); + + private static DateFormat s_df = + DateFormat.getTimeInstance( /*DateFormat.SHORT*/ ); + private static long sCurTimer = Long.MAX_VALUE; + + static { + sQueue = new RowidQueue(); + sQueue.start(); + + DBUtils.setDBChangeListener( new DBUtils.DBChangeListener() { + @Override + public void gameSaved( Context context, long rowid, + GameChangeType change ) + { + Log.d( TAG, "gameSaved(rowid=%d,change=%s) called", rowid, change ); + switch( change ) { + case GAME_CHANGED: + case GAME_CREATED: + synchronized ( sDirtyVals ) { + if ( sDirtyVals.containsKey( rowid ) ) { + sQueue.addOne( context, rowid ); + } else { + Log.d( TAG, "skipping; not dirty" ); + } + } + break; + case GAME_DELETED: + cancelNotification( context, rowid ); + break; + } + } + } ); + + } + + @Override + public void onReceive( Context context, Intent intent ) + { + Log.d( TAG, "onReceive()" ); + sCurTimer = Long.MAX_VALUE; // clear so we'll set again + sQueue.addAll( context ); + } + + /** + * Called when + */ + static void init( Context context ) + { + Log.d( TAG, "init()" ); + sQueue.addAll( context ); + } + + public static void gameOpened( Context context, long rowid ) + { + Log.d( TAG, "gameOpened(%s, %d)", context, rowid ); + sQueue.addOne( context, rowid ); + } + + public static void gameClosed( Context context, long rowid ) + { + Log.d( TAG, "gameClosed(%s, %d)", context, rowid ); + sQueue.addOne( context, rowid ); + } + + // public static void timerPauseChanged( Context context, long rowid ) + // { + // sQueue.addOne( context, rowid ); + // } + + public static void timerChanged( Context context, int gameID, int newVal ) + { + long[] rowids = DBUtils.getRowIDsFor( context, gameID ); + for ( long rowid : rowids ) { + Log.d( TAG, "timerChanged(rowid=%d, newVal=%d)", rowid, newVal ); + synchronized ( sDirtyVals ) { + sDirtyVals.put( rowid, newVal ); + } + } + } + + private static void postNotification( Context context, long rowid, long when ) + { + Log.d( TAG, "postNotification(rowid=%d)", rowid ); + if ( !JNIThread.gameIsOpen( rowid ) ) { + String title = LocUtils.getString( context, R.string.dup_notif_title ); + if ( BuildConfig.DEBUG ) { + title += " (" + rowid + ")"; + } + String body = context.getString( R.string.dup_notif_title_fmt, + s_df.format( new Date( 1000 * when ) ) ); + Intent intent = GamesListDelegate.makeRowidIntent( context, rowid ); + + Intent pauseIntent = GamesListDelegate.makeRowidIntent( context, rowid ); + pauseIntent.putExtra( BoardDelegate.PAUSER_KEY, true ); + Utils.postOngoingNotification( context, intent, title, body, + rowid, sMyChannel, + pauseIntent, R.string.board_menu_game_pause ); + } else { + Log.d( TAG, "postOngoingNotification(%d): open, so skipping", rowid ); + } + } + + private static void cancelNotification( Context context, long rowid ) + { + Log.d( TAG, "cancelNotification(rowid=%d)", rowid ); + Utils.cancelNotification( context, sMyChannel, rowid ); + } + + private static void setTimer( Context context, long whenSeconds ) + { + if ( whenSeconds < sCurTimer ) { + sCurTimer = whenSeconds; + Intent intent = new Intent( context, DupeModeTimer.class ); + PendingIntent pi = PendingIntent.getBroadcast( context, 0, intent, 0 ); + + long now = Utils.getCurSeconds(); + long fire_millis = SystemClock.elapsedRealtime() + + (1000 * (whenSeconds - now)); + + ((AlarmManager)context.getSystemService( Context.ALARM_SERVICE )) + .set( AlarmManager.ELAPSED_REALTIME, fire_millis, pi ); + } + } + + private static class RowidQueue extends Thread { + private Set mSet = new HashSet<>(); + private Context mContext; + + void addAll( Context context ) + { + addOne( context, 0 ); + } + + synchronized void addOne( Context context, long rowid ) + { + mContext = context; + synchronized ( mSet ) { + mSet.add( rowid ); + mSet.notify(); + } + } + + @Override + public void run() + { + long rowid = DBUtils.ROWID_NOTFOUND; + for ( ; ; ) { + synchronized( mSet ) { + mSet.remove( rowid ); + if ( 0 == mSet.size() ) { + try { + mSet.wait(); + Assert.assertTrue( 0 < mSet.size() ); + } catch ( InterruptedException ie ) { + break; + } + } + rowid = mSet.iterator().next(); + } + inventoryGames( rowid ); + } + } + + private void inventoryGames( long onerow ) + { + Log.d( TAG, "inventoryGames(%d)", onerow ); + Map dupeGames = onerow == 0 + ? DBUtils.getDupModeGames( mContext ) + : DBUtils.getDupModeGames( mContext, onerow ); + + Log.d( TAG, "inventoryGames(%s)", dupeGames ); + long now = Utils.getCurSeconds(); + long minTimer = sCurTimer; + + for ( long rowid : dupeGames.keySet() ) { + int timerFires = dupeGames.get( rowid ); + + synchronized ( sDirtyVals ) { + if ( sDirtyVals.containsKey(rowid) && timerFires == sDirtyVals.get(rowid) ) { + sDirtyVals.remove(rowid); + } + } + + if ( timerFires > now ) { + Log.d( TAG, "found dupe game with %d seconds left", + timerFires - now ); + postNotification( mContext, rowid, timerFires ); + if ( timerFires < minTimer ) { + minTimer = timerFires; + } + } else { + cancelNotification( mContext, rowid ); + Log.d( TAG, "found dupe game with expired or inactive timer" ); + if ( timerFires > 0 ) { + giveGameTime( rowid ); + } + } + } + + setTimer( mContext, minTimer ); + } + + private void giveGameTime( long rowid ) + { + Log.d( TAG, "giveGameTime(%d)() starting", rowid ); + try ( GameLock lock = GameLock.tryLock( rowid ) ) { + if ( null != lock ) { + CurGameInfo gi = new CurGameInfo( mContext ); + MultiMsgSink sink = new MultiMsgSink( mContext, rowid ); + try ( final XwJNI.GamePtr gamePtr = GameUtils + .loadMakeGame( mContext, gi, sink, lock ) ) { // calls getJNI() + Log.d( TAG, "got gamePtr: %H", gamePtr ); + if ( null != gamePtr ) { + boolean draw = false; + for ( int ii = 0; ii < 3; ++ii ) { + draw = XwJNI.server_do( gamePtr ) || draw; + } + + GameUtils.saveGame( mContext, gamePtr, gi, lock, false ); + + if ( draw && XWPrefs.getThumbEnabled( mContext ) ) { + Bitmap bitmap = GameUtils + .takeSnapshot( mContext, gamePtr, gi ); + DBUtils.saveThumbnail( mContext, lock, bitmap ); + } + } + } + } + } + Log.d( TAG, "giveGameTime(%d)() DONE", rowid ); + } + } +} diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/EditWClear.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/EditWClear.java index a88a5a16d..d94e754b8 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/EditWClear.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/EditWClear.java @@ -23,14 +23,33 @@ import android.widget.SearchView; import android.content.Context; import android.util.AttributeSet; -public class EditWClear extends SearchView { +import java.util.HashSet; +import java.util.Set; + +public class EditWClear extends SearchView + implements SearchView.OnQueryTextListener { private static final String TAG = EditWClear.class.getSimpleName(); + private Set mWatchers; + + public interface TextWatcher { + void onTextChanged( String newText ); + } + public EditWClear( Context context, AttributeSet as ) { super( context, as ); } + synchronized void addTextChangedListener( TextWatcher proc ) + { + if ( null == mWatchers ) { + mWatchers = new HashSet<>(); + setOnQueryTextListener( this ); + } + mWatchers.add( proc ); + } + void setText( String txt ) { super.setQuery( txt, false ); @@ -40,4 +59,22 @@ public class EditWClear extends SearchView { { return super.getQuery(); } + + // from SearchView.OnQueryTextListener + @Override + public synchronized boolean onQueryTextChange( String newText ) + { + for ( TextWatcher proc : mWatchers ) { + proc.onTextChanged( newText ); + } + return true; + } + + // from SearchView.OnQueryTextListener + @Override + public boolean onQueryTextSubmit( String query ) + { + Assert.assertFalse( BuildConfig.DEBUG ); + return true; + } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameConfigDelegate.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameConfigDelegate.java index 923d2c3be..2969243c3 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameConfigDelegate.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameConfigDelegate.java @@ -123,6 +123,7 @@ public class GameConfigDelegate extends DelegateBase R.id.room_spinner, R.id.refresh_button, R.id.hints_allowed, + R.id.duplicate_check, R.id.pick_faceup, R.id.boardsize_spinner, R.id.use_timer, @@ -620,26 +621,66 @@ public class GameConfigDelegate extends DelegateBase setSmartnessSpinner(); + tweakTimerStuff(); + setChecked( R.id.hints_allowed, !m_gi.hintsNotAllowed ); setChecked( R.id.pick_faceup, m_gi.allowPickTiles ); - setInt( R.id.timer_minutes_edit, - m_gi.gameSeconds/60/m_gi.nPlayers ); + setBoardsizeSpinner(); + } + } // loadGame + + private boolean mTimerStuffInited = false; + private void tweakTimerStuff() + { + // one-time only stuff + if ( ! mTimerStuffInited ) { + mTimerStuffInited = true; + + // dupe-mode check is GONE by default (in the .xml) + if ( CommonPrefs.getDupModeHidden( m_activity ) ) { + setChecked( R.id.duplicate_check, false ); + } else { + CheckBox check = (CheckBox)findViewById( R.id.duplicate_check ); + check.setVisibility( View.VISIBLE ); + check.setChecked( m_gi.inDuplicateMode ); + check.setOnCheckedChangeListener( new OnCheckedChangeListener() { + @Override + public void onCheckedChanged( CompoundButton buttonView, + boolean checked ) { + tweakTimerStuff(); + } + } ); + } CheckBox check = (CheckBox)findViewById( R.id.use_timer ); OnCheckedChangeListener lstnr = new OnCheckedChangeListener() { public void onCheckedChanged( CompoundButton buttonView, boolean checked ) { - showTimerSet( checked ); + tweakTimerStuff(); } }; check.setOnCheckedChangeListener( lstnr ); - setChecked( R.id.use_timer, m_gi.timerEnabled ); - showTimerSet( m_gi.timerEnabled ); - - setBoardsizeSpinner(); + check.setChecked( m_gi.timerEnabled ); } - } // loadGame + + boolean dupModeChecked = getChecked( R.id.duplicate_check ); + CheckBox check = (CheckBox)findViewById( R.id.use_timer ); + check.setText( dupModeChecked ? R.string.use_duptimer : R.string.use_timer ); + + boolean timerSet = getChecked( R.id.use_timer ); + showTimerSet( timerSet ); + + int id = dupModeChecked ? R.string.dup_minutes_label : R.string.minutes_label; + TextView label = (TextView)findViewById(R.id.timer_label ); + label.setText( id ); + + // setInt( R.id.timer_minutes_edit, + // m_gi.gameSeconds/60/m_gi.nPlayers ); + + // setChecked( R.id.use_timer, m_gi.timerEnabled ); + // showTimerSet( m_gi.timerEnabled ); + } private void showTimerSet( boolean show ) { @@ -852,7 +893,7 @@ public class GameConfigDelegate extends DelegateBase private void deleteGame() { - GameUtils.deleteGame( m_activity, m_rowid, false ); + GameUtils.deleteGame( m_activity, m_rowid, false, false ); } private void loadPlayersList() @@ -1199,11 +1240,19 @@ public class GameConfigDelegate extends DelegateBase } } + m_gi.inDuplicateMode = getChecked( R.id.duplicate_check ); m_gi.hintsNotAllowed = !getChecked( R.id.hints_allowed ); m_gi.allowPickTiles = getChecked( R.id.pick_faceup ); m_gi.timerEnabled = getChecked( R.id.use_timer ); - m_gi.gameSeconds = - 60 * m_gi.nPlayers * getInt( R.id.timer_minutes_edit ); + + // Get timer value. It's per-move minutes in duplicate mode, otherwise + // it's for the whole game. + int seconds = 60 * getInt( R.id.timer_minutes_edit ); + if ( m_gi.inDuplicateMode ) { + m_gi.gameSeconds = seconds; + } else { + m_gi.gameSeconds = seconds * m_gi.nPlayers; + } int position = m_phoniesSpinner.getSelectedItemPosition(); m_gi.phoniesAction = CurGameInfo.XWPhoniesChoice.values()[position]; 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 53a310e65..21b49355c 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 @@ -348,6 +348,9 @@ public class GameListItem extends LinearLayout m_role.setText( roleSummary ); } + findViewById( R.id.dup_tag ) + .setVisibility( summary.inDuplicateMode() ? View.VISIBLE : View.GONE ); + update( expanded, summary.lastMoveTime, haveATurn, haveALocalTurn ); } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameUtils.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameUtils.java index 2048127a1..d3cf43356 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameUtils.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/GameUtils.java @@ -179,7 +179,7 @@ public class GameUtils { tellDied( context, lock, true ); resetGame( context, lock, lock, DBUtils.GROUPID_UNSPEC, false ); - Utils.cancelNotification( context, (int)rowidIn ); + Utils.cancelNotification( context, rowidIn ); success = true; } else { DbgUtils.toastNoLock( TAG, context, rowidIn, @@ -299,11 +299,13 @@ public class GameUtils { } public static void deleteGame( Context context, GameLock lock, - boolean informNow ) + boolean informNow, boolean skipTell ) { if ( null != lock ) { - tellDied( context, lock, informNow ); - Utils.cancelNotification( context, (int)lock.getRowid() ); + if ( !skipTell ) { + tellDied( context, lock, informNow ); + } + Utils.cancelNotification( context, lock.getRowid() ); DBUtils.deleteGame( context, lock ); } else { Log.e( TAG, "deleteGame(): null lock; doing nothing" ); @@ -311,13 +313,13 @@ public class GameUtils { } public static boolean deleteGame( Context context, long rowid, - boolean informNow ) + boolean informNow, boolean skipTell ) { boolean success; // does this need to be synchronized? try ( GameLock lock = GameLock.tryLock( rowid ) ) { if ( null != lock ) { - deleteGame( context, lock, informNow ); + deleteGame( context, lock, informNow, skipTell ); success = true; } else { DbgUtils.toastNoLock( TAG, context, rowid, @@ -334,7 +336,7 @@ public class GameUtils { int nSuccesses = 0; long[] rowids = DBUtils.getGroupGames( context, groupid ); for ( int ii = rowids.length - 1; ii >= 0; --ii ) { - if ( deleteGame( context, rowids[ii], ii == 0 ) ) { + if ( deleteGame( context, rowids[ii], ii == 0, false ) ) { ++nSuccesses; } } @@ -938,8 +940,18 @@ public class GameUtils { public static void launchGame( Delegator delegator, long rowid, boolean invited ) + { + launchGame( delegator, rowid, invited, null ); + } + + public static void launchGame( Delegator delegator, long rowid, + boolean invited, Bundle moreExtras ) { Bundle extras = makeLaunchExtras( rowid, invited ); + if ( null != moreExtras ) { + extras.putAll( moreExtras ); + } + if ( delegator.inDPMode() ) { delegator.addFragment( BoardFrag.newInstance( delegator ), extras ); } else { @@ -1055,7 +1067,7 @@ public class GameUtils { if ( GameSummary.MSG_FLAGS_NONE != flags ) { draw = true; int curFlags = DBUtils.getMsgFlags( context, rowid ); - DBUtils.setMsgFlags( rowid, flags | curFlags ); + DBUtils.setMsgFlags( context, rowid, flags | curFlags ); } } } @@ -1245,7 +1257,7 @@ public class GameUtils { if ( 0 != titleID ) { String title = LocUtils.getString( context, titleID, getName( context, rowid ) ); - Utils.postNotification( context, intent, title, msg, (int)rowid ); + Utils.postNotification( context, intent, title, msg, rowid ); } } else { Log.d( TAG, "postMoveNotification(): posting nothing for lack" @@ -1258,7 +1270,7 @@ public class GameUtils { { Intent intent = GamesListDelegate.makeGameIDIntent( context, gameID ); Utils.postNotification( context, intent, R.string.invite_notice_title, - body, (int)rowid ); + body, rowid ); } private static void tellDied( Context context, GameLock lock, 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 40422657c..55d393796 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 @@ -1,7 +1,7 @@ /* -*- compile-command: "find-and-gradle.sh inXw4dDeb"; -*- */ /* - * Copyright 2009 - 2016 by Eric House (xwords@eehouse.org). All - * rights reserved. + * Copyright 2009 - 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 @@ -1012,6 +1012,17 @@ public class GamesListDelegate extends ListDelegateBase } else if ( isFirstLaunch ) { warnSMSBannedIf(); } + + Set dupModeGames = DBUtils.getDupModeGames( m_activity ).keySet(); + long[] asArray = new long[dupModeGames.size()]; + int ii = 0; + for ( long rowid : dupModeGames ) { + Log.d( TAG, "row %d is dup-mode", rowid ); + asArray[ii++] = rowid; + } + if ( false ) { + deleteGames( asArray, true ); + } } // init @Override @@ -1172,7 +1183,9 @@ public class GamesListDelegate extends ListDelegateBase ////////////////////////////////////////////////////////////////////// // DBUtils.DBChangeListener interface ////////////////////////////////////////////////////////////////////// - public void gameSaved( final long rowid, final GameChangeType change ) + @Override + public void gameSaved( Context context, final long rowid, + final GameChangeType change ) { post( new Runnable() { public void run() { @@ -1324,7 +1337,7 @@ public class GamesListDelegate extends ListDelegateBase mkListAdapter(); break; case DELETE_GAMES: - deleteGames( (long[])params[0] ); + deleteGames( (long[])params[0], false ); break; case OPEN_GAME: doOpenGame( params ); @@ -2140,7 +2153,7 @@ public class GamesListDelegate extends ListDelegateBase return launched; } - private boolean startFirstHasDict( final long rowid ) + private boolean startFirstHasDict( final long rowid, final Bundle extras ) { boolean handled = -1 != rowid && DBUtils.haveGame( m_activity, rowid ); if ( handled ) { @@ -2154,7 +2167,7 @@ public class GamesListDelegate extends ListDelegateBase .gameDictsHere( m_activity, lock ); lock.release(); if ( haveDict ) { - launchGame( rowid ); + launchGame( rowid, extras ); } } } @@ -2171,7 +2184,7 @@ public class GamesListDelegate extends ListDelegateBase String[] relayIDs = intent.getStringArrayExtra( RELAYIDS_EXTRA ); if ( !startFirstHasDict( relayIDs ) ) { long rowid = intent.getLongExtra( ROWID_EXTRA, -1 ); - result = startFirstHasDict( rowid ); + result = startFirstHasDict( rowid, intent.getExtras() ); } } return result; @@ -2455,10 +2468,10 @@ public class GamesListDelegate extends ListDelegateBase } } - private void deleteGames( long[] rowids ) + private void deleteGames( long[] rowids, boolean skipTell ) { for ( long rowid : rowids ) { - GameUtils.deleteGame( m_activity, rowid, false ); + GameUtils.deleteGame( m_activity, rowid, false, skipTell ); m_mySIS.selGames.remove( rowid ); } invalidateOptionsMenuIf(); @@ -2547,7 +2560,7 @@ public class GamesListDelegate extends ListDelegateBase return madeGame; } - private void launchGame( long rowid, boolean invited ) + private void launchGame( long rowid, boolean invited, Bundle extras ) { if ( DBUtils.ROWID_NOTFOUND == rowid ) { Log.d( TAG, "launchGame(): dropping bad rowid" ); @@ -2556,20 +2569,25 @@ public class GamesListDelegate extends ListDelegateBase if ( m_adapter.inExpandedGroup( rowid ) ) { setSelGame( rowid ); } - GameUtils.launchGame( getDelegator(), rowid, invited ); + GameUtils.launchGame( getDelegator(), rowid, invited, extras ); } } private void launchGame( long rowid ) { - launchGame( rowid, false ); + launchGame( rowid, false, null ); + } + + private void launchGame( long rowid, Bundle extras ) + { + launchGame( rowid, false, extras ); } private void makeNewNetGame( NetLaunchInfo nli ) { long rowid = DBUtils.ROWID_NOTFOUND; rowid = GameUtils.makeNewMultiGame( m_activity, nli ); - launchGame( rowid, true ); + launchGame( rowid, true, null ); } private void tryStartsFromIntent( Intent intent ) diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiService.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiService.java index 220a64935..1b33a4cf1 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiService.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/MultiService.java @@ -51,6 +51,7 @@ public class MultiService { public static final String BT_ADDRESS = "BT_ADDRESS"; public static final String P2P_MAC_ADDRESS = "P2P_MAC_ADDRESS"; private static final String NLI_DATA = "nli"; + public static final String DUPEMODE = "du"; public enum DictFetchOwner { _NONE, OWNER_SMS, diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NagTurnReceiver.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NagTurnReceiver.java index e7cd05885..2e3d15b5b 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NagTurnReceiver.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/NagTurnReceiver.java @@ -101,7 +101,7 @@ public class NagTurnReceiver extends BroadcastReceiver { } Utils.postNotification( context, msgIntent, R.string.nag_title, body, - (int)rowid ); + rowid ); } DBUtils.updateNeedNagging( context, needNagging ); 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 d118120b9..6151437cc 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 @@ -65,6 +65,7 @@ public class NetLaunchInfo implements Serializable { private static final String FORCECHANNEL_KEY = "fc"; private static final String NAME_KEY = "nm"; private static final String P2P_MAC_KEY = "p2"; + private static final String DUPMODE_KEY = "du"; protected String gameName; protected String dict; @@ -87,6 +88,7 @@ public class NetLaunchInfo implements Serializable { private CommsConnTypeSet m_addrs; private boolean m_valid; private String inviteID; + private boolean dupeMode; public NetLaunchInfo() { @@ -244,6 +246,8 @@ public class NetLaunchInfo implements Serializable { val = data.getQueryParameter( FORCECHANNEL_KEY ); forceChannel = null == val ? 0 : Integer.decode( val ); gameName = data.getQueryParameter( NAME_KEY ); + val = data.getQueryParameter( DUPMODE_KEY ); + dupeMode = null != val && Integer.decode(val) != 0; } calcValid(); } catch ( Exception e ) { @@ -254,7 +258,7 @@ public class NetLaunchInfo implements Serializable { } private NetLaunchInfo( int gamID, String gamNam, int dictLang, - String dictName, int nPlayers ) + String dictName, int nPlayers, boolean dupMode ) { this(); gameName = gamNam; @@ -263,6 +267,7 @@ public class NetLaunchInfo implements Serializable { nPlayersT = nPlayers; nPlayersH = 1; gameID = gamID; + dupeMode = dupMode; } public NetLaunchInfo( Context context, GameSummary summary, CurGameInfo gi, @@ -275,7 +280,8 @@ public class NetLaunchInfo implements Serializable { public NetLaunchInfo( CurGameInfo gi ) { - this( gi.gameID, gi.getName(), gi.dictLang, gi.dictName, gi.nPlayers ); + this( gi.gameID, gi.getName(), gi.dictLang, gi.dictName, gi.nPlayers, + gi.inDuplicateMode ); } public NetLaunchInfo( Context context, GameSummary summary, CurGameInfo gi ) @@ -350,12 +356,17 @@ public class NetLaunchInfo implements Serializable { bundle.putString( MultiService.GAMENAME, gameName ); bundle.putInt( MultiService.NPLAYERST, nPlayersT ); bundle.putInt( MultiService.NPLAYERSH, nPlayersH ); - bundle.putBoolean( MultiService.REMOTES_ROBOTS, remotesAreRobots ); + if ( remotesAreRobots ) { + bundle.putBoolean( MultiService.REMOTES_ROBOTS, true ); + } bundle.putInt( MultiService.GAMEID, gameID() ); bundle.putString( MultiService.BT_NAME, btName ); bundle.putString( MultiService.BT_ADDRESS, btAddress ); bundle.putString( MultiService.P2P_MAC_ADDRESS, p2pMacAddress ); bundle.putInt( MultiService.FORCECHANNEL, forceChannel ); + if ( dupeMode ) { + bundle.putBoolean( MultiService.DUPEMODE, true ); + } int flags = m_addrs.toInt(); bundle.putInt( ADDRS_KEY, flags ); @@ -374,6 +385,7 @@ public class NetLaunchInfo implements Serializable { && forceChannel == other.forceChannel && nPlayersT == other.nPlayersT && nPlayersH == other.nPlayersH + && dupeMode == other.dupeMode && remotesAreRobots == other.remotesAreRobots && TextUtils.equals( room, other.room ) && TextUtils.equals( btName, other.btName ) @@ -406,7 +418,12 @@ public class NetLaunchInfo implements Serializable { .put( MultiService.NPLAYERSH, nPlayersH ) .put( MultiService.REMOTES_ROBOTS, remotesAreRobots ) .put( MultiService.GAMEID, gameID() ) - .put( MultiService.FORCECHANNEL, forceChannel ); + .put( MultiService.FORCECHANNEL, forceChannel ) + ; + + if ( dupeMode ) { + obj.put( MultiService.DUPEMODE, dupeMode ); + } if ( m_addrs.contains( CommsConnType.COMMS_CONN_RELAY ) ) { obj.put( MultiService.ROOM, room ) @@ -477,6 +494,7 @@ public class NetLaunchInfo implements Serializable { lang = json.optInt( MultiService.LANG, -1 ); forceChannel = json.optInt( MultiService.FORCECHANNEL, 0 ); + dupeMode = json.optBoolean( MultiService.DUPEMODE, false ); dict = json.optString( MultiService.DICT ); gameName = json.optString( MultiService.GAMENAME ); nPlayersT = json.optInt( MultiService.NPLAYERST, -1 ); @@ -549,6 +567,9 @@ public class NetLaunchInfo implements Serializable { appendInt( ub, FORCECHANNEL_KEY, forceChannel ); appendInt( ub, ADDRS_KEY, addrs ); ub.appendQueryParameter( NAME_KEY, gameName ); + if ( dupeMode ) { + appendInt( ub, DUPMODE_KEY, 1 ); + } if ( null != dict ) { ub.appendQueryParameter( WORDLIST_KEY, dict ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/PrefsDelegate.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/PrefsDelegate.java index af465ae55..dc188fc52 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/PrefsDelegate.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/PrefsDelegate.java @@ -36,8 +36,8 @@ import android.preference.PreferenceScreen; import android.view.View; import android.widget.Button; - import org.eehouse.android.xw4.DlgDelegate.Action; +import org.eehouse.android.xw4.jni.CommonPrefs; import org.eehouse.android.xw4.loc.LocUtils; import java.io.File; @@ -387,6 +387,10 @@ public class PrefsDelegate extends DelegateBase if ( null == FBMService.getFCMDevID( m_activity ) ) { hideOne( R.string.key_show_fcm, R.string.pref_group_relay_title ); } + + if ( CommonPrefs.getDupModeHidden( m_activity ) ) { + hideOne( R.string.key_init_dupmodeon, R.string.key_prefs_defaults ); + } } public static void launch( Context context ) 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 65e39dbe5..b0af8bd73 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 @@ -1330,6 +1330,10 @@ public class RelayService extends XWJIService if ( null == udpSocket ) { // will be null if e.g. device or emulator doesn't have network udpSocket = getService().connectSocketOnce(); // block until this is done + // Assert.assertTrue( null != udpSocket || !BuildConfig.DEBUG ); // firing + if ( null == udpSocket ) { + Log.e( TAG, "connectSocketOnce() failed; no socket" ); + } } byte[] buf = new byte[1024]; diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/StudyListDelegate.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/StudyListDelegate.java index cf4575ebf..b2ab06d33 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/StudyListDelegate.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/StudyListDelegate.java @@ -326,11 +326,11 @@ public class StudyListDelegate extends ListDelegateBase startLang = startIntent.getIntExtra( START_LANG, NO_LANG ); } - String[] names = DictLangCache.getLangNames( m_activity ); String[] myNames = new String[m_langCodes.length]; for ( int ii = 0; ii < m_langCodes.length; ++ii ) { int lang = m_langCodes[ii]; - myNames[ii] = xlateLang( names[lang], true ); + String name = DictLangCache.getLangName( m_activity, lang ); + myNames[ii] = xlateLang( name, true ); if ( lang == startLang ) { startIndex = ii; } 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 00765770d..d461b4b7e 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 @@ -88,6 +88,8 @@ public class Utils { private static final String FIRST_VERSION_KEY = "FIRST_VERSION_KEY"; private static final String SHOWN_VERSION_KEY = "SHOWN_VERSION_KEY"; + private static final Channels.ID sDefaultChannel = Channels.ID.GAME_EVENT; + private static Boolean s_isFirstBootThisVersion = null; private static Boolean s_firstVersion = null; private static Boolean s_isFirstBootEver = null; @@ -244,24 +246,68 @@ public class Utils { LocUtils.getString( context, bodyID ), id ); } + public static void postNotification( Context context, Intent intent, + String title, String body, long rowid ) + { + int id = sDefaultChannel.idFor( rowid ); + postNotification( context, intent, title, body, id ); + } + + public static void postNotification( Context context, Intent intent, + int titleId, String body, long rowid ) + { + postNotification( context, intent, titleId, body, rowid, + sDefaultChannel ); + } + public static void postNotification( Context context, Intent intent, int titleID, String body, int id ) + { + postNotification( context, intent, titleID, body, id, + sDefaultChannel ); + } + + public static void postNotification( Context context, Intent intent, + int titleID, String body, long rowid, + Channels.ID channel ) + { + int id = channel.idFor( rowid ); + postNotification( context, intent, titleID, body, id, channel ); + } + + private static void postNotification( Context context, Intent intent, + int titleID, String body, int id, + Channels.ID channel ) { String title = LocUtils.getString( context, titleID ); - postNotification( context, intent, title, body, id ); + // Log.d( TAG, "posting with title %s", title ); + postNotification( context, intent, title, body, id, channel, false, + null, 0 ); } public static void postNotification( Context context, Intent intent, String title, String body, int id ) { - String channelID = Channels.getChannelID( context, Channels.ID.GAME_EVENT ); - postNotification( context, intent, title, body, id, channelID ); + postNotification( context, intent, title, body, id, + sDefaultChannel, false, null, 0 ); + } + + static void postOngoingNotification( Context context, Intent intent, + String title, String body, + long rowid, Channels.ID channel, + Intent actionIntent, + int actionString ) + { + int id = channel.idFor( rowid ); + postNotification( context, intent, title, body, id, channel, true, + actionIntent, actionString ); } private static void postNotification( Context context, Intent intent, String title, String body, - int id, String channelID ) + int id, Channels.ID channel, boolean ongoing, + Intent actionIntent, int actionString ) { /* nextRandomInt: per this link http://stackoverflow.com/questions/10561419/scheduling-more-than-one-pendingintent-to-same-activity-using-alarmmanager @@ -269,9 +315,8 @@ public class Utils { Intents is to send a different second param each time, though the docs say that param's ignored. */ - PendingIntent pi = null == intent ? null - : PendingIntent.getActivity( context, nextRandomInt(), intent, - PendingIntent.FLAG_ONE_SHOT ); + PendingIntent pi = null == intent + ? null : getPendingIntent( context, intent ); int defaults = Notification.FLAG_AUTO_CANCEL; if ( CommonPrefs.getSoundNotify( context ) ) { @@ -281,42 +326,63 @@ public class Utils { defaults |= Notification.DEFAULT_VIBRATE; } - Notification notification = + String channelID = Channels.getChannelID( context, channel ); + NotificationCompat.Builder builder = new NotificationCompat.Builder( context, channelID ) .setContentIntent( pi ) .setSmallIcon( R.drawable.notify ) //.setTicker(body) //.setWhen(time) + .setOngoing( ongoing ) .setAutoCancel( true ) .setDefaults( defaults ) .setContentTitle( title ) .setContentText( body ) - .build(); + ; + + if ( null != actionIntent ) { + PendingIntent actionPI = getPendingIntent( context, actionIntent ); + builder.addAction( 0, LocUtils.getString(context, actionString), + actionPI ); + } + + Notification notification = builder.build(); NotificationManager nm = (NotificationManager) context.getSystemService( Context.NOTIFICATION_SERVICE ); nm.notify( id, notification ); } + private static PendingIntent getPendingIntent( Context context, Intent intent ) + { + PendingIntent pi = PendingIntent + .getActivity( context, Utils.nextRandomInt(), intent, + PendingIntent.FLAG_ONE_SHOT ); + return pi; + } + private static final String KEY_LAST_STALL_NOT = TAG + ".last_stall_note"; private static final long MIN_STALL_NOTE_INTERVAL_MS = 1000 * 60 * 30; - public static void showStallNotification( Context context, long ageMS ) + public static void showStallNotification( Context context, String typ, + long ageMS ) { + String body = LocUtils.getString( context, R.string.notify_stall_body_fmt, + typ, (ageMS + 500) / 1000, + MIN_STALL_NOTE_INTERVAL_MS / (1000 * 60)); + long now = System.currentTimeMillis(); long lastStallNotify = DBUtils.getLongFor( context, KEY_LAST_STALL_NOT, 0 ); if ( now - lastStallNotify > MIN_STALL_NOTE_INTERVAL_MS ) { String title = LocUtils.getString( context, R.string.notify_stall_title ); - String body = LocUtils.getString( context, R.string.notify_stall_body_fmt, - (ageMS + 500) / 1000, - MIN_STALL_NOTE_INTERVAL_MS / (1000 * 60)); - String channelID = Channels.getChannelID( context, - Channels.ID.SERVICE_STALL ); - Intent intent = GamesListDelegate .makeAlertWithEmailIntent( context, body ); postNotification( context, intent, title, body, - R.string.notify_stall_title, channelID ); + R.string.notify_stall_title, + Channels.ID.SERVICE_STALL, false, null, 0 ); DBUtils.setLongFor( context, KEY_LAST_STALL_NOT, now ); + } else { + Log.e( TAG, "stalled, but too recent for notification: %s", + body ); } } @@ -328,6 +394,18 @@ public class Utils { cancelNotification( context, R.string.notify_stall_title ); } + public static void cancelNotification( Context context, Channels.ID channel, + long rowid ) + { + int id = channel.idFor( rowid ); + cancelNotification( context, id ); + } + + public static void cancelNotification( Context context, long rowid ) + { + cancelNotification( context, sDefaultChannel, rowid ); + } + public static void cancelNotification( Context context, int id ) { NotificationManager nm = (NotificationManager) @@ -522,6 +600,7 @@ public class Utils { return result; } + // Called from andutils.c in the jni world public static long getCurSeconds() { // Note: an int is big enough for *seconds* (not milliseconds) since 1970 diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWApp.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWApp.java index b459101a2..b0d993c2a 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWApp.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWApp.java @@ -97,6 +97,8 @@ public class XWApp extends Application mPort = Short.valueOf( getString( R.string.nbs_port ) ); NBSProxy.register( this, mPort, BuildConfig.APPLICATION_ID, this ); + + DupeModeTimer.init( this ); } @OnLifecycleEvent(ON_ANY) diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWJIService.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWJIService.java index 05bdf5a4e..3307c93c5 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWJIService.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/XWJIService.java @@ -132,6 +132,7 @@ abstract class XWJIService extends JobIntentService { if ( stallCheckEnabled( context ) ) { long now = System.currentTimeMillis(); long maxAge = 0; + String maxName = null; synchronized ( sPendingIntents ) { for ( String simpleName : sPendingIntents.keySet() ) { List intents = sPendingIntents.get( simpleName ); @@ -141,6 +142,7 @@ abstract class XWJIService extends JobIntentService { long age = now - timestamp; if ( age > maxAge ) { maxAge = age; + maxName = simpleName; } } } @@ -148,7 +150,7 @@ abstract class XWJIService extends JobIntentService { if ( maxAge > AGE_THRESHOLD_MS ) { // ConnStatusHandler.noteStall( sTypes.get( clazz ), maxAge ); - Utils.showStallNotification( context, maxAge ); + Utils.showStallNotification( context, maxName, maxAge ); } } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommonPrefs.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommonPrefs.java index ec56b5658..c1014ab3a 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommonPrefs.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CommonPrefs.java @@ -237,6 +237,16 @@ public class CommonPrefs extends XWPrefs { return getPrefsBoolean( context, key, true ); } + public static boolean getDefaultDupMode( Context context ) + { + return getPrefsBoolean( context, R.string.key_init_dupmodeon, false ); + } + + public static boolean getDupModeHidden( Context context ) + { + return !getPrefsBoolean( context, R.string.key_unhide_dupmode, false ); + } + public static boolean getAutoJuggle( Context context ) { return getPrefsBoolean( context, R.string.key_init_autojuggle, false ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CurGameInfo.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CurGameInfo.java index eedeaa81e..7fc26fecc 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CurGameInfo.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/CurGameInfo.java @@ -49,6 +49,7 @@ public class CurGameInfo implements Serializable { private static final String TIMER = "TIMER"; private static final String ALLOW_PICK = "ALLOW_PICK"; private static final String PHONIES = "PHONIES"; + private static final String DUP = "DUP"; public enum XWPhoniesChoice { PHONIES_IGNORE, PHONIES_WARN, PHONIES_DISALLOW }; public enum DeviceRole { SERVER_STANDALONE, SERVER_ISSERVER, SERVER_ISCLIENT }; @@ -63,6 +64,7 @@ public class CurGameInfo implements Serializable { public int forceChannel; public DeviceRole serverRole; + public boolean inDuplicateMode; public boolean hintsNotAllowed; public boolean timerEnabled; public boolean allowPickTiles; @@ -83,8 +85,9 @@ public class CurGameInfo implements Serializable { { boolean isNetworked = null != inviteID; nPlayers = 2; - gameSeconds = 60 * nPlayers * - CommonPrefs.getDefaultPlayerMinutes( context ); + inDuplicateMode = CommonPrefs.getDefaultDupMode( context ); + gameSeconds = inDuplicateMode ? (5 * 60) + : 60 * nPlayers * CommonPrefs.getDefaultPlayerMinutes( context ); boardSize = CommonPrefs.getDefaultBoardSize( context ); players = new LocalPlayer[MAX_NUM_PLAYERS]; serverRole = isNetworked ? DeviceRole.SERVER_ISCLIENT @@ -142,6 +145,7 @@ public class CurGameInfo implements Serializable { dictName = src.dictName; dictLang = src.dictLang; hintsNotAllowed = src.hintsNotAllowed; + inDuplicateMode = src.inDuplicateMode; phoniesAction = src.phoniesAction; timerEnabled = src.timerEnabled; allowPickTiles = src.allowPickTiles; @@ -169,6 +173,8 @@ public class CurGameInfo implements Serializable { } sb.append( "], gameID: ").append( gameID ) .append( ", hashCode: ").append( hashCode() ) + .append( ", timerEnabled: ").append( timerEnabled ) + .append( ", gameSeconds: ").append( gameSeconds ) .append('}'); result = sb.toString(); @@ -185,6 +191,7 @@ public class CurGameInfo implements Serializable { JSONObject obj = new JSONObject() .put( BOARD_SIZE, boardSize ) .put( NO_HINTS, hintsNotAllowed ) + .put( DUP, inDuplicateMode ) .put( TIMER, timerEnabled ) .put( ALLOW_PICK, allowPickTiles ) .put( PHONIES, phoniesAction.ordinal() ) @@ -204,6 +211,7 @@ public class CurGameInfo implements Serializable { JSONObject obj = new JSONObject( jsonData ); boardSize = obj.optInt( BOARD_SIZE, boardSize ); hintsNotAllowed = obj.optBoolean( NO_HINTS, hintsNotAllowed ); + inDuplicateMode = obj.optBoolean( DUP, inDuplicateMode ); timerEnabled = obj.optBoolean( TIMER, timerEnabled ); allowPickTiles = obj.optBoolean( ALLOW_PICK, allowPickTiles ); int tmp = obj.optInt( PHONIES, phoniesAction.ordinal() ); @@ -280,6 +288,7 @@ public class CurGameInfo implements Serializable { || dictLang != other.dictLang || boardSize != other.boardSize || hintsNotAllowed != other.hintsNotAllowed + || inDuplicateMode != other.inDuplicateMode || allowPickTiles != other.allowPickTiles || phoniesAction != other.phoniesAction; @@ -313,6 +322,7 @@ public class CurGameInfo implements Serializable { && boardSize == other.boardSize && forceChannel == other.forceChannel && hintsNotAllowed == other.hintsNotAllowed + && inDuplicateMode == other.inDuplicateMode && timerEnabled == other.timerEnabled && allowPickTiles == other.allowPickTiles && allowHintRect == other.allowHintRect diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DUtilCtxt.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DUtilCtxt.java index 14ecd7525..3ccbcd4ba 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DUtilCtxt.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DUtilCtxt.java @@ -21,14 +21,20 @@ package org.eehouse.android.xw4.jni; import android.content.Context; +import android.content.Intent; import android.telephony.PhoneNumberUtils; import org.eehouse.android.xw4.Assert; +import org.eehouse.android.xw4.Channels; import org.eehouse.android.xw4.DBUtils; import org.eehouse.android.xw4.DevID; +import org.eehouse.android.xw4.DupeModeTimer; import org.eehouse.android.xw4.FBMService; +import org.eehouse.android.xw4.GameUtils; +import org.eehouse.android.xw4.GamesListDelegate; import org.eehouse.android.xw4.Log; import org.eehouse.android.xw4.R; +import org.eehouse.android.xw4.Utils; import org.eehouse.android.xw4.XWApp; import org.eehouse.android.xw4.loc.LocUtils; @@ -102,15 +108,17 @@ public class DUtilCtxt { static final int STRD_CUMULATIVE_SCORE = 14; static final int STRS_NEW_TILES = 15; static final int STR_COMMIT_CONFIRM = 16; - static final int STR_BONUS_ALL = 17; - static final int STRD_TURN_SCORE = 18; - static final int STRD_REMAINS_HEADER = 19; - static final int STRD_REMAINS_EXPL = 20; - static final int STRSD_RESIGNED = 21; - static final int STRSD_WINNER = 22; - static final int STRDSD_PLACER = 23; + static final int STR_SUBMIT_CONFIRM = 17; + static final int STR_BONUS_ALL = 18; + static final int STRD_TURN_SCORE = 19; + static final int STRD_REMAINS_HEADER = 20; + static final int STRD_REMAINS_EXPL = 21; + static final int STRSD_RESIGNED = 22; + static final int STRSD_WINNER = 23; + static final int STRDSD_PLACER = 24; + static final int STR_DUP_CLIENT_SENT = 25; + static final int STRDD_DUP_HOST_RECEIVED = 26; - public String getUserString( int stringCode ) { Log.d( TAG, "getUserString(%d)", stringCode ); @@ -161,6 +169,9 @@ public class DUtilCtxt { case STR_COMMIT_CONFIRM: id = R.string.str_commit_confirm; break; + case STR_SUBMIT_CONFIRM: + id = R.string.str_submit_confirm; + break; case STR_BONUS_ALL: id = R.string.str_bonus_all; break; @@ -177,6 +188,14 @@ public class DUtilCtxt { id = R.string.str_placer_fmt; break; + case STR_DUP_CLIENT_SENT: + id = R.string.dup_client_sent; + break; + case STRDD_DUP_HOST_RECEIVED: + id = R.string.dup_host_received_fmt; + break; + + default: Log.w( TAG, "no such stringCode: %d", stringCode ); } @@ -236,4 +255,71 @@ public class DUtilCtxt { Log.d( TAG, "load(%s) returning %d bytes", key, resultLen ); return result; } + + + // Must match enum DupPauseType + public static final int UNPAUSED = 0; + public static final int PAUSED = 1; + public static final int AUTOPAUSED = 2; + + // A pause can come in when a game's open or when it's not. If it's open, + // we want to post an alert. If it's not, we want to post a notification, + // or at least kick off DupeModeTimer to cancel or start the timer-running + // notification. + public void notifyPause( int gameID, int pauseType, int pauser, + String pauserName, String expl ) + { + long[] rowids = DBUtils.getRowIDsFor( m_context, gameID ); + Log.d( TAG, "got %d games with gameid", null == rowids ? 0 : rowids.length ); + + final boolean isPause = UNPAUSED != pauseType; + + for ( long rowid : rowids ) { + String msg = msgForPause( rowid, pauseType, pauserName, expl ); + try ( JNIThread thread = JNIThread.getRetained( rowid ) ) { + if ( null != thread ) { + thread.notifyPause( pauser, isPause, msg ); + } else { + Intent intent = GamesListDelegate + .makeRowidIntent( m_context, rowid ); + int titleID = isPause ? R.string.game_paused_title + : R.string.game_unpaused_title; + Channels.ID channelID = Channels.ID.DUP_PAUSED; + Utils.postNotification( m_context, intent, titleID, msg, + rowid, channelID ); + + // DupeModeTimer.timerPauseChanged( m_context, rowid ); + } + } + } + } + + private String msgForPause( long rowid, int pauseType, String pauserName, String expl ) + { + String msg; + final String gameName = GameUtils.getName( m_context, rowid ); + if ( AUTOPAUSED == pauseType ) { + msg = LocUtils.getString( m_context, R.string.autopause_expl_fmt, + gameName ); + } else { + boolean isPause = PAUSED == pauseType; + if ( null != expl && 0 < expl.length() ) { + msg = LocUtils.getString( m_context, + isPause ? R.string.pause_notify_expl_fmt + : R.string.unpause_notify_expl_fmt, + pauserName, expl ); + } else { + msg = LocUtils.getString( m_context, + isPause ? R.string.pause_notify_fmt + : R.string.unpause_notify_fmt, + pauserName ); + } + } + return msg; + } + + public void onDupTimerChanged( int gameID, int oldVal, int newVal ) + { + DupeModeTimer.timerChanged( m_context, gameID, newVal ); + } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DrawCtx.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DrawCtx.java index f41b45262..9eb93ffed 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DrawCtx.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/DrawCtx.java @@ -57,7 +57,7 @@ public interface DrawCtx { // void score_drawPlayers( Rect scoreRect, DrawScoreInfo[] playerData, // Rect[] playerRects ); - void drawTimer( Rect rect, int player, int secondsLeft ); + void drawTimer( Rect rect, int player, int secondsLeft, boolean inDuplicateMode ); boolean drawCell( Rect rect, String text, int tile, int value, int owner, int bonus, int hintAtts, int flags ); @@ -69,8 +69,8 @@ public interface DrawCtx { int flags ); boolean drawTileBack( Rect rect, int flags ); void drawTrayDivider( Rect rect, int flags ); - void score_pendingScore( Rect rect, int score, int playerNum, int curTurn, - int flags ); + void score_pendingScore( Rect rect, int score, int playerNum, + boolean curTurn, int flags ); public static final int BONUS_NONE = 0; public static final int BONUS_DOUBLE_LETTER = 1; diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/GameSummary.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/GameSummary.java index 09ef06ca0..c51f3acfe 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/GameSummary.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/GameSummary.java @@ -37,6 +37,7 @@ import org.eehouse.android.xw4.Utils; import org.eehouse.android.xw4.XWApp; import org.eehouse.android.xw4.jni.CommsAddrRec.CommsConnType; import org.eehouse.android.xw4.jni.CommsAddrRec.CommsConnTypeSet; +import org.eehouse.android.xw4.jni.CurGameInfo; import org.eehouse.android.xw4.jni.CurGameInfo.DeviceRole; import org.eehouse.android.xw4.loc.LocUtils; @@ -55,8 +56,10 @@ public class GameSummary implements Serializable { public static final int MSG_FLAGS_CHAT = 2; public static final int MSG_FLAGS_GAMEOVER = 4; public static final int MSG_FLAGS_ALL = 7; + public static final int DUP_MODE_MASK = 1 << (CurGameInfo.MAX_NUM_PLAYERS * 2); public int lastMoveTime; // set by jni's server.c on move receipt + public int dupTimerExpires; public int nMoves; public int turn; public boolean turnIsLocal; @@ -110,6 +113,7 @@ public class GameSummary implements Serializable { GameSummary other = (GameSummary)obj; result = lastMoveTime == other.lastMoveTime && nMoves == other.nMoves + && dupTimerExpires == other.dupTimerExpires && turn == other.turn && turnIsLocal == other.turnIsLocal && nPlayers == other.nPlayers @@ -361,10 +365,21 @@ public class GameSummary implements Serializable { result |= 1 << (ii * 2); } } + + Assert.assertTrue( (result & DUP_MODE_MASK) == 0 ); + if ( m_gi.inDuplicateMode ) { + result |= DUP_MODE_MASK; + } } return result; } + public boolean inDuplicateMode() + { + int flags = giflags(); + return (flags & DUP_MODE_MASK) != 0; + } + public void setGiFlags( int flags ) { m_giFlags = new Integer( flags ); diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/JNIThread.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/JNIThread.java index bd820e255..925a65a10 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/JNIThread.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/JNIThread.java @@ -1,6 +1,6 @@ /* -*- compile-command: "find-and-gradle.sh inXw4dDeb"; -*- */ /* - * Copyright 2009 - 2017 by Eric House (xwords@eehouse.org). All rights + * Copyright 2009 - 2019 by Eric House (xwords@eehouse.org). All rights * reserved. * * This program is free software; you can redistribute it and/or @@ -33,11 +33,12 @@ import org.eehouse.android.xw4.ConnStatusHandler; import org.eehouse.android.xw4.DBUtils; import org.eehouse.android.xw4.DbgUtils; import org.eehouse.android.xw4.DictUtils; +import org.eehouse.android.xw4.DupeModeTimer; import org.eehouse.android.xw4.GameLock; import org.eehouse.android.xw4.GameUtils; -import org.eehouse.android.xw4.Utils; import org.eehouse.android.xw4.Log; import org.eehouse.android.xw4.R; +import org.eehouse.android.xw4.Utils; import org.eehouse.android.xw4.XWPrefs; import org.eehouse.android.xw4.jni.CommsAddrRec.CommsConnType; import org.eehouse.android.xw4.jni.CurGameInfo.DeviceRole; @@ -97,6 +98,8 @@ public class JNIThread extends Thread implements AutoCloseable { CMD_NETSTATS, CMD_PASS_PASSWD, CMD_SET_BLANK, + CMD_PAUSE, + CMD_UNPAUSE, // CMD_DRAW_CONNS_STATUS, // CMD_DRAW_BT_STATUS, // CMD_DRAW_SMS_STATUS, @@ -106,7 +109,7 @@ public class JNIThread extends Thread implements AutoCloseable { public static final int DIALOG = 2; public static final int QUERY_ENDGAME = 3; public static final int TOOLBAR_STATES = 4; - public static final int GOT_WORDS = 5; + public static final int GOT_PAUSE = 5; public static final int GAME_OVER = 6; public static final int MSGS_SENT = 7; @@ -124,6 +127,8 @@ public class JNIThread extends Thread implements AutoCloseable { public boolean curTurnSelected; public boolean canHideRack; public boolean canTrade; + public boolean canPause; + public boolean canUnpause; public GameStateInfo clone() { GameStateInfo obj = null; try { @@ -167,7 +172,7 @@ public class JNIThread extends Thread implements AutoCloseable { { m_lock = lock.retain(); m_rowid = lock.getRowid(); - m_queue = new LinkedBlockingQueue(); + m_queue = new LinkedBlockingQueue<>(); } public boolean configure( Context context, SyncedDraw drawer, @@ -239,6 +244,8 @@ public class JNIThread extends Thread implements AutoCloseable { } m_lastSavedState = Arrays.hashCode( stream ); + + DupeModeTimer.gameOpened( m_context, m_rowid ); } Log.d( TAG, "configure() => %b", success ); return success; @@ -729,6 +736,12 @@ public class JNIThread extends Thread implements AutoCloseable { ((Integer)args[2]).intValue() ); break; + case CMD_PAUSE: + XwJNI.board_pause( m_jniGamePtr, ((String)args[0]) ); + break; + case CMD_UNPAUSE: + XwJNI.board_unpause( m_jniGamePtr, ((String)args[0]) ); + break; case CMD_NONE: // ignored break; default: @@ -785,6 +798,12 @@ public class JNIThread extends Thread implements AutoCloseable { handle( JNICmd.CMD_SENDCHAT, chat ); } + public void notifyPause( int pauser, boolean isPause, String msg ) + { + Message.obtain( m_handler, GOT_PAUSE, msg ) + .sendToTarget(); + } + public void handle( JNICmd cmd, Object... args ) { if ( m_stopped && ! JNICmd.CMD_NONE.equals(cmd) ) { @@ -837,6 +856,7 @@ public class JNIThread extends Thread implements AutoCloseable { if ( stop ) { waitToStop( true ); + DupeModeTimer.gameClosed( m_context, m_rowid ); } else if ( save && 0 != m_lastSavedState ) { // has configure() run? handle( JNICmd.CMD_SAVE ); // in case releaser has made changes } @@ -873,4 +893,14 @@ public class JNIThread extends Thread implements AutoCloseable { } return result; } + + public static boolean gameIsOpen( long rowid ) + { + boolean result = false; + try ( JNIThread thread = JNIThread.getRetained( rowid ) ) { + result = null != thread; + } + Log.d( TAG, "gameIsOpen(%d) => %b", rowid, result ); + return result; + } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/LastMoveInfo.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/LastMoveInfo.java index bd738654e..f20ba573b 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/LastMoveInfo.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/LastMoveInfo.java @@ -1,6 +1,7 @@ /* -*- compile-command: "find-and-gradle.sh inXw4dDeb"; -*- */ /* - * Copyright 2014 by Eric House (xwords@eehouse.org). All rights reserved. + * Copyright 2014 - 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 @@ -20,6 +21,7 @@ package org.eehouse.android.xw4.jni; import android.content.Context; +import android.text.TextUtils; import org.eehouse.android.xw4.R; import org.eehouse.android.xw4.loc.LocUtils; @@ -33,7 +35,8 @@ public class LastMoveInfo { private static final int PHONY_TYPE = 3; public boolean isValid = false; // modified in jni world - public String name; + public boolean inDuplicateMode; + public String[] names; public int moveType; public int score; public int nTiles; @@ -45,25 +48,42 @@ public class LastMoveInfo { if ( isValid ) { switch( moveType ) { case ASSIGN_TYPE: - result = LocUtils.getString( context, R.string.lmi_tiles_fmt, name ); + result = inDuplicateMode + ? LocUtils.getString( context, R.string.lmi_tiles_dup ) + : LocUtils.getString( context, R.string.lmi_tiles_fmt, names[0] ); break; case MOVE_TYPE: if ( 0 == nTiles ) { - result = LocUtils.getString( context, R.string.lmi_pass_fmt, - name ); + result = inDuplicateMode + // Nobody scoring in dup mode is usually followed + // automatically by a trade. So this first will be + // rare. + ? LocUtils.getString( context, R.string.lmi_pass_dup ) + : LocUtils.getString( context, R.string.lmi_pass_fmt, names[0] ); + } else if ( inDuplicateMode ) { + if ( names.length == 1 ) { + result = LocUtils.getString( context, R.string.lmi_move_one_dup_fmt, + names[0], word, score ); + } else { + String joiner = LocUtils.getString( context, R.string.name_concat_dup ); + String players = TextUtils.join( joiner, names); + result = LocUtils.getString( context, R.string.lmi_move_tie_dup_fmt, + players, score, word ); + } } else { result = LocUtils.getQuantityString( context, R.plurals.lmi_move_fmt, - score, name, word, score ); + score, names[0], word, score ); } break; case TRADE_TYPE: - result = LocUtils - .getQuantityString( context, R.plurals.lmi_trade_fmt, - nTiles, name, nTiles ); + result = inDuplicateMode + ? LocUtils.getString( context, R.string.lmi_trade_dup_fmt, nTiles ) + : LocUtils.getQuantityString( context, R.plurals.lmi_trade_fmt, + nTiles, names[0], nTiles ); break; case PHONY_TYPE: result = LocUtils.getString( context, R.string.lmi_phony_fmt, - name ); + names[0] ); break; } } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxt.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxt.java index 996bee710..34262648d 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxt.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxt.java @@ -53,11 +53,14 @@ public interface UtilCtxt { public static final int TIMER_TIMERTICK = 2; public static final int TIMER_COMMS = 3; public static final int TIMER_SLOWROBOT = 4; + public static final int TIMER_DUP_TIMERCHECK = 5; + public static final int NUM_TIMERS_PLUS_ONE = 6; void setTimer( int why, int when, int handle ); void clearTimer( int why ); void requestTime(); void remSelected(); + void timerSelected( boolean inDuplicateMode, boolean canPause ); void setIsServer( boolean isServer ); void bonusSquareHeld( int bonus ); @@ -66,6 +69,7 @@ public interface UtilCtxt { void notifyMove( String query ); void notifyTrade( String[] tiles ); + void notifyDupStatus( boolean amHost, String msg ); // These can't be an ENUM! The set is open-ended, with arbitrary values // added to ERR_RELAY_BASE, so no way to create an enum from an int in the @@ -110,4 +114,7 @@ public interface UtilCtxt { boolean turnLost ); void showChat( String msg, int fromIndx, String fromName, int tsSeconds ); + + String formatPauseHistory( int pauseTyp, int player, int whenPrev, + int whenCur, String msg ); } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxtImpl.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxtImpl.java index 890dfbe39..6f4528069 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxtImpl.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/UtilCtxtImpl.java @@ -94,6 +94,12 @@ public class UtilCtxtImpl implements UtilCtxt { subclassOverride( "remSelected" ); } + @Override + public void timerSelected( boolean inDuplicateMode, boolean canPause ) + { + subclassOverride( "timerSelected" ); + } + @Override public void setIsServer( boolean isServer ) { @@ -127,6 +133,12 @@ public class UtilCtxtImpl implements UtilCtxt { subclassOverride( "notifyTrade" ); } + @Override + public void notifyDupStatus( boolean amHost, String msg ) + { + subclassOverride( "notifyDupStatus" ); + } + @Override public void userError( int id ) { @@ -183,6 +195,14 @@ public class UtilCtxtImpl implements UtilCtxt { subclassOverride( "showChat" ); } + @Override + public String formatPauseHistory( int pauseTyp, int player, int whenPrev, + int whenCur, String msg ) + { + subclassOverride( "formatPauseHistory" ); + return null; + } + private void subclassOverride( String name ) { // DbgUtils.logf( "%s::%s() called", getClass().getName(), name ); } diff --git a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/XwJNI.java b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/XwJNI.java index 47bb6675c..302f4ec6a 100644 --- a/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/XwJNI.java +++ b/xwords4/android/app/src/main/java/org/eehouse/android/xw4/jni/XwJNI.java @@ -350,6 +350,9 @@ public class XwJNI { public static native String board_formatRemainingTiles( GamePtr gamePtr ); public static native void board_sendChat( GamePtr gamePtr, String msg ); + // Duplicate mode to start and stop timer + public static native void board_pause( GamePtr gamePtr, String msg ); + public static native void board_unpause( GamePtr gamePtr, String msg ); public enum XP_Key { XP_KEY_NONE, diff --git a/xwords4/android/app/src/main/res/layout/game_config.xml b/xwords4/android/app/src/main/res/layout/game_config.xml index 6a950c28f..205f2540b 100644 --- a/xwords4/android/app/src/main/res/layout/game_config.xml +++ b/xwords4/android/app/src/main/res/layout/game_config.xml @@ -189,63 +189,75 @@ android:layout_marginTop="15dp" /> - + android:orientation="horizontal" + > - - + - + + + + + + + + + - - - - - - + + + + + + + + + + + + + + +