Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor retrieving advertisement id to Coroutines #93

Merged
merged 21 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d90b0d6
refactor: create `deviceInfo` for every `buildEvent`
wzieba Nov 14, 2023
9a420f0
refactor: extract creating "device info" to a separate class
wzieba Nov 14, 2023
17c5e82
Rename .java to .kt
wzieba Nov 14, 2023
65e25dd
refactor: move `DeviceInfoRepository` to Kotlin
wzieba Nov 14, 2023
ce1d56c
refactor: make `EventsBuilderTest` not test `DeviceInfoRepository`
wzieba Nov 14, 2023
96acab0
refactor: move `GetAdKey` to Coroutines
wzieba Nov 14, 2023
2c0cc68
refactor: simplify AdKey retrieval
wzieba Nov 14, 2023
25d06f1
fix: remove `appname` from device info
wzieba Nov 14, 2023
c5962bc
style: remove unused imports
wzieba Nov 14, 2023
4c91494
tests: make `EventsBuilderTest` not Robolectric test
wzieba Nov 14, 2023
9cd6cce
refactor: extract getting ad key to `AdvertisementIdProvider`
wzieba Nov 14, 2023
6ff145a
refactor: extract getting uuid to `UuidProvider`
wzieba Nov 14, 2023
59a83d8
tests: add unit tests for `UuidProvider`
wzieba Nov 14, 2023
3bcbb38
refactor: do not query `ANDROID_ID` from `SharedPreferences`.
wzieba Nov 14, 2023
c6f68bf
tests: add unit tests for `AndroidDeviceInfoRepository`
wzieba Nov 14, 2023
30eb83f
Merge branch 'coroutines' into get_add_key_coroutines
wzieba Nov 29, 2023
9d99164
Merge branch 'engagement_manager_coroutines' into get_add_key_coroutines
wzieba Nov 29, 2023
988ff4b
Merge branch 'coroutines' into get_add_key_coroutines
wzieba Dec 5, 2023
7533f97
fix: assign advertisement id in AdvertisementIdProvider
wzieba Dec 5, 2023
473dc7e
tests: make SUT test-scoped in AndroidDeviceInfoRepositoryTest
wzieba Dec 5, 2023
0c3102d
chore: add a comment for AdvertisementIdProvider#provide
wzieba Dec 7, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.parsely.parselyandroid

import android.content.Context
import android.provider.Settings
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

