Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9acb6c8
feat: SDK multi instance support
arifBurakDemiray Jul 22, 2026
0e522f8
fix: make feedback requests class
arifBurakDemiray Jul 23, 2026
56792b0
feat: remove instance
arifBurakDemiray Jul 23, 2026
17d9ade
Merge pull request #587 from Countly/staging
arifBurakDemiray Jul 23, 2026
3e27c0e
Merge branch 'staging' into multi-instance-support
arifBurakDemiray Jul 24, 2026
dcbe06a
fix: push storage across multi instance
arifBurakDemiray Jul 24, 2026
25c4f2f
feat: multi instancing example
arifBurakDemiray Jul 24, 2026
189ebac
feat: some hardenings to migration helper
arifBurakDemiray Jul 24, 2026
bbbe992
fix: api level test
arifBurakDemiray Jul 24, 2026
13851b4
Merge branch 'staging' into multi-instance-support
arifBurakDemiray Aug 10, 2026
e5e9e39
feat: review AI
arifBurakDemiray Aug 17, 2026
fc9ad55
feat: review
arifBurakDemiray Aug 17, 2026
1c90ae9
feat: review
arifBurakDemiray Aug 17, 2026
9df4c34
feat: review
arifBurakDemiray Aug 18, 2026
82eda8c
feat: review
arifBurakDemiray Aug 18, 2026
ef05950
fix: event queue test
arifBurakDemiray Aug 18, 2026
1b8450d
fix: test additions
arifBurakDemiray Aug 18, 2026
e285044
refactor: changelog
arifBurakDemiray Aug 18, 2026
710c6eb
refactor: remove log changes
arifBurakDemiray Aug 19, 2026
03e9f3e
fix: null check
arifBurakDemiray Aug 19, 2026
7548a16
fix: null checks to lifecycle at least started
arifBurakDemiray Aug 20, 2026
3c6c408
fix: review things
arifBurakDemiray Aug 20, 2026
4ca7004
Merge branch 'staging' into multi-instance-support
arifBurakDemiray Aug 25, 2026
a5556f2
Merge branch 'staging' into multi-instance-support
arifBurakDemiray Aug 25, 2026
f79e3f0
fix: tighten for ANR and NPE1
arifBurakDemiray Aug 25, 2026
ba971cc
Merge branch 'multi-instance-support' of https://github.com/Countly/c…
arifBurakDemiray Aug 25, 2026
e1e05b6
Multi Fixes
turtledreams Aug 25, 2026
f3d283f
Merge branch 'multi-instance-support' of origin into multi-fixes
turtledreams Aug 25, 2026
6823490
Update CHANGELOG.md
arifBurakDemiray Aug 25, 2026
ed53a3a
Update CHANGELOG.md
arifBurakDemiray Aug 25, 2026
ae26bba
Merge pull request #596 from Countly/multi-fixes
arifBurakDemiray Aug 25, 2026
feea746
feat: combine ideas
arifBurakDemiray Aug 25, 2026
df6ebe5
feat: combine ideas
arifBurakDemiray Aug 25, 2026
b1b93f8
fix: improve duration asserts
arifBurakDemiray Aug 26, 2026
8f9b62f
feat: rename stop function
arifBurakDemiray Aug 26, 2026
9cfb71e
feat: multi-instance manual tests
arifBurakDemiray Aug 26, 2026
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
## XX.XX.XX
* Added support for multi-instancing, each with isolated storage, request queue, and device ID. Access a named instance with `Countly.instance(name)` and initialize it yourself, and manage instances with `Countly.getInstance(name)`, `Countly.listInstances()`, and `Countly.removeInstance(name)`.

`Countly.sharedInstance()` is unchanged, so existing integrations keep working. A named instance starts from empty storage and generates its own device ID, so do not move an existing integration onto one.

Push notifications and native crash reporting are process wide and stay with the default instance, and at most one content or feedback widget is displayed at a time across all instances.
* Improved the security of content, feedback widget, and push notification links by blocking the `data:`, `zip:`, and `intent:` URI schemes by default, both for opening links and for loading web view resources. They can be allowed with `setAllowedIntentSchemes(List)`.
* Added a new configuration option `enableClearStoredDeviceId()` that clears the stored device ID during init, so the SDK resolves a device ID from scratch instead of reusing the stored one.

