track all pending service Intents; warn of stalls

When enqueuing Intents for any of the three services, cache the
Intent. When onHandleWork() is called, remove the equivalent cached
intent. Then periodically check for intents that have been stored for
more than 60 seconds, and post a Notification alerting user to
stall. The user will likely have noticed that messages aren't flowing,
so this will simply explain the problem. Includes an "email author"
button.
This commit is contained in:
Eric House 2019-03-19 08:39:16 -07:00
parent f5afba8fd4
commit 1291310a0c
10 changed files with 236 additions and 50 deletions

View file

@ -30,10 +30,12 @@ import android.app.NotificationManager;
public class Channels { public class Channels {
enum ID { enum ID {
FOREGROUND(R.string.foreground_channel_expl, NBSPROXY(R.string.nbsproxy_channel_expl,
NotificationManager.IMPORTANCE_LOW), NotificationManager.IMPORTANCE_LOW),
GAME_EVENT(R.string.gameevent_channel_expl, GAME_EVENT(R.string.gameevent_channel_expl,
NotificationManager.IMPORTANCE_LOW); NotificationManager.IMPORTANCE_LOW),
SERVICE_STALL(R.string.servicestall_channel_expl,
NotificationManager.IMPORTANCE_LOW);
private int mExpl; private int mExpl;
private int mImportance; private int mImportance;

View file

@ -2449,6 +2449,23 @@ public class DBUtils {
return dflt; return dflt;
} }
public static void setLongFor( Context context, String key, long value )
{
// Log.d( TAG, "DBUtils.setIntFor(key=%s, val=%d)", key, value );
String asStr = String.format( "%d", value );
setStringFor( context, key, asStr );
}
public static long getLongFor( Context context, String key, long dflt )
{
String asStr = getStringFor( context, key, null );
if ( null != asStr ) {
dflt = Long.parseLong( asStr );
}
// Log.d( TAG, "DBUtils.getIntFor(key=%s)=>%d", key, dflt );
return dflt;
}
public static void setBoolFor( Context context, String key, boolean value ) public static void setBoolFor( Context context, String key, boolean value )
{ {
// Log.df( "DBUtils.setBoolFor(key=%s, val=%b)", key, value ); // Log.df( "DBUtils.setBoolFor(key=%s, val=%b)", key, value );

View file

@ -56,6 +56,7 @@ public class DlgDelegate {
SET_HIDE_NEWGAME_BUTTONS, SET_HIDE_NEWGAME_BUTTONS,
DWNLD_LOC_DICT, DWNLD_LOC_DICT,
NEW_GAME_DFLT_NAME, NEW_GAME_DFLT_NAME,
SEND_EMAIL,
// BoardDelegate // BoardDelegate
UNDO_LAST_ACTION, UNDO_LAST_ACTION,

View file

@ -99,6 +99,7 @@ public class GamesListDelegate extends ListDelegateBase
private static final String REMATCH_P2PADDR_EXTRA = "rm_p2pma"; private static final String REMATCH_P2PADDR_EXTRA = "rm_p2pma";
private static final String ALERT_MSG = "alert_msg"; private static final String ALERT_MSG = "alert_msg";
private static final String WITH_EMAIL = "with_email";
private static class MySIS implements Serializable { private static class MySIS implements Serializable {
public MySIS(){ public MySIS(){
@ -1352,6 +1353,10 @@ public class GamesListDelegate extends ListDelegateBase
askDefaultName(); askDefaultName();
break; break;
case SEND_EMAIL:
Utils.emailAuthor( m_activity );
break;
case ASKED_PHONE_STATE: case ASKED_PHONE_STATE:
rematchWithNameAndPerm( true, params ); rematchWithNameAndPerm( true, params );
break; break;
@ -2297,7 +2302,13 @@ public class GamesListDelegate extends ListDelegateBase
{ {
String msg = intent.getStringExtra( ALERT_MSG ); String msg = intent.getStringExtra( ALERT_MSG );
if ( null != msg ) { if ( null != msg ) {
makeOkOnlyBuilder( msg ).show(); DlgDelegate.DlgDelegateBuilder builder =
makeOkOnlyBuilder( msg );
if ( intent.getBooleanExtra( WITH_EMAIL, false ) ) {
builder.setActionPair( Action.SEND_EMAIL,
R.string.board_menu_file_email );
}
builder.show();
} }
} }
@ -2781,6 +2792,13 @@ public class GamesListDelegate extends ListDelegateBase
return intent; return intent;
} }
public static Intent makeAlertWithEmailIntent( Context context, String msg )
{
return makeAlertIntent( context, msg )
.putExtra( WITH_EMAIL, true )
;
}
public static void sendNFCToSelf( Context context, String data ) public static void sendNFCToSelf( Context context, String data )
{ {
Intent intent = makeSelfIntent( context ); Intent intent = makeSelfIntent( context );

View file

@ -68,9 +68,6 @@ public class MainActivity extends XWActivity
Log.e( TAG, "isTaskRoot() => false!!! What to do?" ); Log.e( TAG, "isTaskRoot() => false!!! What to do?" );
} }
Utils.setLaunchedSinceInstall( this, true );
Utils.cancelLaunchSinceInstall( this );
m_dpEnabled = XWPrefs.getIsTablet( this ); m_dpEnabled = XWPrefs.getIsTablet( this );
m_dlgt = m_dpEnabled ? new DualpaneDelegate( this, savedInstanceState ) m_dlgt = m_dpEnabled ? new DualpaneDelegate( this, savedInstanceState )

View file

@ -35,8 +35,6 @@ public class OnBootReceiver extends BroadcastReceiver {
Log.d( TAG, "got %s", action ); Log.d( TAG, "got %s", action );
switch( action ) { switch( action ) {
case Intent.ACTION_MY_PACKAGE_REPLACED: case Intent.ACTION_MY_PACKAGE_REPLACED:
Utils.setLaunchedSinceInstall( context, false );
// FALLTHRU
case Intent.ACTION_BOOT_COMPLETED: case Intent.ACTION_BOOT_COMPLETED:
startTimers( context ); startTimers( context );
break; break;

View file

@ -246,6 +246,14 @@ public class Utils {
public static void postNotification( Context context, Intent intent, public static void postNotification( Context context, Intent intent,
String title, String body, String title, String body,
int id ) int id )
{
String channelID = Channels.getChannelID( context, Channels.ID.GAME_EVENT );
postNotification( context, intent, title, body, id, channelID );
}
private static void postNotification( Context context, Intent intent,
String title, String body,
int id, String channelID )
{ {
/* nextRandomInt: per this link /* nextRandomInt: per this link
http://stackoverflow.com/questions/10561419/scheduling-more-than-one-pendingintent-to-same-activity-using-alarmmanager http://stackoverflow.com/questions/10561419/scheduling-more-than-one-pendingintent-to-same-activity-using-alarmmanager
@ -265,7 +273,6 @@ public class Utils {
defaults |= Notification.DEFAULT_VIBRATE; defaults |= Notification.DEFAULT_VIBRATE;
} }
String channelID = Channels.getChannelID( context, Channels.ID.GAME_EVENT );
Notification notification = Notification notification =
new NotificationCompat.Builder( context, channelID ) new NotificationCompat.Builder( context, channelID )
.setContentIntent( pi ) .setContentIntent( pi )
@ -283,6 +290,31 @@ public class Utils {
nm.notify( id, notification ); nm.notify( id, notification );
} }
private static final String KEY_LAST_STALL_NOT = TAG + ".last_stall_note";
private static final long MIN_STALL_NOT_INTERVAL_MS = 1000 * 60 * 30;
public static void showStallNotification( Context context, long ageMS )
{
long now = System.currentTimeMillis();
long lastStallNotify = DBUtils.getLongFor( context, KEY_LAST_STALL_NOT, 0 );
if ( now - lastStallNotify > MIN_STALL_NOT_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_NOT_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 );
DBUtils.setLongFor( context, KEY_LAST_STALL_NOT, now );
} else {
// Log.d( TAG, "showStallNotification(): not posting for another %d ms",
// MIN_STALL_NOT_INTERVAL_MS - (now - lastStallNotify) );
}
}
public static void cancelNotification( Context context, int id ) public static void cancelNotification( Context context, int id )
{ {
NotificationManager nm = (NotificationManager) NotificationManager nm = (NotificationManager)
@ -570,39 +602,6 @@ public class Utils {
return Looper.getMainLooper().equals(Looper.myLooper()); return Looper.getMainLooper().equals(Looper.myLooper());
} }
private static final String KEY_LAUNCHED_SINCE_INSTALL
= TAG + "_LAUNCHED_SINCE_INSTALL";
public static void setLaunchedSinceInstall( Context context, boolean val )
{
DBUtils.setBoolFor( context, KEY_LAUNCHED_SINCE_INSTALL, val );
}
private static boolean getLaunchedSinceInstall( Context context )
{
boolean result = DBUtils
.getBoolFor( context, KEY_LAUNCHED_SINCE_INSTALL, false );
return result;
}
private static final int LAUNCH_SINCE_INSTALL_MSG_ID
= R.string.bt_need_launch_body; // whatever
public static void showLaunchSinceInstall( Context context )
{
if ( ! getLaunchedSinceInstall( context ) ) {
Intent intent = GamesListDelegate
.makeGameIDIntent( context, 0 );
postNotification( context, intent,
R.string.bt_need_launch_title,
R.string.bt_need_launch_body,
LAUNCH_SINCE_INSTALL_MSG_ID );
}
}
public static void cancelLaunchSinceInstall( Context context )
{
cancelNotification( context, LAUNCH_SINCE_INSTALL_MSG_ID );
}
public static String base64Encode( byte[] in ) public static String base64Encode( byte[] in )
{ {
return Base64.encodeToString( in, Base64.NO_WRAP ); return Base64.encodeToString( in, Base64.NO_WRAP );

View file

@ -150,7 +150,7 @@ public class XWApp extends Application
public void onRegResponse( boolean appReached, boolean needsInitialLaunch ) public void onRegResponse( boolean appReached, boolean needsInitialLaunch )
{ {
if ( needsInitialLaunch ) { if ( needsInitialLaunch ) {
String channelID = Channels.getChannelID( this, Channels.ID.FOREGROUND ); String channelID = Channels.getChannelID( this, Channels.ID.NBSPROXY );
NBSProxy.postLaunchNotification( this, channelID, R.drawable.notify ); NBSProxy.postLaunchNotification( this, channelID, R.drawable.notify );
} }
} }

View file

@ -21,14 +21,23 @@
package org.eehouse.android.xw4; package org.eehouse.android.xw4;
import android.support.v4.app.JobIntentService;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle;
import android.support.v4.app.JobIntentService;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map; import java.util.Map;
abstract class XWJIService extends JobIntentService { abstract class XWJIService extends JobIntentService {
private static final String TAG = XWJIService.class.getSimpleName();
private static final boolean LOG_COUNTS = false;
static final String CMD_KEY = "CMD"; static final String CMD_KEY = "CMD";
private static final String TIMESTAMP = "TIMESTAMP"; private static final String TIMESTAMP = "TIMESTAMP";
@ -47,6 +56,8 @@ abstract class XWJIService extends JobIntentService {
@Override @Override
public final void onHandleWork( Intent intent ) public final void onHandleWork( Intent intent )
{ {
forget( getClass(), intent );
long timestamp = getTimestamp(intent); long timestamp = getTimestamp(intent);
XWJICmds cmd = cmdFrom( intent ); XWJICmds cmd = cmdFrom( intent );
Log.d( getClass().getSimpleName(), Log.d( getClass().getSimpleName(),
@ -59,7 +70,9 @@ abstract class XWJIService extends JobIntentService {
protected static void enqueueWork( Context context, Class clazz, Intent intent ) protected static void enqueueWork( Context context, Class clazz, Intent intent )
{ {
remember( clazz, intent );
enqueueWork( context, clazz, sJobIDs.get(clazz), intent ); enqueueWork( context, clazz, sJobIDs.get(clazz), intent );
checkForStall( context );
} }
static XWJICmds cmdFrom( Intent intent, XWJICmds[] values ) static XWJICmds cmdFrom( Intent intent, XWJICmds[] values )
@ -86,4 +99,129 @@ abstract class XWJIService extends JobIntentService {
.putExtra( TIMESTAMP, System.currentTimeMillis() ); .putExtra( TIMESTAMP, System.currentTimeMillis() );
return intent; return intent;
} }
private static Map<String, List<Intent>> sPendingIntents = new HashMap<>();
private static void remember( Class clazz, Intent intent )
{
String name = clazz.getSimpleName();
synchronized ( sPendingIntents ) {
if ( !sPendingIntents.containsKey( name )) {
sPendingIntents.put( name, new ArrayList<Intent>() );
}
sPendingIntents.get(name).add( intent );
if ( LOG_COUNTS ) {
Log.d( TAG, "remember(): now have %d intents for class %s",
sPendingIntents.get(name).size(), name );
}
}
}
private static final long AGE_THRESHOLD_MS = 1000 * 60; // one minute to start
private static void checkForStall( Context context )
{
long now = System.currentTimeMillis();
long maxAge = 0;
synchronized ( sPendingIntents ) {
for ( String simpleName : sPendingIntents.keySet() ) {
List<Intent> intents = sPendingIntents.get( simpleName );
if ( 1 <= intents.size() ) {
Intent intent = intents.get(0);
long timestamp = intent.getLongExtra( TIMESTAMP, -1 );
long age = now - timestamp;
if ( age > maxAge ) {
maxAge = age;
}
}
}
}
if ( maxAge > AGE_THRESHOLD_MS ) {
Utils.showStallNotification( context, maxAge );
}
}
private static void forget( Class clazz, Intent intent )
{
String name = clazz.getSimpleName();
synchronized ( sPendingIntents ) {
String found = null;
if ( sPendingIntents.containsKey( name ) ) {
List<Intent> intents = sPendingIntents.get( name );
for (Iterator<Intent> iter = intents.iterator();
iter.hasNext(); ) {
Intent candidate = iter.next();
if ( areSame( candidate, intent ) ) {
found = name;
iter.remove();
break;
} else {
Log.d( TAG, "skipping intent: %s",
DbgUtils.extrasToString( candidate ) );
}
}
if ( found != null ) {
if ( LOG_COUNTS ) {
Log.d( TAG, "forget(): now have %d intents for class %s",
sPendingIntents.get(found).size(), found );
}
} else {
Log.e( TAG, "intent %s not found", intent );
}
}
}
}
private static boolean areSame( Intent intent1, Intent intent2 )
{
boolean equal = intent1.filterEquals( intent2 );
if ( equal ) {
Bundle bundle1 = intent1.getExtras();
equal = null != bundle1;
if ( equal ) {
Bundle bundle2 = intent2.getExtras();
equal = null != bundle2 && bundle1.size() == bundle2.size();
if ( equal ) {
for ( final String key : bundle1.keySet()) {
if ( ! bundle2.containsKey( key ) ) {
equal = false;
break;
}
Object obj1 = bundle1.get( key );
Object obj2 = bundle2.get( key );
if ( obj1.getClass() != obj2.getClass() ) {
equal = false;
break;
}
if ( obj1 instanceof byte[] ) {
equal = Arrays.equals( (byte[])obj1, (byte[])obj2 );
} else if ( obj1 instanceof String[] ) {
equal = Arrays.equals( (String[])obj1, (String[])obj2 );
} else {
if ( BuildConfig.DEBUG ) {
if ( obj1 instanceof Long
|| obj1 instanceof String
|| obj1 instanceof Boolean
|| obj1 instanceof Integer ) {
// expected class; log nothing
} else {
Log.d( TAG, "areSame: using default for class %s",
obj1.getClass().getSimpleName() );
}
}
equal = obj1.equals( obj2 );
}
if ( ! equal ) {
break;
}
}
}
}
}
return equal;
}
} }

View file

@ -2792,9 +2792,11 @@
<string name="toast_no_permission">Permission not granted</string> <string name="toast_no_permission">Permission not granted</string>
<!-- Explanation in settings for always-on BT notification --> <!-- Explanation in settings for always-on BT notification -->
<string name="foreground_channel_expl">Accepting Bluetooth in background</string> <string name="nbsproxy_channel_expl">Alerts about NBSProxy</string>
<!-- Explanation in settings for traditional move-arrived notification --> <!-- Explanation in settings for traditional move-arrived notification -->
<string name="gameevent_channel_expl">In-game events</string> <string name="gameevent_channel_expl">In-game events</string>
<!-- Notification that the OS isn't scheduling background services -->
<string name="servicestall_channel_expl">Stalled messaging alerts</string>
<string name="not_again_emptybtscan">If a scan doesn\'t find the device you expect:\n <string name="not_again_emptybtscan">If a scan doesn\'t find the device you expect:\n
• First, just Rescan\n • First, just Rescan\n
@ -2803,10 +2805,24 @@
• If all else fails, reboot this device\n • If all else fails, reboot this device\n
</string> </string>
<string name="bt_need_launch_title">Tap to launch CrossWords</string> <string name="notify_stall_title">Message sending is stalled</string>
<string name="bt_need_launch_body">After each upgrade the app needs
to be launched once before it can do things like receive Bluetooth <string name="notify_stall_body_fmt">Though it normally takes less
messages in the background.</string> than a second, Android has been unable to process CrossWords\'
outbound messages for more than %1$d seconds. This might be because
your device is especially busy, or because CrossWords hasn\'t run in
the foreground for a while.
\n\n
If the problem persists (i.e. you see this frequently), please
consider emailing me with information about your device so I can try
to reproduce and fix it.
\n\n
You will see this message at most once every %2$d minutes.
</string>
<string name="sms_banned_ok_only">The Google Play Store version of <string name="sms_banned_ok_only">The Google Play Store version of
CrossWords no longer supports inviting or playing by Data CrossWords no longer supports inviting or playing by Data