internal class AdvertisementIdProvider(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am curious about the general design choice for this class.

If I am understanding it correctly, when an object of this class is instantiated, if we were to read the adKey immediately, it'll return null. Looking at the constructor signature, there is a hint that we are using coroutines for something but there isn't much context about what it's used for unless we look into the implementation. So, I think a developer may not realize that gotcha and end up requesting the adKey before it's ready. Also, I am not even sure if there is a straightforward way to tell when the adKey is ready to be retrieved.

So, I was wondering if we should use a suspend function for provide so that it can be used in a proper coroutine scope without worrying about the internal details. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am understanding it correctly, when an object of this class is instantiated, if we were to read the adKey immediately, it'll return null.

That's correct and intended 👍

Also, I am not even sure if there is a straightforward way to tell when the adKey is ready to be retrieved.

Yes, I don't see a way in current implementation. One suggestion would be to maybe use a (State)Flow? But I'm not sure if it wouldn't be an overkill for this case.

So, I was wondering if we should use a suspend function for provide so that it can be used in a proper coroutine scope without worrying about the internal details. What do you think?

We could do this, I was also experimenting with such design. My decision to go with not using suspend is the actual usage of AdvertisementIdProvider in the codebase. If we made suspend provide method, we'd have to move executing coroutine invocation higher in a chain of execution - maybe run coroutine in AndroidDeviceInfoRepository#collectDeviceInfo or EventsBuilder#buildEvent? It got all more complex than what I intended with such PR, so I decided to run coroutine in constructor of AdvertisementIdProvider and return null if the coroutine did not yet finish.

WDYT? I understand the possibly misleading design, but having the context above - would it make sense to stick to such design, or we should iterate? If we should iterate and make suspend provide - where would you suggest running actual coroutine?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is important to get right. We don't have to use a suspended function if it's going to be more trouble than it's worth - but at the very least, I think it's worth having a big disclaimer in both the class definition and where it's consumed. The reason I strongly feel this way is because if we have any issues related to this, it can be hard to debug if the developer doesn't have any prior context. To be fair - the fact that it's marked as nullable may give enough context, so maybe it's not as big of a deal as I am making it out to be 🤷‍♂️

I don't think we should make any major changes in this PR, because it'll get hard to review it. However, if I was working on this, I'd give using suspended function a chance as a separate PR. Assuming we keep the current architecture, this will cause a chain reaction and the following methods may also need to become suspended functions: AndroidDeviceInfoRepository.collectDeviceInfo, EventsBuilder.buildEvent & ParselyTracker.trackPlay.

  • ParselyTracker.trackPlay: Looking at the name, I assumed that this function would eventually become a suspended function regardless of the decision we'll make here, but looking at the implementation, I am not so sure. I'd love to hear your thoughts!
  • EventsBuilder.buildEvent: This probably shouldn't be a suspended function, but instead take the device info as a parameter. This is used in ParselyTracker.trackPlay & ParselyTracker.trackPageview both of which could be suspended functions, so I think that'll work out.
  • AndroidDeviceInfoRepository.collectDeviceInfo: Making this a suspended function makes sense to me. It communicates that collecting device info is not an immediate thing which could be for many reasons. We could be reading from file, checking a DB etc to collect this info. Admittedly some of the info is available immediately, so I could see an argument for keeping it a regular function as well. 🤷‍♂️

I think the decision will heavily depend on your vision for the SDK in general and I don't want to influence that vision too much. If you think it's better to leave the current design as is, I can live with that as long as we document it.

Hope you find my thoughts useful - let me know what you think!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if I was working on this, I'd give using suspended function a chance as a separate PR

Gotcha 👍 I've created an issue to track it: #97

ParselyTracker.trackPlay: Looking at the name, I assumed that this function would eventually become a suspended function

This function is one of the few main exposed to the consumers of the SDK. I'd argue to not make it, or rest of the tracking methods (startEngagement, trackPageview) suspend, as it would force clients to use coroutines when using our API. It'd be problematic for them, and sometimes very complex, if the client's codebase is in Java.

AndroidDeviceInfoRepository.collectDeviceInfo: Making this a suspended function makes sense to me. It communicates that collecting device info is not an immediate thing which could be for many reasons

True, that'd be a good place. Still, we would have to find a way to wait for collectDeviceInfo to finish without blocking the SDK from accepting new events - I'd prefer to never allow user to wait for accepting a new event.

I think the decision will heavily depend on your vision for the SDK in general

That's true - I think it'd be best to combine this improvement with changes described in #94 ! Thank you for sharing your thoughts and suggestions - I really appreciate it as, even if we won't apply them right away, they'll be certainly useful during later phases.


I don't think we should make any major changes in this PR, because it'll get hard to review it.

As that's the case - would you mind accepting this PR, or do you think we should iterate on something more here?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As that's the case - would you mind accepting this PR, or do you think we should iterate on something more here?

I just wanted this conversation to resolve before approving the PR - in case you wanted to add a comment about this issue. Approved now - thanks for the discussion!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, thanks! Added a comment: 0c3102d. Merging 👍

private val context: Context,
coroutineScope: CoroutineScope
) : IdProvider {

private var adKey: String? = null

init {
coroutineScope.launch {
try {
adKey = AdvertisingIdClient.getAdvertisingIdInfo(context).id
} catch (e: Exception) {
ParselyTracker.PLog("No Google play services or error!")
}
}
}

/**
* @return advertisement id if the coroutine in the constructor finished executing AdvertisingIdClient#getAdvertisingIdInfo
* null otherwise
*/
override fun provide(): String? = adKey
}

internal class AndroidIdProvider(private val context: Context) : IdProvider {
override fun provide(): String? {
val uuid = try {
Settings.Secure.getString(
context.applicationContext.contentResolver,
Settings.Secure.ANDROID_ID
)
} catch (ex: Exception) {
null
}
ParselyTracker.PLog(String.format("Android ID: %s", uuid))
return uuid
}
}

