diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index a7303252c..9cc11caa8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -1,6 +1,7 @@ package io.sentry.android.core; import android.content.Context; +import android.content.SharedPreferences; import android.content.pm.PackageInfo; import io.sentry.core.EnvelopeReader; import io.sentry.core.IEnvelopeReader; @@ -9,7 +10,9 @@ import io.sentry.core.SentryOptions; import io.sentry.core.util.Objects; import java.io.File; +import java.util.UUID; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Android Options initializer, it reads configurations from AndroidManifest and sets to the @@ -17,6 +20,8 @@ */ final class AndroidOptionsInitializer { + private static final String SENTRY_DEVICE_ID = "sentry_device_id"; + /** private ctor */ private AndroidOptionsInitializer() {} @@ -68,9 +73,24 @@ static void init( options.addIntegration(new AnrIntegration()); options.addIntegration(new SessionTrackingIntegration()); - readDefaultOptionValues(options, context); + final SharedPreferences sharedPreferences = + context.getSharedPreferences(BuildConfig.LIBRARY_PACKAGE_NAME, Context.MODE_PRIVATE); + + String sentryDeviceId; + // cache it if not set + if (!sharedPreferences.contains(SENTRY_DEVICE_ID)) { + sentryDeviceId = UUID.randomUUID().toString(); + // set default sentry device id + sharedPreferences.edit().putString(SENTRY_DEVICE_ID, sentryDeviceId).apply(); + } else { + sentryDeviceId = sharedPreferences.getString(SENTRY_DEVICE_ID, null); + } + + options.setUserCache(new AndroidUserCache(options, sharedPreferences, sentryDeviceId)); + + readDefaultOptionValues(options, context, sentryDeviceId); - options.addEventProcessor(new DefaultAndroidEventProcessor(context, options)); + options.addEventProcessor(new DefaultAndroidEventProcessor(context, options, sentryDeviceId)); options.setSerializer(new AndroidSerializer(options.getLogger(), envelopeReader)); @@ -84,7 +104,9 @@ static void init( * @param context the Android context methods */ private static void readDefaultOptionValues( - final @NotNull SentryAndroidOptions options, final @NotNull Context context) { + final @NotNull SentryAndroidOptions options, + final @NotNull Context context, + final @Nullable String sentryDeviceId) { final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, options.getLogger()); if (packageInfo != null) { // Sets App's release if not set by Manifest @@ -101,11 +123,7 @@ private static void readDefaultOptionValues( } if (options.getDistinctId() == null) { - try { - options.setDistinctId(Installation.id(context)); - } catch (RuntimeException e) { - options.getLogger().log(SentryLevel.ERROR, "Could not generate distinct Id.", e); - } + options.setDistinctId(sentryDeviceId); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidUserCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidUserCache.java new file mode 100644 index 000000000..322cbde8d --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidUserCache.java @@ -0,0 +1,90 @@ +package io.sentry.android.core; + +import android.content.SharedPreferences; +import io.sentry.core.IUserCache; +import io.sentry.core.protocol.User; +import io.sentry.core.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// TODO: to think, this could be a SentryOptionsCache that could be expanded, does it make sense? + +/** the Android User Cache class. It caches the user data in the Android SharedPreferences */ +public final class AndroidUserCache implements IUserCache { + + // TODO: only these 3 fields make sense to me, otherwise caching a raw json would be better, but I + // don't want to do that. + private static final String EMAIL = "email"; + private static final String ID = "id"; + private static final String USERNAME = "user_name"; + + private final @NotNull SharedPreferences sharedPreferences; + private final @NotNull SentryAndroidOptions options; + private final @Nullable String sentryDeviceId; + + public AndroidUserCache( + final @NotNull SentryAndroidOptions options, + final @NotNull SharedPreferences sharedPreferences, + final @Nullable String sentryDeviceId) { + this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required."); + this.sharedPreferences = + Objects.requireNonNull(sharedPreferences, "SharedPreferences is required."); + this.sentryDeviceId = sentryDeviceId; + } + + // TODO: I believe this makes sense only for global hub SDKs, but if not, + // how do we cache multiple users (because we have multiple scopes)? + + @Override + public void setUser(final @Nullable User user) { + if (options.isCacheUserForSessions()) { + final SharedPreferences.Editor edit = sharedPreferences.edit(); + if (user != null) { + edit.putString(ID, user.getId()) + .putString(EMAIL, user.getEmail()) + .putString(USERNAME, user.getUsername()) + .apply(); // it does in-memory cache and flush to the disk async. + } else { + cleanUserFields(edit); + } + } + } + + @Override + public @Nullable User getUser() { + // sharedPreferences has in-memory cache and flush to the disk async., so it's not expensive + if (options.isCacheUserForSessions()) { + final String id = sharedPreferences.getString(ID, null); + final String email = sharedPreferences.getString(EMAIL, null); + final String userName = sharedPreferences.getString(USERNAME, null); + + boolean hasCachedUser = true; + if (id == null && email == null && userName == null) { + hasCachedUser = false; + } + + final User user = new User(); + + if (hasCachedUser) { + // returns previous set user, even if it has no id + user.setId(id); + user.setEmail(email); + user.setUsername(userName); + } else { + user.setId(sentryDeviceId); + } + return user; + } else { + final SharedPreferences.Editor edit = sharedPreferences.edit(); + // is it a good idea to do it here? this guarantees that it cleans up on restart if the flag + // has been swapped + cleanUserFields(edit); + return null; + } + } + + private void cleanUserFields(final @NotNull SharedPreferences.Editor edit) { + // do not clean SENTRY_DEVICE_ID + edit.remove(ID).remove(EMAIL).remove(USERNAME).apply(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index cdd3dcdd6..0228758f6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -3,6 +3,7 @@ import android.content.Context; import android.content.pm.PackageInfo; import android.os.Build; +import android.provider.Settings; import io.sentry.core.ILogger; import io.sentry.core.SentryLevel; import org.jetbrains.annotations.NotNull; @@ -45,4 +46,24 @@ static String getVersionCode(final @NotNull PackageInfo packageInfo) { private static @NotNull String getVersionCodeDep(final @NotNull PackageInfo packageInfo) { return Integer.toString(packageInfo.versionCode); } + + /** + * Returns the Settings.Secure.ANDROID_ID and if not valid, fallback to defaultValue + * + * @param defaultValue the defaultValue + * @return Settings.Secure.ANDROID_ID if valid or defaultValue + */ + @SuppressWarnings("HardwareIds") + static @Nullable String getAndroidId( + final @NotNull Context context, final @Nullable String defaultValue) { + String androidId = + Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + + if (androidId == null + || androidId.isEmpty() + || androidId.equalsIgnoreCase("9774d56d682e549c")) { + return defaultValue; + } + return androidId; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index e06a0aea3..5dfc1d22b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -24,7 +24,6 @@ import io.sentry.core.EventProcessor; import io.sentry.core.SentryEvent; import io.sentry.core.SentryLevel; -import io.sentry.core.SentryOptions; import io.sentry.core.protocol.App; import io.sentry.core.protocol.DebugImage; import io.sentry.core.protocol.DebugMeta; @@ -61,7 +60,6 @@ final class DefaultAndroidEventProcessor implements EventProcessor { @TestOnly static final String PROGUARD_UUID = "proGuardUuids"; @TestOnly static final String ROOTED = "rooted"; - @TestOnly static final String ANDROID_ID = "androidId"; @TestOnly static final String KERNEL_VERSION = "kernelVersion"; @TestOnly static final String EMULATOR = "emulator"; @@ -70,14 +68,19 @@ final class DefaultAndroidEventProcessor implements EventProcessor { @TestOnly final Context context; - private final SentryOptions options; + private final SentryAndroidOptions options; @TestOnly final Future> contextData; + private final @Nullable String sentryDeviceId; + public DefaultAndroidEventProcessor( - final @NotNull Context context, final @NotNull SentryOptions options) { + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @Nullable String sentryDeviceId) { this.context = Objects.requireNonNull(context, "The application context is required."); - this.options = Objects.requireNonNull(options, "The SentryOptions is required."); + this.options = Objects.requireNonNull(options, "The SentryAndroidOptions is required."); + this.sentryDeviceId = sentryDeviceId; ExecutorService executorService = Executors.newSingleThreadExecutor(); // dont ref. to method reference, theres a bug on it @@ -95,11 +98,6 @@ public DefaultAndroidEventProcessor( map.put(ROOTED, isRooted()); - String androidId = getAndroidId(); - if (androidId != null) { - map.put(ANDROID_ID, androidId); - } - String kernelVersion = getKernelVersion(); if (kernelVersion != null) { map.put(KERNEL_VERSION, kernelVersion); @@ -137,8 +135,14 @@ public DefaultAndroidEventProcessor( // Data to be applied to events that was created in the running process private void processNonCachedEvent(final @NotNull SentryEvent event) { - if (event.getUser() == null) { - event.setUser(getUser()); + User user = event.getUser(); + if (user == null) { + user = new User(); + } + event.setUser(user); + + if (user.getId() == null) { + user.setId(getDeviceId()); } App app = event.getContexts().getApp(); @@ -839,47 +843,12 @@ private boolean isRooted() { return null; } - public @NotNull User getUser() { - User user = new User(); - user.setId(getDeviceId()); - - return user; - } - - private @Nullable String getDeviceId() { - try { - Object androidId = contextData.get().get(ANDROID_ID); - - if (androidId != null) { - return (String) androidId; - } - } catch (Exception e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting androidId.", e); - } - return null; - } - - @SuppressWarnings("HardwareIds") - private @Nullable String getAndroidId() { - // Android 29 has changed and -> Avoid using hardware identifiers, find another way in the - // future - String androidId = - Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); - - // https://android-developers.googleblog.com/2011/03/identifying-app-installations.html - if (androidId == null - || androidId.isEmpty() - || androidId.toLowerCase(Locale.ROOT).contentEquals("9774d56d682e549c")) { - try { - androidId = Installation.id(context); - } catch (RuntimeException e) { - options.getLogger().log(SentryLevel.ERROR, "Could not generate device Id.", e); - - return null; - } + public @Nullable String getDeviceId() { + if (options.isEnableSecureAndroidId()) { + return ContextUtils.getAndroidId(context, sentryDeviceId); + } else { + return sentryDeviceId; } - - return androidId; } private @Nullable String[] getProGuardUuids() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java b/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java deleted file mode 100644 index b067bab11..000000000 --- a/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java +++ /dev/null @@ -1,57 +0,0 @@ -package io.sentry.android.core; - -import android.content.Context; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.RandomAccessFile; -import java.nio.charset.Charset; -import java.util.UUID; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.TestOnly; - -final class Installation { - @TestOnly static String deviceId = null; - - @TestOnly static final String INSTALLATION = "INSTALLATION"; - private static final Charset UTF_8 = Charset.forName("UTF-8"); - - private Installation() {} - - public static synchronized String id(final @NotNull Context context) throws RuntimeException { - if (deviceId == null) { - File installation = new File(context.getFilesDir(), INSTALLATION); - try { - if (!installation.exists()) { - deviceId = writeInstallationFile(installation); - return deviceId; - } - deviceId = readInstallationFile(installation); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - return deviceId; - } - - @TestOnly - static @NotNull String readInstallationFile(final @NotNull File installation) throws IOException { - try (RandomAccessFile f = new RandomAccessFile(installation, "r")) { - byte[] bytes = new byte[(int) f.length()]; - f.readFully(bytes); - return new String(bytes, UTF_8); - } - } - - @TestOnly - static @NotNull String writeInstallationFile(final @NotNull File installation) - throws IOException { - try (OutputStream out = new FileOutputStream(installation)) { - String id = UUID.randomUUID().toString(); - out.write(id.getBytes(UTF_8)); - out.flush(); - return id; - } - } -} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 1ecba173c..93938ab9e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -32,6 +32,8 @@ final class ManifestMetadataReader { static final String SESSION_TRACKING_ENABLE = "io.sentry.session-tracking.enable"; static final String SESSION_TRACKING_TIMEOUT_INTERVAL_MILLIS = "io.sentry.session-tracking.timeout-interval-millis"; + static final String SECURE_ANDROID_ID_ENABLE = "io.sentry.secure-android-id.enable"; + static final String CACHE_USER_SESSIONS_ENABLE = "io.sentry.session-tracking.cache-user.enable"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -135,6 +137,18 @@ static void applyMetadata( "sessionTrackingTimeoutIntervalMillis read: %d", sessionTrackingTimeoutIntervalMillis); options.setSessionTrackingIntervalMillis(sessionTrackingTimeoutIntervalMillis); + + final boolean secureAndroidIdEnabled = + metadata.getBoolean(SECURE_ANDROID_ID_ENABLE, options.isEnableSecureAndroidId()); + options + .getLogger() + .log(SentryLevel.DEBUG, "enableAndroidId read: %s", secureAndroidIdEnabled); + options.setEnableSecureAndroidId(secureAndroidIdEnabled); + + final boolean cacheUserEnabled = + metadata.getBoolean(CACHE_USER_SESSIONS_ENABLE, options.isCacheUserForSessions()); + options.getLogger().log(SentryLevel.DEBUG, "cacheUserEnabled read: %s", cacheUserEnabled); + options.setCacheUserForSessions(cacheUserEnabled); } options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 8ace83952..d05c3c12c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -16,6 +16,11 @@ public final class SentryAndroidOptions extends SentryOptions { /** Enable or disable ANR on Debug mode Default is disabled Used by AnrIntegration */ private boolean anrReportInDebug = false; + /** + * enable the usage of Settings.Secure.ANDROID_ID to keep retro compatible with older SDK versions + */ + private boolean enableSecureAndroidId; + /** * Checks if ANR (Application Not Responding) is enabled or disabled Default is enabled * @@ -86,4 +91,22 @@ public boolean isAnrReportInDebug() { public void setAnrReportInDebug(boolean anrReportInDebug) { this.anrReportInDebug = anrReportInDebug; } + + /** + * Returns if the enableSecureAndroidId flag is enabled + * + * @return true if enabled or false otherwise + */ + public boolean isEnableSecureAndroidId() { + return enableSecureAndroidId; + } + + /** + * Sets the enableSecureAndroidId flag + * + * @param enableSecureAndroidId true if enabled or false otherwise + */ + public void setEnableSecureAndroidId(boolean enableSecureAndroidId) { + this.enableSecureAndroidId = enableSecureAndroidId; + } } diff --git a/sentry-core/src/main/java/io/sentry/core/IUserCache.java b/sentry-core/src/main/java/io/sentry/core/IUserCache.java new file mode 100644 index 000000000..32ca84166 --- /dev/null +++ b/sentry-core/src/main/java/io/sentry/core/IUserCache.java @@ -0,0 +1,24 @@ +package io.sentry.core; + +import io.sentry.core.protocol.User; +import org.jetbrains.annotations.Nullable; + +/** The UserCache interface */ +public interface IUserCache { + + /** + * Caches the user (id, email and username) in the disk if the cacheUserForSessions flag is + * enabled. This method overwrites previous values. A null user cleans the cache. + * + * @param user the user object or null. + */ + void setUser(@Nullable User user); + + /** + * Returns the user that was cached in the disk if cacheUserForSessions flag was enabled + * + * @return the user (id, email and username) or null + */ + @Nullable + User getUser(); +} diff --git a/sentry-core/src/main/java/io/sentry/core/NoOpUserCache.java b/sentry-core/src/main/java/io/sentry/core/NoOpUserCache.java new file mode 100644 index 000000000..f0ac4a296 --- /dev/null +++ b/sentry-core/src/main/java/io/sentry/core/NoOpUserCache.java @@ -0,0 +1,23 @@ +package io.sentry.core; + +import io.sentry.core.protocol.User; +import org.jetbrains.annotations.Nullable; + +final class NoOpUserCache implements IUserCache { + + private static final NoOpUserCache instance = new NoOpUserCache(); + + private NoOpUserCache() {} + + public static NoOpUserCache getInstance() { + return instance; + } + + @Override + public void setUser(@Nullable User user) {} + + @Override + public @Nullable User getUser() { + return null; + } +} diff --git a/sentry-core/src/main/java/io/sentry/core/Scope.java b/sentry-core/src/main/java/io/sentry/core/Scope.java index 6358407bc..3665bffa1 100644 --- a/sentry-core/src/main/java/io/sentry/core/Scope.java +++ b/sentry-core/src/main/java/io/sentry/core/Scope.java @@ -50,6 +50,9 @@ public final class Scope implements Cloneable { /** Session lock, Ops should be atomic */ private final @NotNull Object sessionLock = new Object(); + /** User lock, Ops should be atomic */ + private final @NotNull Object userLock = new Object(); + /** * Scope's ctor * @@ -102,7 +105,12 @@ public void setTransaction(@Nullable String transaction) { * @return the user */ public @Nullable User getUser() { - return user; + synchronized (userLock) { + if (user == null) { + user = options.getUserCache().getUser(); + } + return user; + } } /** @@ -110,8 +118,11 @@ public void setTransaction(@Nullable String transaction) { * * @param user the user */ - public void setUser(@Nullable User user) { - this.user = user; + public void setUser(final @Nullable User user) { + synchronized (userLock) { + this.user = user; + options.getUserCache().setUser(user); + } } /** @@ -402,9 +413,30 @@ SessionPair startSession() { } previousSession = session; - session = - new Session( - options.getDistinctId(), user, options.getEnvironment(), options.getRelease()); + String distinctId = null; + + final User finalUser = getUser(); + // synchronized (userLock) { + if (finalUser != null) { + if (finalUser.getId() != null && !finalUser.getId().isEmpty()) { + distinctId = finalUser.getId(); + } else if (finalUser.getEmail() != null && !finalUser.getEmail().isEmpty()) { + distinctId = finalUser.getEmail(); + } else if (finalUser.getUsername() != null && !finalUser.getUsername().isEmpty()) { + distinctId = finalUser.getUsername(); + } + } + + // fallback to sentryDeviceId + if (distinctId == null) { + // if I add a transient field (not serializable) to the User object, I can get rid of the + // distinctId on options + // I cannot simply set on user.Id cus its gonna be a breaking change + distinctId = options.getDistinctId(); + } + + session = new Session(distinctId, user, options.getEnvironment(), options.getRelease()); + // } pair = new SessionPair(session, previousSession); } diff --git a/sentry-core/src/main/java/io/sentry/core/SentryOptions.java b/sentry-core/src/main/java/io/sentry/core/SentryOptions.java index 223cbcf65..a7bd577fa 100644 --- a/sentry-core/src/main/java/io/sentry/core/SentryOptions.java +++ b/sentry-core/src/main/java/io/sentry/core/SentryOptions.java @@ -166,12 +166,24 @@ public class SentryOptions { */ private long sessionTrackingIntervalMillis = 30000; // 30s - /** The distinct Id (generated Guid) used for session tracking */ + /** The distinct Id used for session tracking if no user is set */ private String distinctId; /** The server name used in the Sentry messages. */ private String serverName; + /** + * If this flag is enabled, an User set into the Scope will be cached and reused in the next App's + * run. + */ + private boolean cacheUserForSessions; + + /** + * The UserCache is responsible of caching/cleaning User's data if the flag cacheUserForSessions + * is enabled + */ + private @NotNull IUserCache userCache = NoOpUserCache.getInstance(); + /** * Adds an event processor * @@ -780,6 +792,42 @@ public void setFlushTimeoutMillis(long flushTimeoutMillis) { this.flushTimeoutMillis = flushTimeoutMillis; } + /** + * Returns if CacheUserForSessions is enabled + * + * @return true if enabled or false otherwise + */ + public boolean isCacheUserForSessions() { + return cacheUserForSessions; + } + + /** + * Sets the cacheUserForSessions flag + * + * @param cacheUserForSessions true if enabled or false otherwise + */ + public void setCacheUserForSessions(boolean cacheUserForSessions) { + this.cacheUserForSessions = cacheUserForSessions; + } + + /** + * Returns the UserCache interface + * + * @return the UserCache interface + */ + public @NotNull IUserCache getUserCache() { + return userCache; + } + + /** + * Sets the UserCache interface + * + * @param userCache the UserCache implementation + */ + public void setUserCache(final @Nullable IUserCache userCache) { + this.userCache = (userCache == null) ? NoOpUserCache.getInstance() : userCache; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry-sample/src/main/AndroidManifest.xml b/sentry-sample/src/main/AndroidManifest.xml index 864605c1a..37dd0cd5f 100644 --- a/sentry-sample/src/main/AndroidManifest.xml +++ b/sentry-sample/src/main/AndroidManifest.xml @@ -67,5 +67,11 @@ + + + + + +