* Updated the `androidx.annotation` dependency to 1.10.0, the `androidx.lifecycle` dependencies to 2.8.7, and the symbol upload plugin's OkHttp dependency to 4.12.0.

## 26.1.5
Expand Down
9 changes: 9 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="ly.count.android.demo.MultiInstanceHarness"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay"/>
</application>
</manifest>
133 changes: 133 additions & 0 deletions app/src/debug/java/ly/count/android/demo/MultiInstanceHarness.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package ly.count.android.demo;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.os.Debug;
import android.util.Log;
import java.io.File;
import java.util.List;
import ly.count.android.sdk.Countly;
import ly.count.android.sdk.CountlyConfig;

/**
* Debug-only, intent-driven driver for multi-instance manual testing. No UI, finishes immediately.
* Lives in src/debug so no tracked app file changes and the staging/multi-instance comparison stays valid.
*
* Every op logs under tag MIH so logcat is the single source of evidence.
*/
public class MultiInstanceHarness extends Activity {
private static final String T = "MIH";

@Override protected void onCreate(Bundle b) {
super.onCreate(b);
String op = str("op", "stats");
Log.i(T, "---- op=" + op + " begin");
long t0 = System.currentTimeMillis();
try {
run(op);
} catch (Throwable t) {
Log.e(T, "op=" + op + " THREW " + t.getClass().getSimpleName() + ": " + t.getMessage(), t);
}
Log.i(T, "---- op=" + op + " end took_ms=" + (System.currentTimeMillis() - t0));
stats("after-" + op);
finish();
}

private void run(String op) {
switch (op) {
case "init": initOne(str("name", null), str("appkey", null), str("salt", null), str("deviceid", null)); break;
case "event": inst().events().recordEvent(str("key", "harness_event")); break;
case "view": inst().views().startAutoStoppedView(str("viewname", "harness_view")); break;
case "userprop": inst().userProfile().setProperty("harness_prop", str("val", "v")); inst().userProfile().save(); break;
case "beginsession":inst().sessions().beginSession(); break;
case "endsession": inst().sessions().endSession(); break;
case "flush": inst().requestQueue().attemptToSendStoredRequests(); break;
case "stop": inst().stop(); Log.i(T, "stopped " + str("name", "?")); break;
case "halt": inst().halt(); Log.i(T, "halted " + str("name", "?")); break;
case "remove": Countly.removeInstance(str("name", null)); Log.i(T, "removed " + str("name", "?")); break;
case "haltall": Countly.haltAllInstances(); Log.i(T, "halted all"); break;
case "list": break; // stats() prints the registry
case "bulk": bulk(getIntent().getIntExtra("count", 10), str("appkey", null)); break;
default: Log.w(T, "unknown op " + op);
}
}

/** Initialises one named instance. A null/empty name means the default (shared) instance. */
private void initOne(String name, String appKey, String salt, String deviceId) {
String key = appKey != null ? appKey : App.getAppKey();
CountlyConfig cfg = new CountlyConfig(getApplication(), key, App.getServerUrl())
.setLoggingEnabled(true)
.setEventQueueSizeToSend(1); // send immediately so requests are observable
if (deviceId != null) {
cfg.setDeviceId(deviceId);
}
if (salt != null) {
cfg.setParameterTamperingProtectionSalt(salt);
}
Countly c = (name == null || name.isEmpty()) ? Countly.sharedInstance() : Countly.instance(name);
c.init(cfg);
Log.i(T, "init name=[" + name + "] appkey=[" + key + "] salt=[" + salt + "] deviceid=[" + deviceId
+ "] initialised=" + c.isInitialized());
}

/** Phase E: create and initialise N instances as fast as possible and report what breaks. */
private void bulk(int count, String appKey) {
String key = appKey != null ? appKey : App.getAppKey();
int ok = 0;
String firstFailure = null;
for (int i = 0; i < count; i++) {
String name = "bulk_" + i;
try {
Countly c = Countly.instance(name);
c.init(new CountlyConfig(getApplication(), key, App.getServerUrl())
.setLoggingEnabled(false) // keep logcat survivable at scale
.setDeviceId("dev_" + name));
if (c.isInitialized()) {
ok++;
}
} catch (Throwable t) {
if (firstFailure == null) {
firstFailure = "at i=" + i + " " + t.getClass().getName() + ": " + t.getMessage();
Log.e(T, "bulk FIRST FAILURE " + firstFailure, t);
}
}
if (i % 25 == 0) {
Log.i(T, "bulk progress i=" + i + " ok=" + ok + " " + resources());
}
}
Log.i(T, "bulk DONE requested=" + count + " initialised_ok=" + ok
+ " first_failure=" + (firstFailure == null ? "none" : firstFailure));
}

private Countly inst() {
String name = str("name", null);
Countly c = (name == null || name.isEmpty()) ? Countly.sharedInstance() : Countly.getInstance(name);
if (c == null) {
throw new IllegalStateException("no instance registered under [" + name + "]");
}
return c;
}

private String resources() {
Runtime r = Runtime.getRuntime();
File prefs = new File(getApplicationInfo().dataDir, "shared_prefs");
String[] files = prefs.list();
return "threads=" + Thread.activeCount()
+ " heap_used_mb=" + ((r.totalMemory() - r.freeMemory()) / 1048576)
+ " heap_max_mb=" + (r.maxMemory() / 1048576)
+ " native_mb=" + (Debug.getNativeHeapAllocatedSize() / 1048576)
+ " prefs_files=" + (files == null ? -1 : files.length);
}

private void stats(String when) {
List<String> named = Countly.listInstances();
Log.i(T, "STATS[" + when + "] registered_named=" + named.size() + " " + resources());
Log.i(T, "STATS[" + when + "] names=" + named);
}

private String str(String k, String dflt) {
String v = getIntent().getStringExtra(k);
return (v == null || v.isEmpty()) ? dflt : v;
}
}
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@
android:name=".ActivityExampleSessions"
android:label="@string/activity_name_sessions"
android:configChanges="orientation|screenSize"/>

