Skip to content

Commit ad90112

Browse files
committed
Improve countImpression functionality in native android SDK
1 parent 687501d commit ad90112

File tree

4 files changed

+127
-29
lines changed

4 files changed

+127
-29
lines changed

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,15 @@ Any registered `Observer` will be called with the final authentication and subsc
150150
* The subscription information gets validated as well on every token refresh.
151151

152152
### Counting an impression
153-
154-
To count an impression, call either the suspending function `countImpressionSuspending(context: Context)` or the compatibility function `countImpression(context: Context, callback: CountImpressionCallback)`.
155-
156-
In both cases you'll need to supply a `Context` (i.e. an `Activity` or `Fragment`) so the login process can be started again in case the user needs to login again.
157-
158-
For an impression to be able to be counted, a user has to be authenticated and have an active subscription applicable to your scope.
153+
To count an impression, call either the suspending function `countImpressionSuspending(context: Context)` or
154+
the compatibility function `countImpression(context: Context, callback: CountImpressionCallback)`.
155+
In both cases you'll need to supply a `Context` (i.e. an `Activity` or `Fragment`) so the login process can
156+
be started again in case the user needs to login again.
157+
158+
These methods count impressions for billing purposes. This method must be invoked whenever a user views a piece
159+
of content, independently of authentication state. If the current user is authenticated the impression will automatically
160+
be logged as paid ad-free impression to calculate the publisher compensation. As the total amount of impressions is required
161+
for billing as well, this method also counts sampled impressions of non-subscribers.
159162