internal fun interface IdProvider {
oguzkocer marked this conversation as resolved.
Show resolved Hide resolved
fun provide(): String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.parsely.parselyandroid

import android.os.Build

internal interface DeviceInfoRepository{
fun collectDeviceInfo(): Map<String, String>
}

internal open class AndroidDeviceInfoRepository(
private val advertisementIdProvider: IdProvider,
private val androidIdProvider: IdProvider,
): DeviceInfoRepository {

/**
* Collect device-specific info.
*
*
* Collects info about the device and user to use in Parsely events.
*/
override fun collectDeviceInfo(): Map<String, String> {
val dInfo: MutableMap<String, String> = HashMap()

// TODO: screen dimensions (maybe?)
dInfo["parsely_site_uuid"] = parselySiteUuid
dInfo["manufacturer"] = Build.MANUFACTURER
dInfo["os"] = "android"
dInfo["os_version"] = String.format("%d", Build.VERSION.SDK_INT)

return dInfo
}

private val parselySiteUuid: String
get() {
val adKey = advertisementIdProvider.provide()
val androidId = androidIdProvider.provide()

ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, androidId)

return if (adKey != null) {
adKey
} else {
ParselyTracker.PLog("falling back to device uuid")
androidId .orEmpty()
}
}
}
116 changes: 8 additions & 108 deletions parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,27 @@
import static com.parsely.parselyandroid.ParselyTracker.PLog;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.provider.Settings;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.google.android.gms.ads.identifier.AdvertisingIdClient;
import com.google.android.gms.common.GooglePlayServicesNotAvailableException;
import com.google.android.gms.common.GooglePlayServicesRepairableException;

import java.io.IOException;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;
import java.util.TimeZone;