<activity
android:name=".ActivityExampleMultiInstance"
android:label="@string/activity_name_multi_instance"
android:configChanges="orientation|screenSize"/>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package ly.count.android.demo;

import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;

import java.util.List;

import ly.count.android.sdk.Countly;
import ly.count.android.sdk.CountlyConfig;

/**
* Demonstrates running several independent Countly instances alongside the default (shared) one.
*
* Each named instance keeps its own request queue, event queue, device ID, consent state, logging
* state, and stored configuration, fully isolated from {@code Countly.sharedInstance()}. For demo
* simplicity the named instances are pointed at the same server and app key as the default instance
* (each with a distinct device ID); a real integration would use a separate Countly application's
* credentials.
*/
public class ActivityExampleMultiInstance extends AppCompatActivity {
private static final String ANALYTICS = "analytics";
private static final String BILLING = "billing";
private boolean analyticsLogging = true;

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_example_multi_instance);

// --- analytics instance ---
findViewById(R.id.btnCreateAnalytics).setOnClickListener(v -> createAndInit(ANALYTICS, "analytics-device"));

findViewById(R.id.btnRecordEventAnalytics).setOnClickListener(v -> {
Countly analytics = requireInitialized(ANALYTICS);
if (analytics == null) {
return;
}
analytics.events().recordEvent("analytics_event");
toast("Recorded 'analytics_event' on '" + ANALYTICS + "'");
});

findViewById(R.id.btnRecordViewAnalytics).setOnClickListener(v -> {
Countly analytics = requireInitialized(ANALYTICS);
if (analytics == null) {
return;
}
analytics.views().startAutoStoppedView("AnalyticsScreen");
toast("Started view 'AnalyticsScreen' on '" + ANALYTICS + "'");
});

findViewById(R.id.btnToggleLogAnalytics).setOnClickListener(v -> {
Countly analytics = requireInitialized(ANALYTICS);
if (analytics == null) {
return;
}
analyticsLogging = !analyticsLogging;
analytics.setLoggingEnabled(analyticsLogging);
toast("'" + ANALYTICS + "' logging " + (analyticsLogging ? "ENABLED" : "DISABLED") + " (default instance logging unaffected)");
});

