Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions test-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ Tier 3 configures `EventPersistence.IMMEDIATE`. With that setting, events record
**Eval+Kill now** should be recovered and delivered after the next launch even though no process
code ran on exit.

## Event storage

The text under the persistence picker shows what the SDK has on disk for this app and mobile key:
`pending-` logs that processes append to, and `ready-` batches waiting for delivery, with the number
of events each holds. It reads names, sizes and modification times only, so looking never changes
what is there. It reads them again every second while the app is in the foreground, so a file that
comes and goes within a second, such as a `new-` log being opened, is never shown. A long press
copies the directory's path.

## Over-refresh scenario

Create a boolean flag named `trackevents-test` with event tracking enabled. Tap
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.launchdarkly.sdk.android;

import java.io.File;

/**
* Where the SDK keeps events for one mobile key, for the test app's event storage view.
* <p>
* In the SDK's package because {@link EventDirectory} is package-private there, so the view asks the SDK
* for the path rather than working out the hash a second time. The app and the library share one class
* loader, so that access holds at run time.
*/
public final class EventStorageLocation {
private EventStorageLocation() {
}

public static File directory(File noBackupFilesDir, String mobileKey) {
return EventDirectory.defaultDirectory(noBackupFilesDir, mobileKey);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package com.launchdarkly.sdk.testapp;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.format.Formatter;
import android.widget.TextView;

import com.launchdarkly.sdk.android.EventStorageLocation;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;

/**
* What the SDK has left in this app's event directory: the logs live processes append to and the batches
* waiting to be delivered, shown in a text view and read again every second while it is watched.
* <p>
* Read from the file names, sizes and modification times alone, and never opened, so looking cannot change
* what it looks at: a lock taken to see whether a log is held would stop another process's recovery from
* claiming it.
* <p>
* Anything that comes and goes between two reads is never shown, such as a {@code new-} log, which is
* renamed as soon as it has its header, or a batch delivered within a second of being closed.
*/
final class EventStorageInspector {
private static final long INTERVAL_MILLIS = 1_000;
/** Enough to see a few processes' logs and a few batches without pushing the rest of the screen off it. */
private static final int MAX_ENTRIES_SHOWN = 6;

private static final String LOG_PREFIX = "pending-";
private static final String OPENING_PREFIX = "new-";
private static final String BATCH_PREFIX = "ready-";
/** The version of how the rest of a batch's name reads; the count follows the payload ID. */
private static final String OWN_BATCH_PREFIX = BATCH_PREFIX + "v1-";

private final Context context;
private final TextView view;
private final File directory;
private final Handler main = new Handler(Looper.getMainLooper());
private final Runnable tick = new Runnable() {
@Override
public void run() {
refresh();
main.postDelayed(this, INTERVAL_MILLIS);
}
};
private boolean watching;

EventStorageInspector(Context context, TextView view, String mobileKey) {
this.context = context;
this.view = view;
this.directory = EventStorageLocation.directory(context.getNoBackupFilesDir(), mobileKey);
}

File directory() {
return directory;
}

void startWatching() {
if (watching) {
return;
}
watching = true;
tick.run();
}

void stopWatching() {
watching = false;
main.removeCallbacks(tick);
}

private void refresh() {
File[] files = directory.listFiles(file -> !file.getName().startsWith("."));
if (files == null) {
view.setText("Event storage: nothing stored yet");
return;
}
Arrays.sort(files, (a, b) -> {
int byTime = Long.compare(a.lastModified(), b.lastModified());
return byTime != 0 ? byTime : a.getName().compareTo(b.getName());
});

int logs = 0;
int batches = 0;
int events = 0;
List<String> lines = new ArrayList<>();
SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss", Locale.US);
for (File file : files) {
String name = file.getName();
int count = eventsInBatchNamed(name);
if (name.startsWith(LOG_PREFIX)) {
logs++;
} else if (name.startsWith(BATCH_PREFIX)) {
batches++;
events += Math.max(count, 0);
}
lines.add(String.format(Locale.US, "%-5s %-18s %s%s · %s",
kind(name), shortened(name), count >= 0 ? count + " ev · " : "",
Formatter.formatShortFileSize(context, file.length()),
time.format(new Date(file.lastModified()))));
}

StringBuilder text = new StringBuilder(String.format(Locale.US,
"Event storage: %d log(s), %d batch(es) holding %d event(s)", logs, batches, events));
// The newest last, so those are the ones shown.
int hidden = Math.max(0, lines.size() - MAX_ENTRIES_SHOWN);
if (hidden > 0) {
text.append(String.format(Locale.US, "\n… %d older", hidden));
}
for (String line : lines.subList(hidden, lines.size())) {
text.append('\n').append(line);
}
view.setText(text);
}

private static String kind(String name) {
if (name.startsWith(LOG_PREFIX)) {
return "log";
}
if (name.startsWith(OPENING_PREFIX)) {
return "new";
}
if (name.startsWith(BATCH_PREFIX)) {
return "batch";
}
return "other";
}

/** The prefix and the start of the UUID after it, which is enough to tell files apart on a phone. */
private static String shortened(String name) {
int prefixEnd = name.startsWith(OWN_BATCH_PREFIX) ? OWN_BATCH_PREFIX.length() : name.indexOf('-') + 1;
int end = prefixEnd + 8;
return name.length() <= end + 1 ? name : name.substring(0, end) + "…";
}

/** What a batch's name says it holds, {@code ready-v1-<payloadId>.<count>}, or -1 for anything else. */
private static int eventsInBatchNamed(String name) {
if (!name.startsWith(OWN_BATCH_PREFIX)) {
return -1;
}
String[] fields = name.substring(OWN_BATCH_PREFIX.length()).split("\\.", -1);
if (fields.length < 2) {
return -1;
}
try {
return Integer.parseInt(fields[1]);
} catch (NumberFormatException e) {
return -1;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ public class MainActivity extends AppCompatActivity {
private final ExposureCountingHook slowHook =
new ExposureCountingHook("slow", this::updateDedupeStatus);
private final AtomicInteger evaluationsRequested = new AtomicInteger();
/** Null without a mobile key, which is what names the directory. */
private EventStorageInspector storageInspector;

private static boolean isStaging() {
return "staging".equalsIgnoreCase(BuildConfig.LD_ENVIRONMENT);
Expand Down Expand Up @@ -177,9 +179,38 @@ public void onCreate(Bundle savedInstanceState) {
return;
}

setupStorageInspector();
ldClient = startClient(eventPersistence, offlineSwitch().isChecked());
}

@Override
protected void onResume() {
super.onResume();
if (storageInspector != null) {
storageInspector.startWatching();
}
}

@Override
protected void onPause() {
if (storageInspector != null) {
storageInspector.stopWatching();
}
super.onPause();
}

private void setupStorageInspector() {
TextView status = findViewById(R.id.storage_status);
storageInspector = new EventStorageInspector(this, status, BuildConfig.MOBILE_KEY);
status.setOnLongClickListener(v -> {
String path = storageInspector.directory().getPath();
ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
clipboard.setPrimaryClip(ClipData.newPlainText("Event storage", path));
Toast.makeText(this, "Copied " + path, Toast.LENGTH_SHORT).show();
return true;
});
}

/**
* Blocks for up to {@link #INIT_WAIT_SECONDS} waiting for flags, and returns the client either way: if
* they have not arrived by then, it is usable with whatever it has cached.
Expand Down
17 changes: 16 additions & 1 deletion test-app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@
android:spinnerMode="dropdown" />
</LinearLayout>

<!--
What the SDK has on disk for this app and mobile key, read again every second while the app is in
the foreground: see EventStorageInspector. A long press copies the directory's path.
-->
<TextView
android:id="@+id/storage_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/persistence_row"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:fontFamily="monospace"
android:textColor="@android:color/black"
android:textSize="10sp" />

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
Expand Down Expand Up @@ -236,7 +251,7 @@
android:id="@+id/space"
android:layout_width="0dp"
android:layout_height="8dp"
android:layout_below="@+id/persistence_row"
android:layout_below="@+id/storage_status"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_alignParentEnd="true"
Expand Down
Loading