diff --git a/.circleci/config.yml b/.circleci/config.yml index 2d02ad3..ab3d7ab 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -36,7 +36,7 @@ jobs: steps: - checkout - restore_gradle_cache - - run: ./gradlew sdk:build sdkMock:build --continue + - run: ./gradlew sdk:build sdkMock:build -x sdk:test -x sdkMock:test --continue - save_gradle_cache test: executor: android diff --git a/.gitignore b/.gitignore index c61d2d0..12bf2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ build/ #idea *.iml .idea/ + +.DS_Store \ No newline at end of file diff --git a/sample/proguard-project.txt b/sample/proguard-project.txt index 8bb8ea0..3b27d29 100644 --- a/sample/proguard-project.txt +++ b/sample/proguard-project.txt @@ -19,3 +19,8 @@ # public *; #} -keep class * extends android.os.IInterface + +# to check the R8 results except obfuscation +-keepnames class com.deploygate.sdk.** { + *; +} \ No newline at end of file diff --git a/sdk/build.gradle b/sdk/build.gradle index 6493ebc..c81259c 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -1,6 +1,14 @@ apply from: rootProject.file('sdk.build.gradle') -android.defaultConfig.consumerProguardFiles 'deploygate-sdk-proguard-rules.txt' +android { + defaultConfig { + consumerProguardFiles 'deploygate-sdk-proguard-rules.txt' + } +} + +dependencies { + testImplementation 'org.mockito:mockito-core:4.4.0' +} ext { displayName = "DeployGate SDK" diff --git a/sdk/deploygate-sdk-proguard-rules.txt b/sdk/deploygate-sdk-proguard-rules.txt index 564865b..7512270 100644 --- a/sdk/deploygate-sdk-proguard-rules.txt +++ b/sdk/deploygate-sdk-proguard-rules.txt @@ -1 +1,10 @@ -keep class * extends android.os.IInterface + +# To allow removing our logger +-assumenosideeffects class com.deploygate.sdk.internal.Logger { + public static void v(...); + public static void i(...); + public static void w(...); + public static void d(...); + public static void e(...); +} diff --git a/sdk/src/debug/java/com/deploygate/sdk/internal/Config.java b/sdk/src/debug/java/com/deploygate/sdk/internal/Config.java new file mode 100644 index 0000000..bf9fb0f --- /dev/null +++ b/sdk/src/debug/java/com/deploygate/sdk/internal/Config.java @@ -0,0 +1,5 @@ +package com.deploygate.sdk.internal; + +public class Config { + public static final boolean DEBUG = true; +} diff --git a/sdk/src/main/java/com/deploygate/sdk/CustomLog.java b/sdk/src/main/java/com/deploygate/sdk/CustomLog.java new file mode 100644 index 0000000..d8313ad --- /dev/null +++ b/sdk/src/main/java/com/deploygate/sdk/CustomLog.java @@ -0,0 +1,37 @@ +package com.deploygate.sdk; + +import android.os.Bundle; + +import com.deploygate.service.DeployGateEvent; + +class CustomLog { + public final String type; + public final String body; + private int retryCount; + + CustomLog( + String type, + String body + ) { + this.type = type; + this.body = body; + this.retryCount = 0; + } + + /** + * @return the number of current attempts + */ + int getAndIncrementRetryCount() { + return retryCount++; + } + + /** + * @return a bundle to send to the client service + */ + Bundle toExtras() { + Bundle extras = new Bundle(); + extras.putSerializable(DeployGateEvent.EXTRA_LOG, body); + extras.putSerializable(DeployGateEvent.EXTRA_LOG_TYPE, type); + return extras; + } +} diff --git a/sdk/src/main/java/com/deploygate/sdk/CustomLogConfiguration.java b/sdk/src/main/java/com/deploygate/sdk/CustomLogConfiguration.java new file mode 100644 index 0000000..0b2fd69 --- /dev/null +++ b/sdk/src/main/java/com/deploygate/sdk/CustomLogConfiguration.java @@ -0,0 +1,91 @@ +package com.deploygate.sdk; + +import com.deploygate.sdk.internal.Logger; + +public class CustomLogConfiguration { + public enum Backpressure { + /** + * SDK rejects new logs if buffer size is exceeded + */ + PRESERVE_BUFFER, + + /** + * SDK drops logs from the oldest if buffer size is exceeded + */ + DROP_BUFFER_BY_OLDEST + } + + /** + * the log buffer is required until DeployGate client app receives BOOT_COMPLETED broadcast. + *

+ * This is an experimental value. + *