findViewById(R.id.btnRemoveAnalytics).setOnClickListener(v -> {
if (Countly.getInstance(ANALYTICS) == null) {
toast("'" + ANALYTICS + "' is not registered, nothing to remove");
return;
}
Countly.removeInstance(ANALYTICS);
toast("Removed the '" + ANALYTICS + "' instance");
});

// --- billing instance (second named instance) ---
findViewById(R.id.btnCreateBilling).setOnClickListener(v -> createAndInit(BILLING, "billing-device"));

findViewById(R.id.btnRecordEventBilling).setOnClickListener(v -> {
Countly billing = requireInitialized(BILLING);
if (billing == null) {
return;
}
billing.events().recordEvent("billing_event");
toast("Recorded 'billing_event' on '" + BILLING + "'");
});

// --- default instance (contrast) ---
findViewById(R.id.btnRecordEventDefault).setOnClickListener(v -> {
//the default instance can be halted from this very screen ("Halt All Instances"), and the
//module accessors return null when an instance is not initialised
if (!Countly.sharedInstance().isInitialized()) {
toast("The default instance is not initialized (halt all resets it too). Restart the app to init it again.");
return;
}
Countly.sharedInstance().events().recordEvent("default_event");
toast("Recorded 'default_event' on the default (shared) instance");
});

// --- registry ---
findViewById(R.id.btnList).setOnClickListener(v -> {
List<String> names = Countly.listInstances();
toast("Registered instances: " + (names.isEmpty() ? "(none)" : names));
});

findViewById(R.id.btnGetAnalytics).setOnClickListener(v -> {
Countly existing = Countly.getInstance(ANALYTICS);
if (existing == null) {
toast("'" + ANALYTICS + "' is not registered");
} else {
toast("'" + ANALYTICS + "' exists, initialized: " + existing.isInitialized());
}
});

findViewById(R.id.btnHaltAll).setOnClickListener(v -> {
Countly.haltAllInstances();
toast("Halted every instance, the default one included. Stored data was ERASED: device IDs, consent, unsent requests and push preferences");
});
}

private void createAndInit(String name, String deviceId) {
Countly instance = Countly.instance(name);
if (instance.isInitialized()) {
toast("'" + name + "' is already initialized");
return;
}

// The name passed to Countly.instance(name) is what isolates this instance's storage. The
// distinct device ID keeps its identity separate. No Application class is given, so the SDK
// cannot observe the activity lifecycle for this instance - session control is switched to
// manual, otherwise an automatic session would begin at init (the app is in the foreground)
// that nothing could ever end, updating forever even in the background.
CountlyConfig config = new CountlyConfig(getApplicationContext(), App.getAppKey(), App.getServerUrl())
.setDeviceId(deviceId)
.enableManualSessionControl()
.setLoggingEnabled(true);

instance.init(config);
toast("Initialized '" + name + "' (device id: " + deviceId + ")");
}

/**
* Returns the initialized instance registered under the name, or null (with a hint toast). The
* returned handle - not a fresh {@code Countly.instance(name)} lookup - must be used for the
* follow-up call: re-fetching through instance(name) would silently re-create a fresh,
* uninitialized instance if removeInstance(name) ran in between, and its module accessors
* (events(), views(), ...) would then return null.
*/
private Countly requireInitialized(String name) {
Countly instance = Countly.getInstance(name);
if (instance == null || !instance.isInitialized()) {
toast("Create and initialize the '" + name + "' instance first");
return null;
}
return instance;
}

private void toast(String message) {
Log.d(Countly.TAG, "[MultiInstanceDemo] " + message);
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/ly/count/android/demo/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ public class App extends Application {

private final static long applicationStartTimestamp = System.currentTimeMillis();

// Exposed so example activities (such as the multi-instance demo) can spin up additional named
// instances pointed at the same server and app key without duplicating the configuration.
public static String getServerUrl() {
return COUNTLY_SERVER_URL;
}

public static String getAppKey() {
return COUNTLY_APP_KEY;
}

@Override
public void onCreate() {
super.onCreate();
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/ly/count/android/demo/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,8 @@ public void onClickButtonLocation(View v) {
public void onClickButtonSessions(View v) {
startActivity(new Intent(this, ActivityExampleSessions.class));
}

public void onClickButtonMultiInstance(View v) {
startActivity(new Intent(this, ActivityExampleMultiInstance.class));
}
}
Loading
Loading