class EventsBuilder {
private static final String UUID_KEY = "parsely-uuid";
private static final String VIDEO_START_ID_KEY = "vsid";
private static final String PAGE_VIEW_ID_KEY = "pvid";

@NonNull
private final Context context;
private final SharedPreferences settings;
private final String siteId;

private Map<String, String> deviceInfo;
@NonNull
private final DeviceInfoRepository deviceInfoRepository;

public EventsBuilder(@NonNull final Context context, @NonNull final String siteId) {
this.context = context;
public EventsBuilder(@NonNull final DeviceInfoRepository deviceInfoRepository, @NonNull final String siteId) {
this.siteId = siteId;
settings = context.getSharedPreferences("parsely-prefs", 0);
deviceInfo = collectDeviceInfo(null);
new GetAdKey(context).execute();
this.deviceInfoRepository = deviceInfoRepository;
}

/**
Expand Down Expand Up @@ -74,11 +60,11 @@ Map<String, Object> buildEvent(
if (extraData != null) {
data.putAll(extraData);
}
data.put("manufacturer", deviceInfo.get("manufacturer"));
data.put("os", deviceInfo.get("os"));
data.put("os_version", deviceInfo.get("os_version"));

final Map<String, String> deviceInfo = deviceInfoRepository.collectDeviceInfo();
data.put("ts", now.getTimeInMillis());
data.put("parsely_site_uuid", deviceInfo.get("parsely_site_uuid"));
data.putAll(deviceInfo);

event.put("data", data);

if (metadata != null) {
Expand All @@ -96,90 +82,4 @@ Map<String, Object> buildEvent(
return event;
}

/**
* Collect device-specific info.
* <p>
* Collects info about the device and user to use in Parsely events.
*/
private Map<String, String> collectDeviceInfo(@Nullable final String adKey) {
Map<String, String> dInfo = new HashMap<>();

// TODO: screen dimensions (maybe?)
PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid());
final String uuid = (adKey != null) ? adKey : getSiteUuid();
dInfo.put("parsely_site_uuid", uuid);
dInfo.put("manufacturer", android.os.Build.MANUFACTURER);
dInfo.put("os", "android");
dInfo.put("os_version", String.format("%d", android.os.Build.VERSION.SDK_INT));

// FIXME: Not passed in event or used anywhere else.
CharSequence txt = context.getPackageManager().getApplicationLabel(context.getApplicationInfo());
dInfo.put("appname", txt.toString());
oguzkocer marked this conversation as resolved.
Show resolved Hide resolved

return dInfo;
}

/**
* Get the UUID for this user.
*/
//TODO: docs about where we get this UUID from and how.
private String getSiteUuid() {
String uuid = "";
try {
uuid = settings.getString(UUID_KEY, "");
if (uuid.equals("")) {
uuid = generateSiteUuid();
}
} catch (Exception ex) {
PLog("Exception caught during site uuid generation: %s", ex.toString());
}
return uuid;
}

/**
* Read the Parsely UUID from application context or make a new one.
*
* @return The UUID to use for this user.
*/
private String generateSiteUuid() {
String uuid = Settings.Secure.getString(context.getApplicationContext().getContentResolver(),
Settings.Secure.ANDROID_ID);
PLog(String.format("Generated UUID: %s", uuid));
return uuid;
}
/**
* Async task to get adKey for this device.
*/
private class GetAdKey extends AsyncTask<Void, Void, String> {
private final Context mContext;

public GetAdKey(Context context) {
mContext = context;
}

@Override
protected String doInBackground(Void... params) {
AdvertisingIdClient.Info idInfo = null;
String advertId = null;
try {
idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext);
} catch (GooglePlayServicesRepairableException | IOException |
GooglePlayServicesNotAvailableException | IllegalArgumentException e) {
PLog("No Google play services or error! falling back to device uuid");
// fall back to device uuid on google play errors
advertId = getSiteUuid();
}
try {
advertId = idInfo.getId();
} catch (NullPointerException e) {
advertId = getSiteUuid();
}
return advertId;
}

@Override
protected void onPostExecute(String advertId) {
deviceInfo = collectDeviceInfo(advertId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ public class ParselyTracker {
*/
protected ParselyTracker(String siteId, int flushInterval, Context c) {
context = c.getApplicationContext();
eventsBuilder = new EventsBuilder(context, siteId);
eventsBuilder = new EventsBuilder(
new AndroidDeviceInfoRepository(
new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()),
new AndroidIdProvider(context)
), siteId);
localStorageRepository = new LocalStorageRepository(context);
flushManager = new ParselyFlushManager(new Function0<Unit>() {
@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.parsely.parselyandroid

import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowBuild

private const val SDK_VERSION = 33
private const val MANUFACTURER = "test manufacturer"

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [SDK_VERSION])
internal class AndroidDeviceInfoRepositoryTest {

@Before
fun setUp() {
ShadowBuild.setManufacturer(MANUFACTURER)
}

@Test
fun `given the advertisement id exists, when collecting device info, then parsely site uuid is advertisement id`() {
// given
val advertisementId = "ad id"
val sut = AndroidDeviceInfoRepository(
advertisementIdProvider = { advertisementId },
androidIdProvider = { "android id" })

// when
val result = sut.collectDeviceInfo()

// then
assertThat(result).isEqualTo(expectedConstantDeviceInfo + ("parsely_site_uuid" to advertisementId))
}

@Test
fun `given the advertisement is null and android id is not, when collecting device info, then parsely id is android id`() {
// given
val androidId = "android id"
val sut = AndroidDeviceInfoRepository(
advertisementIdProvider = { null },
androidIdProvider = { androidId }
)

// when
val result = sut.collectDeviceInfo()

// then
assertThat(result).isEqualTo(expectedConstantDeviceInfo + ("parsely_site_uuid" to androidId))
}

@Test
fun `given both advertisement id and android id are null, when collecting device info, then parsely id is empty`() {
// given
val sut = AndroidDeviceInfoRepository(
advertisementIdProvider = { null },
androidIdProvider = { null }
)

// when
val result = sut.collectDeviceInfo()

// then
assertThat(result).isEqualTo(expectedConstantDeviceInfo + ("parsely_site_uuid" to ""))
}

private companion object {
val expectedConstantDeviceInfo = mapOf(
"manufacturer" to MANUFACTURER,
"os" to "android",
"os_version" to "$SDK_VERSION"
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.parsely.parselyandroid

import android.app.Application
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class AndroidIdProviderTest {

lateinit var sut: AndroidIdProvider

@Before
fun setUp() {
sut = AndroidIdProvider(ApplicationProvider.getApplicationContext())
}

@Test
fun `given no site uuid is stored, when requesting uuid, then return ANDROID_ID value`() {
// given
val fakeAndroidId = "test id"
Settings.Secure.putString(
ApplicationProvider.getApplicationContext<Application>().contentResolver,
Settings.Secure.ANDROID_ID,
fakeAndroidId
)

// when
val result= sut.provide()

// then
assertThat(result).isEqualTo(fakeAndroidId)
}

@Test
fun `given site uuid already requested, when requesting uuid, then return same uuid`() {
// given
val fakeAndroidId = "test id"
Settings.Secure.putString(
ApplicationProvider.getApplicationContext<Application>().contentResolver,
Settings.Secure.ANDROID_ID,
fakeAndroidId
)
val storedValue = sut.provide()

// when
val result = sut.provide()

// then
assertThat(result).isEqualTo(storedValue)
}
}
Loading