+ * - 10 seconds until boot-completed + * - 10 logs per 1 seconds + * - plus some buffer + */ + private static final int DEFAULT_BUFFER_SIZE = 150; + private static final int MAX_BUFFER_SIZE = DEFAULT_BUFFER_SIZE; + + public final Backpressure backpressure; + public final int bufferSize; + + /** + * Do not bypass {@link Builder} to instantiate this class. + * + * @see Builder + */ + private CustomLogConfiguration( + Backpressure backpressure, + int bufferSize + ) { + this.backpressure = backpressure; + this.bufferSize = bufferSize; + } + + public static class Builder { + private Backpressure backpressure = Backpressure.DROP_BUFFER_BY_OLDEST; + private int bufferSize = DEFAULT_BUFFER_SIZE; + + /** + * @param backpressure + * the strategy of the backpressure in the log buffer + * + * @return self + * + * @see Backpressure + */ + public Builder setBackpressure(Backpressure backpressure) { + if (backpressure == null) { + throw new IllegalArgumentException("backpressure must be non-null"); + } + + this.backpressure = backpressure; + return this; + } + + /** + * @param bufferSize + * the max size of the log buffer + * + * @return self + */ + public Builder setBufferSize(int bufferSize) { + if (bufferSize <= 0) { + throw new IllegalArgumentException("buffer size must be greater than 0"); + } + + if (bufferSize > MAX_BUFFER_SIZE) { + Logger.w("buffer size is exceeded %d so it's rounded to %d", bufferSize, MAX_BUFFER_SIZE); + bufferSize = MAX_BUFFER_SIZE; + } + + this.bufferSize = bufferSize; + return this; + } + + public CustomLogConfiguration build() { + return new CustomLogConfiguration(backpressure, bufferSize); + } + } +} diff --git a/sdk/src/main/java/com/deploygate/sdk/CustomLogInstructionSerializer.java b/sdk/src/main/java/com/deploygate/sdk/CustomLogInstructionSerializer.java new file mode 100644 index 0000000..76455dd --- /dev/null +++ b/sdk/src/main/java/com/deploygate/sdk/CustomLogInstructionSerializer.java @@ -0,0 +1,351 @@ +package com.deploygate.sdk; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.RemoteException; +import android.text.TextUtils; + +import com.deploygate.sdk.internal.Logger; +import com.deploygate.service.DeployGateEvent; +import com.deploygate.service.IDeployGateSdkService; + +import java.util.LinkedList; + +/** + * This class serialize the instructions for custom logs and process them in another thread in order of enqueued. + * The internal handler class creates a buffer pool and guarantee the order. + * + *

+ * - Enqueue a new log to the buffer pool + * - Send logs in the order of FIFO + *

+ * The cost of polling the log buffer pool may be high, so SDK starts sending logs only while a service is active. + */ +class CustomLogInstructionSerializer { + static final int MAX_RETRY_COUNT = 2; + static final int SEND_LOG_RESULT_SUCCESS = 0; + static final int SEND_LOG_RESULT_FAILURE_RETRIABLE = -1; + static final int SEND_LOG_RESULT_FAILURE_RETRY_EXCEEDED = -2; + + private final String packageName; + private final CustomLogConfiguration configuration; + + @SuppressWarnings("FieldCanBeLocal") + private final HandlerThread thread; + private CustomLogHandler handler; + private boolean isDisabled; + + private volatile IDeployGateSdkService service; + + CustomLogInstructionSerializer( + String packageName, + CustomLogConfiguration configuration + ) { + if (TextUtils.isEmpty(packageName)) { + throw new IllegalArgumentException("packageName must be present"); + } + + if (configuration == null) { + throw new IllegalArgumentException("configuration must not be null"); + } + + this.packageName = packageName; + this.configuration = configuration; + this.isDisabled = false; + + this.thread = new HandlerThread("deploygate-sdk-custom-log"); + this.thread.start(); + } + + /** + * Bind a service and trigger several instructions immediately. + * + * @param service + * the latest service connection + */ + public final synchronized void connect(IDeployGateSdkService service) { + if (service == null) { + throw new IllegalArgumentException("service must not be null"); + } + + ensureHandlerInitialized(); + + handler.cancelPendingSendLogsInstruction(); + this.service = service; + handler.enqueueSendLogsInstruction(); + } + + /** + * Release a service connection and cancel all pending instructions. + */ + public final void disconnect() { + ensureHandlerInitialized(); + + handler.cancelPendingSendLogsInstruction(); + this.service = null; + } + + /** + * Request sending custom logs to DeployGate client service. All requests will be scheduled to an exclusive thread. + * + * @param type + * custom log type + * @param body + * custom log body + */ + public final synchronized void requestSendingLog( + String type, + String body + ) { + if (isDisabled) { + return; + } + + ensureHandlerInitialized(); + + CustomLog log = new CustomLog(type, body); + handler.enqueueAddNewLogInstruction(log); + } + + /** + * Disable accepting instructions. This does not mean interrupting and/or terminating the exclusive thread. + * + * @param isDisabled + * specify true if wanna disable this serializer, otherwise false. + */ + public final void setDisabled(boolean isDisabled) { + this.isDisabled = isDisabled; + + if (isDisabled) { + Logger.d("Disabled custom log instruction serializer"); + } else { + Logger.d("Enabled custom log instruction serializer"); + } + } + + /** + * Check if this serializer a service connection. + *

+ * The connection may return {@link android.os.DeadObjectException} even if this returns true; + * + * @return true if a service connection is assigned, otherwise false. + */ + public final boolean hasServiceConnection() { + return service != null; + } + + /** + * @return + * + * @hide Only for testing. + */ + Looper getLooper() { + return handler.getLooper(); + } + + boolean hasHandlerInitialized() { + return handler != null; + } + + int getPendingCount() { + if (handler == null) { + return 0; + } + + return handler.customLogs.size(); + } + + /** + * Send a single log to the receiver. + *

+ * SDK can't send logs in bulk to avoid TransactionTooLargeException + *

+ * Visible only for testing + * + * @param log + * a custom log to send + * + * @return true if this could send the custom log, otherwise false. + */ + int sendLog(CustomLog log) { + IDeployGateSdkService service = this.service; + + if (service == null) { + // Don't increment retry count + return SEND_LOG_RESULT_FAILURE_RETRIABLE; + } + + try { + service.sendEvent(packageName, DeployGateEvent.ACTION_SEND_CUSTOM_LOG, log.toExtras()); + return SEND_LOG_RESULT_SUCCESS; + } catch (RemoteException e) { + int currentAttempts = log.getAndIncrementRetryCount(); + + if (currentAttempts >= MAX_RETRY_COUNT) { + Logger.e("failed to send custom log and exceeded the max retry count: %s", e.getMessage()); + return SEND_LOG_RESULT_FAILURE_RETRY_EXCEEDED; + } else { + Logger.w("failed to send custom log %d times: %s", currentAttempts + 1, e.getMessage()); + } + + return SEND_LOG_RESULT_FAILURE_RETRIABLE; + } + } + + private void ensureHandlerInitialized() { + if (handler != null) { + return; + } + + synchronized (configuration) { + if (handler != null) { + return; + } + + handler = new CustomLogHandler(thread.getLooper(), this, configuration.backpressure, configuration.bufferSize); + } + } + + /** + * This handler behaves as a ordered-buffer of instructions. + *

+ * The instruction of adding a new log and sending buffered logs are synchronized. + */ + private static class CustomLogHandler extends Handler { + private static final int WHAT_SEND_LOGS = 0x30; + private static final int WHAT_ADD_NEW_LOG = 0x100; + + private final CustomLogInstructionSerializer serializer; + private final CustomLogConfiguration.Backpressure backpressure; + private final int bufferSize; + private final LinkedList customLogs; + + /** + * @param looper + * Do not use Main Looper to avoid wasting the main thread resource. + * @param serializer + * an instance to send logs + * @param backpressure + * the backpressure strategy of the log buffer, not of instructions. + * @param bufferSize + * the max size of the log buffer, not of instructions. + */ + CustomLogHandler( + Looper looper, + CustomLogInstructionSerializer serializer, + CustomLogConfiguration.Backpressure backpressure, + int bufferSize + ) { + super(looper); + this.serializer = serializer; + this.backpressure = backpressure; + this.bufferSize = bufferSize; + this.customLogs = new LinkedList<>(); + } + + /** + * Cancel the send-logs instruction in the handler message queue. + * This doesn't interrupt the thread and stop the sending-logs instruction that is running at the time. + */ + void cancelPendingSendLogsInstruction() { + removeMessages(WHAT_SEND_LOGS); + } + + /** + * Enqueue the send-logs instruction in the handler message queue unless enqueued. + */ + void enqueueSendLogsInstruction() { + if (hasMessages(WHAT_SEND_LOGS)) { + return; + } + + sendEmptyMessage(WHAT_SEND_LOGS); + } + + /** + * Enqueue new add-new-log instruction to the handler message queue. + */ + void enqueueAddNewLogInstruction(CustomLog log) { + Message msg = obtainMessage(WHAT_ADD_NEW_LOG, log); + sendMessage(msg); + } + + /** + * Append the new log to the log buffer if the backpressure is not dropping LATEST. + * + * @param log + * a new log + */ + void addLogToLast(CustomLog log) { + boolean dropFirst = backpressure == CustomLogConfiguration.Backpressure.DROP_BUFFER_BY_OLDEST; + int droppedCount = 0; + + while (customLogs.size() >= bufferSize) { + if (dropFirst) { + customLogs.poll(); + droppedCount++; + } else { + Logger.d("the log buffer is already full and reject the new log."); + return; + } + } + + Logger.d("filtered out %d overflowed logs from the oldest.", droppedCount); + + customLogs.addLast(log); + + if (serializer.hasServiceConnection()) { + sendAllInBuffer(); + } + } + + /** + * send all logs from the oldest in the log buffers + * If sending a log failed, it will be put into the head of the log buffer. And then, this schedules the sending-logs instruction with some delay. + */ + void sendAllInBuffer() { + boolean retry = false; + + while (!customLogs.isEmpty()) { + CustomLog log = customLogs.poll(); + + if (serializer.sendLog(log) == SEND_LOG_RESULT_FAILURE_RETRIABLE) { + // Don't lost the failed log + customLogs.addFirst(log); + retry = true; + break; + } + } + + if (retry) { + try { + removeMessages(WHAT_SEND_LOGS); + Message msg = obtainMessage(WHAT_SEND_LOGS); + // Put the retry message at front of the queue because delay or enqueuing a message may cause unexpected overflow of the buffer. + sendMessageAtFrontOfQueue(msg); + Thread.sleep(600); // experimental valuea + } catch (InterruptedException ignore) { + } + } + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case WHAT_SEND_LOGS: { + sendAllInBuffer(); + + break; + } + case WHAT_ADD_NEW_LOG: { + CustomLog log = (CustomLog) msg.obj; + addLogToLast(log); + + break; + } + } + } + } +} diff --git a/sdk/src/main/java/com/deploygate/sdk/DeployGate.java b/sdk/src/main/java/com/deploygate/sdk/DeployGate.java index b333aba..7888258 100644 --- a/sdk/src/main/java/com/deploygate/sdk/DeployGate.java +++ b/sdk/src/main/java/com/deploygate/sdk/DeployGate.java @@ -48,7 +48,6 @@ * @author tnj */ public class DeployGate { - private static final String TAG = "DeployGate"; private static final int SDK_VERSION = 4; @@ -70,6 +69,7 @@ public class DeployGate { private final Context mApplicationContext; private final Handler mHandler; + private final CustomLogInstructionSerializer mCustomLogInstructionSerializer; private final HashSet mCallbacks; private final String mExpectedAuthor; private String mAuthor; @@ -116,8 +116,6 @@ public void onEvent( } } - ; - private void onInitialized( final boolean isManaged, final boolean isAuthorized, @@ -226,10 +224,12 @@ public void run() { private DeployGate( Context applicationContext, String author, - DeployGateCallback callback + DeployGateCallback callback, + CustomLogConfiguration customLogConfiguration ) { - mHandler = new Handler(); mApplicationContext = applicationContext; + mHandler = new Handler(); + mCustomLogInstructionSerializer = new CustomLogInstructionSerializer(mApplicationContext.getPackageName(), customLogConfiguration); mCallbacks = new HashSet(); mExpectedAuthor = author; @@ -246,10 +246,12 @@ private DeployGate( private boolean initService(boolean isBoot) { if (isDeployGateAvailable()) { Log.v(TAG, "DeployGate installation detected. Initializing."); + mCustomLogInstructionSerializer.setDisabled(false); bindToService(isBoot); return true; } else { Log.v(TAG, "DeployGate is not available on this device."); + mCustomLogInstructionSerializer.setDisabled(true); mInitializedLatch.countDown(); mIsDeployGateAvailable = false; callbackDeployGateUnavailable(); @@ -305,6 +307,7 @@ public void onServiceConnected( public void onServiceDisconnected(ComponentName name) { Log.v(TAG, "DeployGate service disconneced"); mRemoteService = null; + mCustomLogInstructionSerializer.disconnect(); } }, Context.BIND_AUTO_CREATE); } @@ -317,6 +320,7 @@ private void requestServiceInit(final boolean isBoot) { args.putInt(DeployGateEvent.EXTRA_SDK_VERSION, SDK_VERSION); try { mRemoteService.init(mRemoteCallback, mApplicationContext.getPackageName(), args); + mCustomLogInstructionSerializer.connect(mRemoteService); } catch (RemoteException e) { Log.w(TAG, "DeployGate service failed to be initialized."); } @@ -566,6 +570,35 @@ public static void install( String author, DeployGateCallback callback, boolean forceApplyOnReleaseBuild + ) { + install(app, author, callback, forceApplyOnReleaseBuild, new CustomLogConfiguration.Builder().build()); + } + + /** + * Install DeployGate on your application instance and register a callback + * listener. Call this method inside of your {@link Application#onCreate()} + * once. + * + * @param app + * Application instance, typically just pass this. + * @param author + * author username of this app. Can be null. + * @param callback + * Callback interface to listen events. Can be null. + * @param forceApplyOnReleaseBuild + * if you want to keep DeployGate alive on + * the release build, set this true. + * @param customLogConfiguration + * set a configuration for custom logging + * + * @since r4.4 + */ + public static void install( + Application app, + String author, + DeployGateCallback callback, + boolean forceApplyOnReleaseBuild, + CustomLogConfiguration customLogConfiguration ) { if (sInstance != null) { Log.w(TAG, "DeployGate.install was already called. Ignoring."); @@ -577,7 +610,7 @@ public static void install( } Thread.setDefaultUncaughtExceptionHandler(new DeployGateUncaughtExceptionHandler(Thread.getDefaultUncaughtExceptionHandler())); - sInstance = new DeployGate(app.getApplicationContext(), author, callback); + sInstance = new DeployGate(app.getApplicationContext(), author, callback, customLogConfiguration); } /** @@ -1065,15 +1098,7 @@ void sendLog( String type, String body ) { - Bundle extras = new Bundle(); - extras.putSerializable(DeployGateEvent.EXTRA_LOG, body); - extras.putSerializable(DeployGateEvent.EXTRA_LOG_TYPE, type); - - try { - mRemoteService.sendEvent(mApplicationContext.getPackageName(), DeployGateEvent.ACTION_SEND_CUSTOM_LOG, extras); - } catch (RemoteException e) { - Log.w(TAG, "failed to send custom log: " + e.getMessage()); - } + mCustomLogInstructionSerializer.requestSendingLog(type, body); } /** diff --git a/sdk/src/main/java/com/deploygate/sdk/internal/Logger.java b/sdk/src/main/java/com/deploygate/sdk/internal/Logger.java new file mode 100644 index 0000000..64ed6b0 --- /dev/null +++ b/sdk/src/main/java/com/deploygate/sdk/internal/Logger.java @@ -0,0 +1,53 @@ +package com.deploygate.sdk.internal; + +import android.util.Log; + +import java.util.Locale; + +public class Logger { + private static final String TAG = "DeployGateSDK"; + + public static void d( + String format, + Object... args + ) { + Log.d(TAG, String.format(Locale.US, format, args)); + } + + public static void i( + String format, + Object... args + ) { + Log.i(TAG, String.format(Locale.US, format, args)); + } + + public static void w( + String format, + Object... args + ) { + Log.w(TAG, String.format(Locale.US, format, args)); + } + + public static void w( + Throwable th, + String format, + Object... args + ) { + Log.w(TAG, String.format(Locale.US, format, args), th); + } + + public static void e( + String format, + Object... args + ) { + Log.e(TAG, String.format(Locale.US, format, args)); + } + + public static void e( + Throwable th, + String format, + Object... args + ) { + Log.e(TAG, String.format(Locale.US, format, args), th); + } +} diff --git a/sdk/src/release/java/com/deploygate/sdk/internal/Config.java b/sdk/src/release/java/com/deploygate/sdk/internal/Config.java new file mode 100644 index 0000000..6b1aeef --- /dev/null +++ b/sdk/src/release/java/com/deploygate/sdk/internal/Config.java @@ -0,0 +1,5 @@ +package com.deploygate.sdk.internal; + +public class Config { + public static final boolean DEBUG = false; +} diff --git a/sdk/src/test/java/com/deploygate/sdk/CustomLogInstructionSerializerTest.java b/sdk/src/test/java/com/deploygate/sdk/CustomLogInstructionSerializerTest.java new file mode 100644 index 0000000..67d8571 --- /dev/null +++ b/sdk/src/test/java/com/deploygate/sdk/CustomLogInstructionSerializerTest.java @@ -0,0 +1,249 @@ +package com.deploygate.sdk; + +import android.os.Bundle; +import android.os.DeadObjectException; +import android.os.RemoteException; +import android.os.TransactionTooLargeException; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.deploygate.sdk.truth.BundleSubject; +import com.deploygate.service.DeployGateEvent; +import com.deploygate.service.FakeDeployGateClientService; +import com.google.common.truth.Truth; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.mockito.verification.VerificationMode; +import org.robolectric.Shadows; +import org.robolectric.annotation.LooperMode; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.deploygate.sdk.mockito.BundleMatcher.eq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.robolectric.annotation.LooperMode.Mode.PAUSED; + +@RunWith(AndroidJUnit4.class) +@LooperMode(PAUSED) +public class CustomLogInstructionSerializerTest { + + private static final String PACKAGE_NAME = "com.deploygate.sample"; + + private FakeDeployGateClientService service; + + @Before + public void before() { + service = Mockito.spy(new FakeDeployGateClientService(PACKAGE_NAME)); + } + + @Test(timeout = 3000L) + public void check_buffer_size_works_with_drop_by_oldest() throws RemoteException, InterruptedException { + final int bufferSize = 5; + + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().setBufferSize(bufferSize).setBackpressure(CustomLogConfiguration.Backpressure.DROP_BUFFER_BY_OLDEST).build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + for (int i = 0; i < 10; i++) { + customLogInstructionSerializer.requestSendingLog("type", String.valueOf(i)); + } + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + customLogInstructionSerializer.connect(service); + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + for (int i = 0; i < bufferSize; i++) { + CustomLog log = new CustomLog("type", String.valueOf(i)); + Mockito.verify(service, never()).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(log.toExtras())); + } + + VerificationMode once = Mockito.times(1); + InOrder inOrder = Mockito.inOrder(service); + + for (int i = bufferSize; i < 10; i++) { + CustomLog log = new CustomLog("type", String.valueOf(i)); + inOrder.verify(service, once).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(log.toExtras())); + } + } + + @Test(timeout = 3000L) + public void check_buffer_size_works_with_preserve_buffer() throws RemoteException { + final int bufferSize = 5; + + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().setBufferSize(bufferSize).setBackpressure(CustomLogConfiguration.Backpressure.PRESERVE_BUFFER).build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + for (int i = 0; i < 10; i++) { + customLogInstructionSerializer.requestSendingLog("type", String.valueOf(i)); + } + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + customLogInstructionSerializer.connect(service); + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + VerificationMode once = Mockito.times(1); + InOrder inOrder = Mockito.inOrder(service); + + for (int i = 0; i < bufferSize; i++) { + CustomLog log = new CustomLog("type", String.valueOf(i)); + inOrder.verify(service, once).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(log.toExtras())); + } + + for (int i = bufferSize; i < 10; i++) { + CustomLog log = new CustomLog("type", String.valueOf(i)); + Mockito.verify(service, never()).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(log.toExtras())); + } + } + + @Test(timeout = 3000L) + public void sendLog_always_returns_retriable_status_if_service_is_none() throws RemoteException { + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + CustomLog noIssue = new CustomLog("type", "noIssue"); + CustomLog successAfterRetries = new CustomLog("type", "successAfterRetries"); + CustomLog retryExceeded = new CustomLog("type", "retryExceeded"); + + doNothing().when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(noIssue.toExtras())); + doThrow(TransactionTooLargeException.class).doThrow(DeadObjectException.class).doNothing().when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(successAfterRetries.toExtras())); + doThrow(RemoteException.class).when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(retryExceeded.toExtras())); + + for (int i = 0; i < 10; i++) { + Truth.assertThat(customLogInstructionSerializer.sendLog(noIssue)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + Truth.assertThat(customLogInstructionSerializer.sendLog(successAfterRetries)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + Truth.assertThat(customLogInstructionSerializer.sendLog(retryExceeded)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + } + } + + @Test(timeout = 3000L) + public void sendLog_uses_retry_barrier() throws RemoteException { + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + customLogInstructionSerializer.connect(service); + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).pause(); + + CustomLog noIssue = new CustomLog("type", "noIssue"); + CustomLog successAfterRetries = new CustomLog("type", "successAfterRetries"); + CustomLog retryExceeded = new CustomLog("type", "retryExceeded"); + + doNothing().when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(noIssue.toExtras())); + doThrow(TransactionTooLargeException.class).doThrow(DeadObjectException.class).doNothing().when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(successAfterRetries.toExtras())); + doThrow(RemoteException.class).when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(retryExceeded.toExtras())); + + Truth.assertThat(customLogInstructionSerializer.sendLog(noIssue)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_SUCCESS); + Truth.assertThat(customLogInstructionSerializer.sendLog(successAfterRetries)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + Truth.assertThat(customLogInstructionSerializer.sendLog(successAfterRetries)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + Truth.assertThat(customLogInstructionSerializer.sendLog(successAfterRetries)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_SUCCESS); + Truth.assertThat(customLogInstructionSerializer.sendLog(retryExceeded)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + Truth.assertThat(customLogInstructionSerializer.sendLog(retryExceeded)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRIABLE); + Truth.assertThat(customLogInstructionSerializer.sendLog(retryExceeded)).isEqualTo(CustomLogInstructionSerializer.SEND_LOG_RESULT_FAILURE_RETRY_EXCEEDED); + } + + @Test(timeout = 3000L) + public void requestSendingLog_works_regardless_of_service() throws RemoteException { + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + // Don't connect a service + + for (int i = 0; i < 30; i++) { + customLogInstructionSerializer.requestSendingLog("type", "body"); + } + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + Truth.assertThat(customLogInstructionSerializer.getPendingCount()).isEqualTo(30); + } + + @Test(timeout = 3000L) + public void requestSendingLog_does_nothing_if_disabled() throws RemoteException { + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + customLogInstructionSerializer.setDisabled(true); + + for (int i = 0; i < 30; i++) { + customLogInstructionSerializer.requestSendingLog("type", "body"); + } + + Truth.assertThat(customLogInstructionSerializer.hasHandlerInitialized()).isFalse(); + Truth.assertThat(customLogInstructionSerializer.getPendingCount()).isEqualTo(0); + + // Even if a service connection is established, this does nothing. + customLogInstructionSerializer.connect(service); + + for (int i = 0; i < 30; i++) { + customLogInstructionSerializer.requestSendingLog("type", "body"); + } + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + Truth.assertThat(customLogInstructionSerializer.getPendingCount()).isEqualTo(0); + } + + @Test(timeout = 3000L) + public void retry_barrier_can_prevent_holding_logs_that_always_fail() throws RemoteException, InterruptedException { + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + doThrow(RemoteException.class).when(service).sendEvent(any(), any(), any()); + + customLogInstructionSerializer.connect(service); + + for (int i = 0; i < 10; i++) { + CustomLog log = new CustomLog("type", String.valueOf(i)); + customLogInstructionSerializer.requestSendingLog(log.type, log.body); + } + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + while (customLogInstructionSerializer.getPendingCount() > 0) { + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idleFor(100, TimeUnit.MILLISECONDS); + } + + Truth.assertThat(customLogInstructionSerializer.getPendingCount()).isEqualTo(0); + Mockito.verify(service, times((CustomLogInstructionSerializer.MAX_RETRY_COUNT + 1) * 10)).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), any()); + } + + @Test(timeout = 3000L) + public void requestSendingLog_works_as_expected_with_retry_barrier() throws RemoteException { + CustomLogConfiguration configuration = new CustomLogConfiguration.Builder().build(); + CustomLogInstructionSerializer customLogInstructionSerializer = new CustomLogInstructionSerializer(PACKAGE_NAME, configuration); + + CustomLog noIssue = new CustomLog("type", "noIssue"); + CustomLog successAfterRetries = new CustomLog("type", "successAfterRetries"); + CustomLog retryExceeded = new CustomLog("type", "retryExceeded"); + + //noinspection unchecked + doThrow(TransactionTooLargeException.class, DeadObjectException.class).doCallRealMethod().when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(successAfterRetries.toExtras())); + doThrow(RemoteException.class).when(service).sendEvent(eq(PACKAGE_NAME), eq(DeployGateEvent.ACTION_SEND_CUSTOM_LOG), eq(retryExceeded.toExtras())); + + customLogInstructionSerializer.connect(service); + + customLogInstructionSerializer.requestSendingLog(successAfterRetries.type, successAfterRetries.body); + customLogInstructionSerializer.requestSendingLog(noIssue.type, noIssue.body); + customLogInstructionSerializer.requestSendingLog(retryExceeded.type, retryExceeded.body); + + Shadows.shadowOf(customLogInstructionSerializer.getLooper()).idle(); + + List extras = service.getEventExtraList(DeployGateEvent.ACTION_SEND_CUSTOM_LOG); + + Truth.assertThat(extras).hasSize(2); + BundleSubject.assertThat(extras.get(0)).isEqualTo(successAfterRetries.toExtras()); + BundleSubject.assertThat(extras.get(1)).isEqualTo(noIssue.toExtras()); + } +} diff --git a/sdk/src/test/java/com/deploygate/sdk/CustomLogTest.java b/sdk/src/test/java/com/deploygate/sdk/CustomLogTest.java new file mode 100644 index 0000000..bc1391b --- /dev/null +++ b/sdk/src/test/java/com/deploygate/sdk/CustomLogTest.java @@ -0,0 +1,31 @@ +package com.deploygate.sdk; + +import android.os.Bundle; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.deploygate.sdk.truth.BundleSubject; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class CustomLogTest { + + @Test + public void CustomLog_toExtras_must_be_valid_format() { + CustomLog log = new CustomLog("error", "yes"); + + BundleSubject.assertThat(log.toExtras()).isEqualTo(createLogExtra("error", "yes")); + } + + private static Bundle createLogExtra( + String type, + String body + ) { + Bundle bundle = new Bundle(); + bundle.putString("logType", type); + bundle.putString("log", body); + return bundle; + } +} diff --git a/sdk/src/test/java/com/deploygate/sdk/DeployGateInterfaceTest.java b/sdk/src/test/java/com/deploygate/sdk/DeployGateInterfaceTest.java index 0505652..08956e8 100644 --- a/sdk/src/test/java/com/deploygate/sdk/DeployGateInterfaceTest.java +++ b/sdk/src/test/java/com/deploygate/sdk/DeployGateInterfaceTest.java @@ -90,6 +90,11 @@ public void install__Application_String_DeployGateCallback_boolean() { DeployGate.install(app, "author", callback, true); } + @Test + public void install__Application_String_DeployGateCallback_CustomLogConfiguration() { + DeployGate.install(app, "author", callback, true, new CustomLogConfiguration.Builder().build()); + } + @Test public void refresh() { DeployGate.refresh(); diff --git a/sdk/src/test/java/com/deploygate/sdk/helper/Bundles.java b/sdk/src/test/java/com/deploygate/sdk/helper/Bundles.java new file mode 100644 index 0000000..4f74555 --- /dev/null +++ b/sdk/src/test/java/com/deploygate/sdk/helper/Bundles.java @@ -0,0 +1,28 @@ +package com.deploygate.sdk.helper; + +import android.os.Bundle; + +import java.util.Objects; + +public class Bundles { + public static boolean equals( + Bundle left, + Bundle right + ) { + if (left.size() != right.size()) { + return false; + } + + if (!left.keySet().equals(right.keySet())) { + return false; + } + + for (final String key : left.keySet()) { + if (!Objects.equals(left.get(key), right.get(key))) { + return false; + } + } + + return true; + } +} diff --git a/sdk/src/test/java/com/deploygate/sdk/mockito/BundleMatcher.java b/sdk/src/test/java/com/deploygate/sdk/mockito/BundleMatcher.java new file mode 100644 index 0000000..a9f8771 --- /dev/null +++ b/sdk/src/test/java/com/deploygate/sdk/mockito/BundleMatcher.java @@ -0,0 +1,34 @@ +package com.deploygate.sdk.mockito; + +import android.os.Bundle; + +import com.deploygate.sdk.helper.Bundles; + +import org.mockito.ArgumentMatcher; + +import static org.mockito.internal.progress.ThreadSafeMockingProgress.mockingProgress; + +public class BundleMatcher { + public static Bundle eq(Bundle expected) { + mockingProgress().getArgumentMatcherStorage().reportMatcher(new Equals(expected)); + return expected; + } + + private static class Equals implements ArgumentMatcher { + private final Bundle expected; + + public Equals(Bundle expected) { + this.expected = expected; + } + + @Override + public boolean matches(Bundle argument) { + return Bundles.equals(expected, argument); + } + + @Override + public String toString() { + return expected.toString(); + } + } +} diff --git a/sdk/src/test/java/com/deploygate/sdk/truth/BundleSubject.java b/sdk/src/test/java/com/deploygate/sdk/truth/BundleSubject.java new file mode 100644 index 0000000..0e8402b --- /dev/null +++ b/sdk/src/test/java/com/deploygate/sdk/truth/BundleSubject.java @@ -0,0 +1,61 @@ +package com.deploygate.sdk.truth; + +import android.os.Bundle; + +import com.deploygate.sdk.helper.Bundles; +import com.google.common.truth.Fact; +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.Subject; + +import java.util.Locale; + +import static com.google.common.truth.Truth.assertAbout; + +public class BundleSubject extends Subject { + public static Factory bundles() { + return new Factory() { + @Override + public BundleSubject createSubject( + FailureMetadata metadata, + Bundle actual + ) { + return new BundleSubject(metadata, actual); + } + }; + } + + public static BundleSubject assertThat(Bundle actual) { + return assertAbout(bundles()).that(actual); + } + + private final Bundle actual; + + /** + * Constructor for use by subclasses. If you want to create an instance of this class itself, call + * {@link Subject#check(String, Object ..) check(...)}{@code .that(actual)}. + * + * @param metadata + * @param actual + */ + protected BundleSubject( + FailureMetadata metadata, + Bundle actual + ) { + super(metadata, actual); + this.actual = actual; + } + + @Override + public void isEqualTo(Object expected) { + if (!Bundles.equals((Bundle) expected, actual)) { + failWithActual(Fact.simpleFact(String.format(Locale.US, "%s to be same to %s", expected.toString(), actual.toString()))); + } + } + + @Override + public void isNotEqualTo(Object unexpected) { + if (Bundles.equals((Bundle) unexpected, actual)) { + failWithActual(Fact.simpleFact(String.format(Locale.US, "%s to unexpectedly be same to %s", unexpected.toString(), actual.toString()))); + } + } +} diff --git a/sdk/src/test/java/com/deploygate/service/FakeDeployGateClientService.java b/sdk/src/test/java/com/deploygate/service/FakeDeployGateClientService.java new file mode 100644 index 0000000..d2cf8ec --- /dev/null +++ b/sdk/src/test/java/com/deploygate/service/FakeDeployGateClientService.java @@ -0,0 +1,79 @@ +package com.deploygate.service; + +import android.os.Bundle; +import android.os.IBinder; +import android.os.RemoteException; + +import com.google.common.truth.Truth; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public class FakeDeployGateClientService implements IDeployGateSdkService { + public static final String ACTION_INIT = "com.deploygate.sdk.fake.INIT"; + + private final String packageName; + private final Map> eventExtrasMap; + + public FakeDeployGateClientService( + String packageName + ) { + this.packageName = packageName; + this.eventExtrasMap = new HashMap<>(); + + for (final Field field : DeployGateEvent.class.getFields()) { + final String name = field.getName(); + + if (name.startsWith("ACTION_")) { + try { + eventExtrasMap.put((String) field.get(null), new ArrayList<>()); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException(String.format(Locale.US, "cannot access %s", name), e); + } + } + } + } + + public List getEventExtraList(String action) { + return Collections.unmodifiableList(eventExtrasMap.getOrDefault(action, new ArrayList<>())); + } + + @Override + public void init( + IDeployGateSdkServiceCallback callback, + String packageName, + Bundle extras + ) throws RemoteException { + Truth.assertThat(packageName).isEqualTo(this.packageName); + recordEvent(ACTION_INIT, extras); + } + + @Override + public void sendEvent( + String packageName, + String action, + Bundle extras + ) throws RemoteException { + Truth.assertThat(packageName).isEqualTo(this.packageName); + recordEvent(action, extras); + } + + @Override + public IBinder asBinder() { + return null; + } + + private void recordEvent( + String action, + Bundle extras + ) { + List extraList = Objects.requireNonNull(eventExtrasMap.get(action), String.format(Locale.US, "%s is an unknown action", action)); + extraList.add(extras); + } +} diff --git a/sdkMock/src/main/java/com/deploygate/sdk/CustomLogConfiguration.java b/sdkMock/src/main/java/com/deploygate/sdk/CustomLogConfiguration.java new file mode 100644 index 0000000..2978d90 --- /dev/null +++ b/sdkMock/src/main/java/com/deploygate/sdk/CustomLogConfiguration.java @@ -0,0 +1,51 @@ +package com.deploygate.sdk; + +public class CustomLogConfiguration { + public enum Backpressure { + /** + * SDK rejects new logs if buffer size is exceeded + */ + PRESERVE_BUFFER, + + /** + * SDK drops logs from the oldest if buffer size is exceeded + */ + DROP_BUFFER_BY_OLDEST + } + + private CustomLogConfiguration( + ) { + } + + public static class Builder { + /** + * @param backpressure + * the strategy of the backpressure in the log buffer + * + * @return self + * + * @see Backpressure + */ + public Builder setBackpressure(Backpressure backpressure) { + if (backpressure == null) { + throw new IllegalArgumentException("backpressure must be non-null"); + } + + return this; + } + + /** + * @param bufferSize + * the max size of the log buffer + * + * @return self + */ + public Builder setBufferSize(int bufferSize) { + return this; + } + + public CustomLogConfiguration build() { + return new CustomLogConfiguration(); + } + } +} diff --git a/sdkMock/src/main/java/com/deploygate/sdk/DeployGate.java b/sdkMock/src/main/java/com/deploygate/sdk/DeployGate.java index 976d778..be7867f 100644 --- a/sdkMock/src/main/java/com/deploygate/sdk/DeployGate.java +++ b/sdkMock/src/main/java/com/deploygate/sdk/DeployGate.java @@ -50,6 +50,15 @@ public static void install( ) { } + public static void install( + Application app, + String author, + DeployGateCallback callback, + boolean forceApplyOnReleaseBuild, + CustomLogConfiguration configuration + ) { + } + public static void refresh() { }