Skip to content

Commit 8fdcae2

Browse files
authored
Merge pull request #3 from contentpass/count-impressions
Add count impression functionality
2 parents 3387a37 + d998db4 commit 8fdcae2

File tree

6 files changed

+195
-33
lines changed

6 files changed

+195
-33
lines changed

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
Our SDK is available on Maven Central.
1515

1616
```groovy
17-
implementation 'de.contentpass:contentpass-android:1.0.1'
17+
implementation 'de.contentpass:contentpass-android:1.1.0'
1818
```
1919

2020
Add this to your app's `build.gradle` file's `dependencies` element.
@@ -148,6 +148,45 @@ Any registered `Observer` will be called with the final authentication and subsc
148148
* We refresh these tokens automatically in the background before they're invalidated.
149149
* The subscription information gets validated as well on every token refresh.
150150

151+
### Counting an impression
152+
153+
To count an impression, call either the suspending function `countImpressionSuspending(context: Context)` or the compatibility function `countImpression(context: Context, callback: CountImpressionCallback)`.
154+
155+
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.
156+
157+
For an impression to be able to be counted, a user has to be authenticated and have an active subscription applicable to your scope.
158+
159+
```kotlin
160+
try {
161+
contentPass.countImpressionSuspending(context)
162+
// handle success
163+
} catch (impressionException: ContentPass.CountImpressionException) {
164+
// impressionException.message contains the http error code as a string
165+
// if this is "404", this most likely means that the user has no applicable subscription
166+
} catch (ex: Throwable) {
167+
// Handle the exception.
168+
// This might be network related but could also happen because the ContentPass object wasn't initialized properly.
169+
}
170+
```
171+
172+
or for compatibility reasons:
173+
174+
```kotlin
175+
contentPass.countImpression(context, object : ContentPass.CountImpressionCallback {
176+
override fun onSuccess() {
177+
// handle success
178+
}
179+
180+
override fun onFailure(exception: Throwable) {
181+
// handle the exception.
182+
// If this is a ContentPass.CountImpressionException, parse its message for the http error code.
183+
// if this is "404", this most likely means that the user has no applicable subscription
184+
}
185+
})
186+
```
187+
188+
189+
151190
### Logging out a user
152191

153192
Since we persist the user's session, you need a way to log the user out. Simply call `logout` and we remove all stored token data.

app/src/main/java/de/contentpass/app/ExampleViewModel.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ class ExampleViewModel(context: Context) : ViewModel() {
1313
private val _hasValidSubscription = MutableLiveData(false)
1414
val hasValidSubscription: LiveData<Boolean> = _hasValidSubscription
1515

16+
private val _impressionTries = MutableLiveData(0)
17+
val impressionTries: LiveData<Int> = _impressionTries
18+
private val _impressionSuccesses = MutableLiveData(0)
19+
val impressionSuccesses: LiveData<Int> = _impressionSuccesses
20+
1621
private val config = context.resources
1722
.openRawResource(R.raw.contentpass_configuration)
1823

@@ -50,6 +55,20 @@ class ExampleViewModel(context: Context) : ViewModel() {
5055
onNewContentPassState(state)
5156
}
5257

58+
suspend fun countImpression(fromActivity: ComponentActivity) {
59+
_impressionTries.postValue(_impressionTries.value?.plus(1) ?: 1)
60+
61+
try {
62+
contentPass.countImpressionSuspending(fromActivity)
63+
_impressionSuccesses.postValue(_impressionSuccesses.value?.plus(1) ?: 1)
64+
} catch (impressionException: ContentPass.CountImpressionException) {
65+
println("Counting impression error code: ${impressionException.message}")
66+
} catch (ex: Throwable) {
67+
println("Counting impression exception:")
68+
ex.printStackTrace()
69+
}
70+
}
71+
5372
fun logout() {
5473
contentPass.logout()
5574
}

app/src/main/java/de/contentpass/app/MainView.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import kotlinx.coroutines.launch
2626
fun MainView(viewModel: ExampleViewModel) {
2727
val isAuthenticated: Boolean by viewModel.isAuthenticated.observeAsState(false)
2828
val hasValidSubscription: Boolean by viewModel.hasValidSubscription.observeAsState(false)
29+
val impressionTries: Int by viewModel.impressionTries.observeAsState(0)
30+
val impressionSuccesses: Int by viewModel.impressionSuccesses.observeAsState(0)
2931

3032
Column(
3133
horizontalAlignment = Alignment.CenterHorizontally,
@@ -34,7 +36,6 @@ fun MainView(viewModel: ExampleViewModel) {
3436
.fillMaxSize()
3537
) {
3638
Text(text = "Is authenticated: $isAuthenticated")
37-
3839
Text(text = "Has valid subscription: $hasValidSubscription")
3940

4041
PaddingValues(Dp(8.0F))
@@ -47,6 +48,12 @@ fun MainView(viewModel: ExampleViewModel) {
4748
modifier = Modifier.padding(Dp(16F))
4849
) { Text("Logout") }
4950
}
51+
52+
PaddingValues(Dp(8.0F))
53+
54+
Text(text = "Count impression tries: $impressionTries")
55+
Text(text = "Count impression successes: $impressionSuccesses")
56+
ImpressionButton(viewModel)
5057
}
5158
}
5259

@@ -68,6 +75,16 @@ fun LoginButton(viewModel: ExampleViewModel) {
6875
) { Text("Login") }
6976
}
7077