160163
```kotlin
161164
try {

lib/src/main/java/de/contentpass/lib/Authorizer.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal interface Authorizing {
2727

2828
fun onAuthorizationRequestResult(intent: Intent?)
2929

30-
suspend fun countImpression(authState: AuthState, activity: Context)
30+
suspend fun countPaidImpression(authState: AuthState, activity: Context)
3131
}
3232

3333
internal class Authorizer(
@@ -138,9 +138,10 @@ internal class Authorizer(
138138
}
139139
}
140140

141-
override suspend fun countImpression(authState: AuthState, activity: Context) {
141+
override suspend fun countPaidImpression(authState: AuthState, activity: Context) {
142142
val impressionId = UUID.randomUUID()
143-
val path = "pass/hit?pid=$propertyId&iid=$impressionId&t=pageview"
143+
val publicId = propertyId.substring(0, 8)
144+
val path = "pass/hit?pid=$publicId&iid=$impressionId&t=pageview"
144145

145146
val response = fireApiRequestWithFreshTokens(path, authState, activity)
146147

lib/src/main/java/de/contentpass/lib/ContentPass.kt

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,30 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
1212
import kotlinx.coroutines.*
1313
import net.openid.appauth.AuthState
1414
import net.openid.appauth.AuthorizationException
15+
import okhttp3.Call
16+
import okhttp3.Callback
17+
import okhttp3.MediaType.Companion.toMediaType
18+
import okhttp3.OkHttpClient
19+
import okhttp3.RequestBody.Companion.toRequestBody
1520
import okhttp3.Request
21+
import okhttp3.Response
1622
import java.io.InputStream
23+
import java.io.IOException
1724
import java.lang.NullPointerException
1825
import java.util.Timer
1926
import java.util.TimerTask
27+
import java.util.UUID
28+
import kotlin.coroutines.suspendCoroutine
29+
30+
const val SAMPLING_RATE = 0.05
31+
32+
data class SampleImpressionData(
33+
val ea: String,
34+
val ec: String,
35+
val cpabid: String,
36+
val cppid: String,
37+
val cpsr: Double
38+
)
2039

2140
/**
2241
* An object that handles all communication with the contentpass servers for you.
@@ -40,6 +59,7 @@ import java.util.TimerTask
4059
class ContentPass internal constructor(
4160
private val authorizer: Authorizing,
4261
private val tokenStore: TokenStoring,
62+
private val configuration: Configuration
4363
) {
4464
/**
4565
* A collection of functions that allow you to react to changes in the [ContentPass] object.
@@ -93,7 +113,7 @@ class ContentPass internal constructor(
93113
configuration = grabConfiguration()
94114
val authorizer = Authorizer(configuration!!, context!!)
95115
val store = TokenStore(context!!, KeyStore(context!!))
96-
return ContentPass(authorizer, store)
116+
return ContentPass(authorizer, store, configuration!!)
97117
}
98118

99119
private fun grabConfiguration(): Configuration? {
@@ -293,12 +313,8 @@ class ContentPass internal constructor(
293313
* This is the compatibility function for Java users and developers who are not yet
294314
* comfortable or able to use kotlin coroutines.
295315
*
296-
* A user has to be authenticated and have an active subscription applicable to your
297-
* scope for this to work.
298316
* This function calls the callback's onSuccess on a successful impression counting.
299317
* In case of an error the callback's onFailure contains an exception containing more information.
300-
* If the exception is a ContentPass.CountImpressionException and the message states the http error
301-
* code 404, the user most likely has no applicable subscription.
302318
*
303319
* @param context the context the authentication flow can be restarted from in case a login is necessary.
304320
* @param callback an object implementing the [CountImpressionCallback] interface that enables
@@ -318,17 +334,68 @@ class ContentPass internal constructor(
318334
/**
319335
* Count an impression by calling this function.
320336
*
321-
* A user has to be authenticated and have an active subscription applicable to your
322-
* scope for this to work.
323337
* This function simply returns on success or will throw an exception containing more information.
324-
* If the exception is a ContentPass.CountImpressionException and the message states the http error
325-
* code 404, the user most likely has no applicable subscription.
326338
*
327339
* @param context the context the authentication flow can be restarted from in case a login is necessary.
328340
*/
329341
suspend fun countImpressionSuspending(context: Context) {
330342
return withContext(coroutineContext) {
331-
authorizer.countImpression(authState, context)
343+
if (state is State.Authenticated && (state as State.Authenticated).hasValidSubscription) {
344+
authorizer.countPaidImpression(authState, context)
345+
}
346+
347+
348+
countSampledImpression()
349+
}
350+
}
351+
352+
private suspend fun countSampledImpression() {
353+
val generatedSample = Math.random()
354+
if (generatedSample >= SAMPLING_RATE) {
355+
return
356+
}
357+
358+
val instanceId = UUID.randomUUID().toString()
359+
val publicId = configuration.propertyId.substring(0, 8)
360+
val sampleImpressionData = SampleImpressionData(
361+
ea = "load",
362+
ec = "tcf-sampled",
363+
cpabid = instanceId,
364+
cppid = publicId,
365+
cpsr = SAMPLING_RATE
366+
)
367+
val moshi = Moshi.Builder()
368+
.add(KotlinJsonAdapterFactory())
369+
.build()
370+
val jsonAdapter = moshi.adapter(SampleImpressionData::class.java)
371+
val jsonBody = jsonAdapter.toJson(sampleImpressionData)
372+
373+
val responseCode = postRequest("${configuration.apiUrl}/stats", jsonBody);
374+
if (responseCode < 200 || responseCode >= 300) {
375+
throw CountImpressionException(responseCode)
376+
}
377+
}
378+
379+
380+
private suspend fun postRequest(url: String, jsonBody: String): Int {
381+
val client = OkHttpClient()
382+
val requestBody = jsonBody.toRequestBody("application/json; charset=UTF-8".toMediaType())
383+
val request = Request.Builder()
384+
.url(url)
385+
.post(requestBody)
386+
.header("Content-Type", "application/json; charset=UTF-8")
387+
.build()
388+
389+
return suspendCoroutine { continuation ->
390+
client.newCall(request).enqueue(object : Callback {
391+
override fun onFailure(call: Call, e: IOException) {
392+
continuation.resumeWith(Result.failure(e))
393+
}
394+
395+
override fun onResponse(call: Call, response: Response) {
396+
continuation.resumeWith(Result.success(response.code))
397+
}
398+
})
332399
}
333400
}
334401

lib/src/test/java/de/contentpass/lib/ContentPassTests.kt

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,53 @@
11
package de.contentpass.lib
22

33
import android.content.Intent
4+
import android.net.Uri
45
import androidx.activity.ComponentActivity
56
import androidx.activity.result.ActivityResult
67
import androidx.activity.result.contract.ActivityResultContract
78
import androidx.fragment.app.Fragment
89
import io.mockk.coEvery
910
import io.mockk.every
1011
import io.mockk.mockk
12+
import io.mockk.mockkStatic
1113
import io.mockk.verify
1214
import kotlinx.coroutines.runBlocking
1315
import net.openid.appauth.AuthState
1416
import org.junit.Assert.*
17+
import org.junit.Before
1518
import org.junit.Test
1619
import java.lang.NullPointerException
1720

1821
class ContentPassTests {
22+
private lateinit var mockUri: Uri
23+
private lateinit var exampleConfiguration: Configuration
24+
25+
@Before
26+
fun setUp() {
27+
mockkStatic(Uri::class)
28+
29+
mockUri = mockk()
30+
every { mockUri.toString() } returns "https://example.com"
31+
every { mockUri.scheme } returns "https"
32+
every { mockUri.host } returns "example.com"
33+
every { mockUri.path } returns "/"
34+
every { Uri.parse(any()) } returns mockUri
35+
36+
37+
exampleConfiguration = Configuration(
38+
2,
39+
Uri.parse("https://example.com/api"),
40+
Uri.parse("https://example.com/oidc"),
41+
Uri.parse("https://example.com/redirect"),
42+
"example"
43+
)
44+
}
45+
1946
@Test
2047
fun `initialization without stored state results in Unauthenticated`() {
2148
val store = MockedTokenStore()
2249
assertNull(store.retrieveAuthState())
23-
val contentPass = ContentPass(mockk(relaxed = true), store)
50+
val contentPass = ContentPass(mockk(relaxed = true), store, exampleConfiguration)
2451

2552
assertEquals(ContentPass.State.Unauthenticated, contentPass.state)
2653
}
@@ -32,7 +59,7 @@ class ContentPassTests {
3259
val store = MockedTokenStore()
3360
store.storeAuthState(state)
3461

35-
val contentPass = ContentPass(mockk(relaxed = true), store)
62+
val contentPass = ContentPass(mockk(relaxed = true), store, exampleConfiguration)
3663

3764
Thread.sleep(10)
3865

@@ -50,7 +77,7 @@ class ContentPassTests {
5077
coEvery { authorizer.validateSubscription(any()) }.returns(true)
5178
coEvery { authorizer.authenticate(any(), any()) }.returns(state)
5279

53-
val contentPass = ContentPass(authorizer, mockk(relaxed = true))
80+
val contentPass = ContentPass(authorizer, mockk(relaxed = true), exampleConfiguration)
5481
val activity: ComponentActivity = mockk(relaxed = true)
5582
contentPass.registerActivityResultLauncher(activity)
5683

@@ -63,7 +90,7 @@ class ContentPassTests {
6390
@Test
6491
fun `calling authenticate before registerActivityResultLauncher results in NullPointerException`() =
6592
runBlocking {
66-
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true))
93+
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true), exampleConfiguration)
6794

6895
try {
6996
contentPass.authenticateSuspending(mockk())
@@ -77,7 +104,7 @@ class ContentPassTests {
77104

78105
@Test
79106
fun `registerActivityResultLauncher for activity registers for activity result`() {
80-
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true))
107+
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true), exampleConfiguration)
81108
val activity: ComponentActivity = mockk(relaxed = true)
82109

83110
contentPass.registerActivityResultLauncher(activity)
@@ -92,7 +119,7 @@ class ContentPassTests {
92119

93120
@Test
94121
fun `registerActivityResultLauncher for fragment registers for activity result`() {
95-
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true))
122+
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true), exampleConfiguration)
96123
val fragment: Fragment = mockk(relaxed = true)
97124

98125
contentPass.registerActivityResultLauncher(fragment)
@@ -113,7 +140,7 @@ class ContentPassTests {
113140
val authorizer: Authorizing = mockk()
114141
coEvery { authorizer.validateSubscription(any()) }.returns(true)
115142
coEvery { authorizer.authenticate(any(), any()) }.returns(authState)
116-
val contentPass = ContentPass(authorizer, mockk(relaxed = true))
143+
val contentPass = ContentPass(authorizer, mockk(relaxed = true), exampleConfiguration)
117144

118145
contentPass.registerActivityResultLauncher(mockk<Fragment>(relaxed = true))
119146
contentPass.authenticateSuspending(mockk())
@@ -128,7 +155,7 @@ class ContentPassTests {
128155
fun `logout removes stored auth state information`() {
129156
val store = MockedTokenStore()
130157
store.storeAuthState(mockk())
131-
val contentPass = ContentPass(mockk(relaxed = true), store)
158+
val contentPass = ContentPass(mockk(relaxed = true), store, exampleConfiguration)
132159

133160
assertNotNull(store.retrieveAuthState())
134161

@@ -139,7 +166,7 @@ class ContentPassTests {
139166

140167
@Test
141168
fun `registerObserver adds an observer that gets called on state changes`() {
142-
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true))
169+
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true), exampleConfiguration)
143170

144171
var stateResult: ContentPass.State = ContentPass.State.Initializing
145172

@@ -156,7 +183,7 @@ class ContentPassTests {
156183

157184
@Test
158185
fun `unregisterObserver removes an observer`() {
159-
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true))
186+
val contentPass = ContentPass(mockk(relaxed = true), mockk(relaxed = true), exampleConfiguration)
160187

161188
var stateResult: ContentPass.State = ContentPass.State.Initializing
162189
val observer = object : ContentPass.Observer {

0 commit comments

Comments
 (0)