diff --git a/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java b/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java new file mode 100644 index 000000000..ed198b797 --- /dev/null +++ b/android/src/androidTest/java/io/ably/lib/test/android/EventTest.java @@ -0,0 +1,86 @@ +package io.ably.lib.test.android; + +import io.ably.lib.push.ActivationStateMachine.CalledActivate; +import io.ably.lib.push.ActivationStateMachine.CalledDeactivate; +import io.ably.lib.push.ActivationStateMachine.Deregistered; +import io.ably.lib.push.ActivationStateMachine.Event; +import io.ably.lib.push.ActivationStateMachine.GotDeviceRegistration; +import io.ably.lib.push.ActivationStateMachine.GotPushDeviceDetails; +import io.ably.lib.push.ActivationStateMachine.RegistrationSynced; +import io.ably.lib.push.ActivationStateMachine.GettingDeviceRegistrationFailed; +import io.ably.lib.push.ActivationStateMachine.GettingPushDeviceDetailsFailed; +import io.ably.lib.push.ActivationStateMachine.SyncRegistrationFailed; +import io.ably.lib.push.ActivationStateMachine.DeregistrationFailed; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class EventTest { + + @Test + public void events_subclasses_correctly_constructed_by_name() throws ClassNotFoundException, InstantiationException { + + CalledActivate calledActivateEvent = new CalledActivate(); + Event calledActivateReconstructed = Event.constructEventByName(calledActivateEvent.getPersistedName()); + assertEquals(calledActivateEvent.getClass(), calledActivateReconstructed.getClass()); + + CalledDeactivate calledDeactivateEvent = new CalledDeactivate(); + Event calledDeactivateReconstructed = Event.constructEventByName(calledDeactivateEvent.getPersistedName()); + assertEquals(calledDeactivateEvent.getClass(), calledDeactivateReconstructed.getClass()); + + GotPushDeviceDetails gotPushDeviceDetailsEvent = new GotPushDeviceDetails(); + Event gotPushDeviceDetailsReconstructed = Event.constructEventByName(gotPushDeviceDetailsEvent.getPersistedName()); + assertEquals(gotPushDeviceDetailsEvent.getClass(), gotPushDeviceDetailsReconstructed.getClass()); + + RegistrationSynced registrationSyncedEvent = new RegistrationSynced(); + Event registrationSyncedReconstructed = Event.constructEventByName(registrationSyncedEvent.getPersistedName()); + assertEquals(registrationSyncedEvent.getClass(), registrationSyncedReconstructed.getClass()); + + Deregistered DeregisteredEvent = new Deregistered(); + Event DeregisteredReconstructed = Event.constructEventByName(DeregisteredEvent.getPersistedName()); + assertEquals(DeregisteredEvent.getClass(), DeregisteredReconstructed.getClass()); + } + + @Test + public void events_with_constructor_parameter_cannot_be_restored() { + GotDeviceRegistration gotDeviceRegistration = new GotDeviceRegistration(null); + try{ + Event.constructEventByName(gotDeviceRegistration.getPersistedName()); + } catch (Exception e) { + assertEquals(InstantiationException.class, e.getClass()); + } + + GettingDeviceRegistrationFailed gettingDeviceRegistrationFailed = new GettingDeviceRegistrationFailed(null); + try { + Event.constructEventByName(gettingDeviceRegistrationFailed.getPersistedName()); + } catch (Exception e) { + assertEquals(InstantiationException.class, e.getClass()); + } + + GettingPushDeviceDetailsFailed gettingPushDeviceDetailsFailed = new GettingPushDeviceDetailsFailed(null); + try { + Event.constructEventByName(gettingPushDeviceDetailsFailed.getPersistedName()); + } catch (Exception e) { + assertEquals(InstantiationException.class, e.getClass()); + } + + SyncRegistrationFailed syncRegistrationFailed = new SyncRegistrationFailed(null); + try { + Event.constructEventByName(syncRegistrationFailed.getPersistedName()); + } catch (Exception e) { + assertEquals(InstantiationException.class, e.getClass()); + } + + DeregistrationFailed deregistrationFailed = new DeregistrationFailed(null); + try { + Event.constructEventByName(deregistrationFailed.getPersistedName()); + } catch (Exception e) { + assertEquals(InstantiationException.class, e.getClass()); + } + } + + @Test(expected = ClassNotFoundException.class) + public void unknown_events_cannot_be_constructed_by_name() throws Exception { + Event.constructEventByName("notDefinedName"); + } +} diff --git a/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java b/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java index ca6a03392..8992cd6ea 100644 --- a/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java +++ b/android/src/main/java/io/ably/lib/push/ActivationStateMachine.java @@ -7,73 +7,219 @@ import android.content.SharedPreferences; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import android.support.v4.content.LocalBroadcastManager; import com.google.gson.JsonObject; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.util.ArrayDeque; - import com.google.gson.JsonPrimitive; -import io.ably.lib.http.*; +import io.ably.lib.http.Http; +import io.ably.lib.http.HttpCore; +import io.ably.lib.http.HttpScheduler; +import io.ably.lib.http.HttpUtils; import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.DeviceDetails; -import io.ably.lib.types.*; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.Callback; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Param; +import io.ably.lib.types.RegistrationToken; import io.ably.lib.util.IntentUtils; import io.ably.lib.util.Log; import io.ably.lib.util.ParamsUtils; import io.ably.lib.util.Serialisation; +import java.lang.reflect.Field; +import java.util.ArrayDeque; + public class ActivationStateMachine { public static class CalledActivate extends ActivationStateMachine.Event { + public static final String NAME = "CalledActivate"; + public static ActivationStateMachine.CalledActivate useCustomRegistrar(boolean useCustomRegistrar, SharedPreferences prefs) { prefs.edit().putBoolean(ActivationStateMachine.PersistKeys.PUSH_CUSTOM_REGISTRAR, useCustomRegistrar).apply(); return new ActivationStateMachine.CalledActivate(); } + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } } public static class CalledDeactivate extends ActivationStateMachine.Event { + public static final String NAME = "CalledDeactivate"; + static ActivationStateMachine.CalledDeactivate useCustomRegistrar(boolean useCustomRegistrar, SharedPreferences prefs) { prefs.edit().putBoolean(ActivationStateMachine.PersistKeys.PUSH_CUSTOM_REGISTRAR, useCustomRegistrar).apply(); return new ActivationStateMachine.CalledDeactivate(); } + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } } - public static class GotPushDeviceDetails extends ActivationStateMachine.Event {} + public static class GotPushDeviceDetails extends ActivationStateMachine.Event { + public static final String NAME = "GotPushDeviceDetails"; + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + } public static class GotDeviceRegistration extends ActivationStateMachine.Event { final String deviceIdentityToken; - GotDeviceRegistration(String token) { this.deviceIdentityToken = token; } + public GotDeviceRegistration(String token) { this.deviceIdentityToken = token; } + + @Override + public String toString() { + return "GotDeviceRegistration{" + + "deviceIdentityToken='" + deviceIdentityToken + '\'' + + '}'; + } } public static class GettingDeviceRegistrationFailed extends ActivationStateMachine.ErrorEvent { - GettingDeviceRegistrationFailed(ErrorInfo reason) { super(reason); } + public GettingDeviceRegistrationFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "GettingDeviceRegistrationFailed: " + super.toString(); + } } public static class GettingPushDeviceDetailsFailed extends ActivationStateMachine.ErrorEvent { - GettingPushDeviceDetailsFailed(ErrorInfo reason) { super(reason); } + public GettingPushDeviceDetailsFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "GettingPushDeviceDetailsFailed: " + super.toString(); + } } - public static class RegistrationSynced extends ActivationStateMachine.Event {} + public static class RegistrationSynced extends ActivationStateMachine.Event { + public static final String NAME = "RegistrationSynced"; + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + } public static class SyncRegistrationFailed extends ActivationStateMachine.ErrorEvent { public SyncRegistrationFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "SyncRegistrationFailed: " + super.toString(); + } } - public static class Deregistered extends ActivationStateMachine.Event {} + public static class Deregistered extends ActivationStateMachine.Event { + public static final String NAME = "Deregistered"; + + @Override + public String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + } public static class DeregistrationFailed extends ActivationStateMachine.ErrorEvent { public DeregistrationFailed(ErrorInfo reason) { super(reason); } + + @Override + public String toString() { + return "DeregistrationFailed: " + super.toString(); + } } - public abstract static class Event {} + public abstract static class Event { + /** + * The name to be used when persisting this class, or null if this class should not be persisted. + */ + public String getPersistedName() { + return null; + } + + /** + * @param className The name of the class to rehydrate. + * @return A new Event instance, or null if className is not supported. + */ + public static Event constructEventByName(String className) { + switch (className) { + case CalledActivate.NAME: + return new CalledActivate(); + + case CalledDeactivate.NAME: + return new CalledDeactivate(); + + case GotPushDeviceDetails.NAME: + return new GotPushDeviceDetails(); + + case RegistrationSynced.NAME: + return new RegistrationSynced(); + + case Deregistered.NAME: + return new Deregistered(); + } + + // the class name provided was not recognised + return null; + } + } public abstract static class ErrorEvent extends ActivationStateMachine.Event { public final ErrorInfo reason; ErrorEvent(ErrorInfo reason) { this.reason = reason; } + + @Override + public String toString() { + return "ErrorEvent{" + + "reason=" + reason + + '}'; + } } public static class NotActivated extends ActivationStateMachine.PersistentState { public NotActivated(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "NotActivated"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledDeactivate) { machine.callDeactivatedCallback(null); @@ -106,6 +252,19 @@ public ActivationStateMachine.State transition(ActivationStateMachine.Event even public static class WaitingForPushDeviceDetails extends ActivationStateMachine.PersistentState { public WaitingForPushDeviceDetails(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "WaitingForPushDeviceDetails"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + public ActivationStateMachine.State transition(final ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { return this; @@ -176,6 +335,12 @@ public void onError(ErrorInfo reason) { public static class WaitingForDeviceRegistration extends ActivationStateMachine.State { public WaitingForDeviceRegistration(ActivationStateMachine machine) { super(machine); } + + @Override + public String toString() { + return "WaitingForDeviceRegistration"; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { return this; @@ -194,6 +359,19 @@ public ActivationStateMachine.State transition(ActivationStateMachine.Event even public static class WaitingForNewPushDeviceDetails extends ActivationStateMachine.PersistentState { public WaitingForNewPushDeviceDetails(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "WaitingForNewPushDeviceDetails"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return "WaitingForNewPushDeviceDetails"; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { machine.callActivatedCallback(null); @@ -220,6 +398,13 @@ public WaitingForRegistrationSync(ActivationStateMachine machine, Event fromEven this.fromEvent = fromEvent; } + @Override + public String toString() { + return "WaitingForRegistrationSync{" + + "fromEvent=" + fromEvent + + '}'; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate) { if (fromEvent instanceof CalledActivate) { @@ -251,6 +436,19 @@ public ActivationStateMachine.State transition(ActivationStateMachine.Event even public static class AfterRegistrationSyncFailed extends ActivationStateMachine.PersistentState { public AfterRegistrationSyncFailed(ActivationStateMachine machine) { super(machine); } + + public static final String NAME = "AfterRegistrationSyncFailed"; + + @Override + String getPersistedName() { + return NAME; + } + + @Override + public String toString() { + return NAME; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledActivate || event instanceof ActivationStateMachine.GotPushDeviceDetails) { machine.validateRegistration(); @@ -271,6 +469,13 @@ public WaitingForDeregistration(ActivationStateMachine machine, ActivationStateM this.previousState = previousState; } + @Override + public String toString() { + return "WaitingForDeregistration{" + + "previousState=" + previousState + + '}'; + } + public ActivationStateMachine.State transition(ActivationStateMachine.Event event) { if (event instanceof ActivationStateMachine.CalledDeactivate) { return this; @@ -303,6 +508,30 @@ public State(ActivationStateMachine machine) { private static abstract class PersistentState extends ActivationStateMachine.State { PersistentState(ActivationStateMachine machine) { super(machine); } + + /** + * @param className The name of the class to rehydrate. + * @return A new Event instance, or null if className is not supported. + */ + public static State constructStateByName(final String className, final ActivationStateMachine machine) { + switch (className) { + case NotActivated.NAME: + return new NotActivated(machine); + + case WaitingForPushDeviceDetails.NAME: + return new WaitingForPushDeviceDetails(machine); + + case WaitingForNewPushDeviceDetails.NAME: + return new WaitingForNewPushDeviceDetails(machine); + + case AfterRegistrationSyncFailed.NAME: + return new AfterRegistrationSyncFailed(machine); + } + + return null; + } + + abstract String getPersistedName(); } private void callActivatedCallback(ErrorInfo reason) { @@ -548,7 +777,7 @@ private void loadPersisted() { } private void enqueueEvent(ActivationStateMachine.Event event) { - Log.d(TAG, "enqueuing event: " + event.getClass().getSimpleName()); + Log.d(TAG, "enqueuing event: " + event); pendingEvents.add(event); } @@ -566,7 +795,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { handlingEvent = true; try { - Log.d(TAG, String.format("handling event %s from %s", event.getClass().getSimpleName(), current.getClass().getSimpleName())); + Log.d(TAG, "handling event " + event + " from state " + current); ActivationStateMachine.State maybeNext = current.transition(event); if (maybeNext == null) { @@ -574,7 +803,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { return persist(); } - Log.d(TAG, String.format("transition: %s -(%s)-> %s", current.getClass().getSimpleName(), event.getClass().getSimpleName(), maybeNext.getClass().getSimpleName())); + Log.d(TAG, "transition: " + current + " -(" + event + ")-> " + maybeNext + "."); current = maybeNext; while (true) { @@ -583,7 +812,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { break; } - Log.d(TAG, "attempting to consume pending event: " + pending.getClass().getSimpleName()); + Log.d(TAG, "attempting to consume pending event: " + pending); maybeNext = current.transition(pending); if (maybeNext == null) { @@ -591,7 +820,7 @@ public synchronized boolean handleEvent(ActivationStateMachine.Event event) { } pendingEvents.poll(); - Log.d(TAG, String.format("transition: %s -(%s)-> %s", current.getClass().getSimpleName(), pending.getClass().getSimpleName(), maybeNext.getClass().getSimpleName())); + Log.d(TAG, "transition: " + current + " -(" + pending + ")-> " + maybeNext + "."); current = maybeNext; } @@ -621,62 +850,49 @@ private boolean persist() { SharedPreferences.Editor editor = activationContext.getPreferences().edit(); if (current instanceof ActivationStateMachine.PersistentState) { - editor.putString(ActivationStateMachine.PersistKeys.CURRENT_STATE, current.getClass().getName()); + final PersistentState persistableState = (PersistentState)current; + editor.putString(ActivationStateMachine.PersistKeys.CURRENT_STATE, persistableState.getPersistedName()); } editor.putInt(ActivationStateMachine.PersistKeys.PENDING_EVENTS_LENGTH, pendingEvents.size()); int i = 0; for (ActivationStateMachine.Event e : pendingEvents) { - editor.putString( + final String name = e.getPersistedName(); + if (name != null) { + editor.putString( String.format("%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), - e.getClass().getName() - ); - + name + ); + } i++; } return editor.commit(); } + /** + * Returns persisted state or `NotActivated` if there is no persisted state or the name of the currently persisted + * state is not recognised. + */ private ActivationStateMachine.State getPersistedState() { - try { - Class stateClass; - - String className = activationContext.getPreferences().getString(ActivationStateMachine.PersistKeys.CURRENT_STATE, ""); - if (className.endsWith("$AfterRegistrationUpdateFailed")) { - stateClass = AfterRegistrationSyncFailed.class; - } else { - stateClass = (Class) Class.forName(className); - } - - Constructor constructor = stateClass.getConstructor(ActivationStateMachine.class); - return constructor.newInstance(this); - } catch (Exception e) { - return new ActivationStateMachine.NotActivated(this); - } + final String className = activationContext.getPreferences().getString(ActivationStateMachine.PersistKeys.CURRENT_STATE, ""); + final State instance = PersistentState.constructStateByName(className, this); + return instance == null ? new ActivationStateMachine.NotActivated(this) : instance; } private ArrayDeque getPersistedPendingEvents() { int length = activationContext.getPreferences().getInt(ActivationStateMachine.PersistKeys.PENDING_EVENTS_LENGTH, 0); ArrayDeque deque = new ArrayDeque<>(length); for (int i = 0; i < length; i++) { - try { - String className = activationContext.getPreferences().getString(String.format("%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), ""); - Class eventClass = (Class) Class.forName(className); - ActivationStateMachine.Event event; - try { - event = eventClass.newInstance(); - } catch(InstantiationException e) { - // We aren't properly persisting events with a non-nullary constructor. Those events - // are supposed to be handled by states that aren't persisted (until - // https://github.com/ably/ably-java/issues/546 is fixed), so it should be safe to - // just drop them. - Log.d(TAG, String.format("discarding improperly persisted event: %s", className)); - continue; - } + String className = activationContext.getPreferences().getString(String.format("%s[%d]", ActivationStateMachine.PersistKeys.PENDING_EVENTS_PREFIX, i), ""); + ActivationStateMachine.Event event = Event.constructEventByName(className); + if (event != null) { deque.add(event); - } catch(Exception e) { - throw new RuntimeException(e); + } else { + // This is likely to be a difference between builds of the SDK. Perhaps related to obfuscated event + // names having been previously persisted on this device. See: + // https://github.com/ably/ably-java/issues/686 + Log.w(TAG, "Failed to construct push activation state machine event from persisted class name '" + className + "'."); } } return deque;