78+
@Composable
79+
fun ImpressionButton(viewModel: ExampleViewModel) {
80+
val coroutineScope = rememberCoroutineScope()
81+
val activity = LocalContext.current as ComponentActivity
82+
83+
return Button(
84+
onClick = { coroutineScope.launch { viewModel.countImpression(activity) } }
85+
) { Text("Count impression") }
86+
}
87+
7188
@Preview(
7289
showSystemUi = true
7390
)

lib/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ kapt {
5858
extra.apply{
5959
set("PUBLISH_GROUP_ID", "de.contentpass")
6060
set("PUBLISH_ARTIFACT_ID", "contentpass-android")
61-
set("PUBLISH_VERSION", "1.0.1")
61+
set("PUBLISH_VERSION", "1.1.0")
6262
}
6363

6464
apply("${rootProject.projectDir}/scripts/publish-module.gradle")

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

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,35 @@ import android.content.Intent
55
import androidx.activity.result.ActivityResultLauncher
66
import com.squareup.moshi.Moshi
77
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
8-
import kotlinx.coroutines.CoroutineScope
9-
import kotlinx.coroutines.Dispatchers
10-
import kotlinx.coroutines.Job
11-
import kotlinx.coroutines.launch
12-
import net.openid.appauth.AuthState
13-
import net.openid.appauth.AuthorizationException
14-
import net.openid.appauth.AuthorizationRequest
15-
import net.openid.appauth.AuthorizationResponse
16-
import net.openid.appauth.AuthorizationService
17-
import net.openid.appauth.AuthorizationServiceConfiguration
18-
import net.openid.appauth.ResponseTypeValues
19-
import net.openid.appauth.TokenRequest
20-
import okhttp3.Call
21-
import okhttp3.Callback
22-
import okhttp3.FormBody
23-
import okhttp3.OkHttpClient
24-
import okhttp3.Request
25-
import okhttp3.Response
8+
import kotlinx.coroutines.*
9+
import net.openid.appauth.*
10+
import net.openid.appauth.AuthState.AuthStateAction
11+
import okhttp3.*
2612
import okio.IOException
13+
import java.util.*
2714
import kotlin.coroutines.Continuation
15+
import kotlin.coroutines.resumeWithException
2816
import kotlin.coroutines.suspendCoroutine
2917

18+
3019
internal interface Authorizing {
3120
suspend fun authenticate(
3221
activity: Context,
33-
activityResultLauncher: ActivityResultLauncher<Intent>
22+
activityResultLauncher: ActivityResultLauncher<Intent>,
3423
): AuthState
3524

3625
suspend fun validateSubscription(idToken: String): Boolean
3726

3827
suspend fun refreshToken(authState: AuthState): AuthState
3928

4029
fun onAuthorizationRequestResult(intent: Intent?)
30+
31+
suspend fun countImpression(authState: AuthState, activity: Context)
4132
}
4233

4334
internal class Authorizer(
4435
configuration: Configuration,
45-
private val context: Context
36+
private val context: Context,
4637
) : Authorizing {
4738
private val baseUrl = configuration.baseUrl
4839
private val redirectUri = configuration.redirectUri
@@ -89,7 +80,7 @@ internal class Authorizer(
8980

9081
override suspend fun authenticate(
9182
activity: Context,
92-
activityResultLauncher: ActivityResultLauncher<Intent>
83+
activityResultLauncher: ActivityResultLauncher<Intent>,
9384
): AuthState {
9485
val request = buildRequest()
9586
val result: AuthState = suspendCoroutine { cont ->
@@ -102,8 +93,6 @@ internal class Authorizer(
10293
cont.resumeWith(Result.failure(exception))
10394
}
10495
}
105-
authService?.dispose()
106-
authService = null
10796
return result
10897
}
10998

@@ -149,6 +138,56 @@ internal class Authorizer(
149138
}
150139
}
151140

141+
override suspend fun countImpression(authState: AuthState, activity: Context) {
142+
val impressionId = UUID.randomUUID()
143+
val path = "pass/hit?pid=$propertyId&iid=$impressionId&t=pageview"
144+
145+
val response = fireRequestWithFreshTokens(path, authState, activity)
146+
147+
if (response.code == 200) {
148+
return
149+
} else {
150+
throw ContentPass.CountImpressionException(response.code)
151+
}
152+
}
153+
154+
private suspend fun fireRequestWithFreshTokens(
155+
path: String,
156+
authState: AuthState,
157+
context: Context,
158+
): Response {
159+
val client = OkHttpClient.Builder()
160+
.build()
161+
162+
return suspendCoroutine { continuation ->
163+
try {
164+
val authService = AuthorizationService(context)
165+
authState.performActionWithFreshTokens(authService) { accessToken, _, ex ->
166+
ex?.let {
167+
continuation.resumeWithException(it)
168+
return@performActionWithFreshTokens
169+
}
170+
171+
accessToken?.let { strongToken ->
172+
val authorizedRequest = Request.Builder()
173+
.url("$baseUrl/$path")
174+
.header("Authorization", "Bearer $strongToken")
175+
.build()
176+
177+
val response = client.newCall(authorizedRequest).execute()
178+
continuation.resumeWith(Result.success(response))
179+
} ?: run {
180+
val message =
181+
"Although no AuthenticationException was thrown, there's no accessToken - please report this via GitHub issues"
182+
continuation.resumeWithException(NullPointerException(message))
183+
}
184+
}
185+
} catch (error: Throwable) {
186+
continuation.resumeWithException(error)
187+
}
188+
}
189+
}
190+
152191
private suspend fun buildRequest(): AuthorizationRequest {
153192
return AuthorizationRequest.Builder(
154193
fetchConfig(),

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,10 @@ import androidx.activity.result.contract.ActivityResultContracts
99
import androidx.fragment.app.Fragment
1010
import com.squareup.moshi.Moshi
1111
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
12-
import kotlinx.coroutines.CoroutineScope
13-
import kotlinx.coroutines.Dispatchers
14-
import kotlinx.coroutines.Job
15-
import kotlinx.coroutines.delay
16-
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.*
1713
import net.openid.appauth.AuthState
1814
import net.openid.appauth.AuthorizationException
15+
import okhttp3.Request
1916
import java.io.InputStream
2017
import java.lang.NullPointerException
2118
import java.util.Timer
@@ -42,7 +39,7 @@ import java.util.TimerTask
4239
*/
4340
class ContentPass internal constructor(
4441
private val authorizer: Authorizing,
45-
private val tokenStore: TokenStoring
42+
private val tokenStore: TokenStoring,
4643
) {
4744
/**
4845
* A collection of functions that allow you to react to changes in the [ContentPass] object.
@@ -62,6 +59,13 @@ class ContentPass internal constructor(
6259
fun onFailure(exception: Throwable)
6360
}
6461

62+
class CountImpressionException(statusCode: Int) : Throwable("$statusCode")
63+
64+
interface CountImpressionCallback {
65+
fun onSuccess()
66+
fun onFailure(exception: Throwable)
67+
}
68+
6569
class Builder {
6670
private var context: Context? = null
6771
private var configuration: Configuration? = null
@@ -259,7 +263,6 @@ class ContentPass internal constructor(
259263
* @param context the context the authentication flow will be started from.
260264
* @param callback an object implementing the [AuthenticationCallback] interface that enables
261265
* you to react to the authentication flow's outcome.
262-
* @return the resulting authentication [State]
263266
*/
264267
fun authenticate(context: Context, callback: AuthenticationCallback) {
265268
CoroutineScope(coroutineContext).launch {
@@ -284,6 +287,51 @@ class ContentPass internal constructor(
284287
return state
285288
}
286289

290+
/**
291+
* Count an impression by calling this function.
292+
*
293+
* This is the compatibility function for Java users and developers who are not yet
294+
* comfortable or able to use kotlin coroutines.
295+
*
296+
* A user has to be authenticated and have an active subscription applicable to your
297+
* scope for this to work.
298+
* This function calls the callback's onSuccess on a successful impression counting.
299+
* 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.
302+
*
303+
* @param context the context the authentication flow can be restarted from in case a login is necessary.
304+
* @param callback an object implementing the [CountImpressionCallback] interface that enables
305+
* you to react to the count impression outcome.
306+
*/
307+
fun countImpression(context: Context, callback: CountImpressionCallback) {
308+
CoroutineScope(coroutineContext).launch {
309+
try {
310+
countImpressionSuspending(context)
311+
callback.onSuccess()
312+
} catch (exception: Throwable) {
313+
callback.onFailure(exception)
314+
}
315+
}
316+
}
317+
318+
/**
319+
* Count an impression by calling this function.
320+
*
321+
* A user has to be authenticated and have an active subscription applicable to your
322+
* scope for this to work.
323+
* 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.
326+
*
327+
* @param context the context the authentication flow can be restarted from in case a login is necessary.
328+
*/
329+
suspend fun countImpressionSuspending(context: Context) {
330+
return withContext(coroutineContext) {
331+
authorizer.countImpression(authState, context)
332+
}
333+
}
334+
287335
private suspend fun onNewAuthState(authState: AuthState): State {
288336
tokenStore.storeAuthState(authState)
289337

0 commit comments

Comments
 (0)