diff --git a/.gitignore b/.gitignore index 8fbbbc4..d676f57 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,9 @@ .cxx .externalNativeBuild .gradle -.idea -/.idea/assetWizardSettings.xml -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/navEditor.xml -/.idea/workspace.xml +.idea/caches +.idea/workspace.xml +.idea/shelf /TopsortAnalytics/build /build /captures diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..40d39ba --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +TopsortAnalytics \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..3f45c18 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fe63bb6 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 078b3b1..d95de40 100644 --- a/README.md +++ b/README.md @@ -238,4 +238,51 @@ private void reportImpressionWithResolvedBidId() { } ``` +#### Banners on Android + +#### Kotlin +You should first add the `BannerView` into your activity `xml`. You can do so with +Android Studio's visual editor, but the end file should like like the following +```xml + + + + +``` + +Then, you have to call the `BannerView.setup()` function with you auction parameters. +Notice that since this makes network calls, we need to `launch` it in a co-routine. +```kotlin +class SampleActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.sample_activity) + + this.lifecycleScope.launch { + val bannerView = findViewById(R.id.bannerView) + val bannerConfig = + BannerConfig.CategorySingle(slotId = "slot", category = "category") + bannerView.setup( + bannerConfig, + "sample_activity", + null, + { id, entityType -> onBannerClick(id, entityType) }) + } + } +} +``` + + [1]: ./LICENSE diff --git a/TopsortAnalytics/build.gradle b/TopsortAnalytics/build.gradle index 4563d66..0c747b3 100644 --- a/TopsortAnalytics/build.gradle +++ b/TopsortAnalytics/build.gradle @@ -53,6 +53,9 @@ dependencies { //JodaTime implementation group: 'joda-time', name: 'joda-time', version: '2.12.5' + // Image Loading for Banners + implementation 'io.coil-kt:coil:2.7.0' + testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20200518' testImplementation 'org.assertj:assertj-core:3.26.0' diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/BannerView.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/BannerView.kt new file mode 100644 index 0000000..1ff9107 --- /dev/null +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/BannerView.kt @@ -0,0 +1,60 @@ +package com.topsort.analytics.banners + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import coil.load +import com.topsort.analytics.Analytics +import com.topsort.analytics.model.Placement +import com.topsort.analytics.model.auctions.EntityType + + +/** + * View for displaying banners powered by auctions. + * + * @constructor The constructor is meant to be called automatically from XML inflation. + * You can add this view to your layout by using a `com.topsort.analytics.banners.BannerView` element. + * + * @param context + * @param attrs AttributeSet for the view. Since this view inherits from `ImageView` + * you can set attributes as you would with a regular `ImageView`. + */ +class BannerView( + context: Context, + attrs: AttributeSet +) : ImageView(context, attrs) { + + /** + * Setup the banner in the view by running an auction in the background. + * + * @param config a BannerConfig object that specifies the parameters for the auction + * @param path identifier for the activity where the banner is displayed. It's recommended to be the deeplink for the view. + * @param location optional name for the location within the view where the banner is displayed. + * @param onClick callback for when the banner is clicked. Usually this should navigate to an activity related to the banner (e.g. the product page for the product shown in the banner). + * @receiver + */ + suspend fun setup( + config: BannerConfig, + path: String, + location: String?, + onClick: (String, EntityType) -> Unit + ) { + val result = runBannerAuction(config) + if (result != null) { + this.load(result.url) + this.viewTreeObserver.addOnGlobalLayoutListener { + Analytics.reportImpressionPromoted( + resolvedBidId = result.resolvedBidId, + placement = Placement(path = path, location = location) + ) + } + this.setOnClickListener { + Analytics.reportClickPromoted( + resolvedBidId = result.resolvedBidId, + placement = Placement(path = path, location = location) + ) + onClick(result.id, result.type) + } + } + } +} \ No newline at end of file diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/run.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/run.kt index fa0fc06..c40135f 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/run.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/banners/run.kt @@ -2,7 +2,11 @@ package com.topsort.analytics.banners import com.topsort.analytics.model.auctions.Auction import com.topsort.analytics.model.auctions.AuctionRequest +import com.topsort.analytics.model.auctions.AuctionResponse import com.topsort.analytics.service.TopsortAuctionsHttpService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Run a banner auction with a single slot @@ -10,16 +14,20 @@ import com.topsort.analytics.service.TopsortAuctionsHttpService * @param config the banner configuration that specifies which kind of banner auction to run * @return A BannerResponse if the auction successfully returned a winner or null if not. */ -fun runBannerAuction(config: BannerConfig): BannerResponse? { +suspend fun runBannerAuction(config: BannerConfig): BannerResponse? { val auction = buildBannerAuction(config) val request = AuctionRequest(listOf(auction)) - val response = TopsortAuctionsHttpService.runAuctions(request) + var response: AuctionResponse? = null; + val auctionJob = CoroutineScope(Dispatchers.IO).launch { + response = TopsortAuctionsHttpService.runAuctions(request) + } + auctionJob.join() if ((response?.results?.isNotEmpty() == true)) { - if (response.results[0].winners.isNotEmpty()) { - val winner = response.results[0].winners[0] + if (response!!.results[0].winners.isNotEmpty()) { + val winner = response!!.results[0].winners[0] return BannerResponse( id = winner.id, - url = winner.asset!!.url, + url = winner.asset!![0].url, type = winner.type, resolvedBidId = winner.resolvedBidId ) diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/Auction.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/Auction.kt index b131806..7154286 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/Auction.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/Auction.kt @@ -23,7 +23,7 @@ data class Auction private constructor( put("products", JSONObject.wrap(products)) } if (category != null) { - put("category", JSONObject.wrap(category)) + put("category", category.toJsonObject()) } if (searchQuery != null) { put("searchQuery", searchQuery) @@ -214,7 +214,23 @@ data class Auction private constructor( val id: String? = null, val ids: List? = null, val disjunctions: List>? = null, - ) + ) { + fun toJsonObject(): JSONObject { + val builder = JSONObject() + with(builder) { + if (id != null) { + put("id", id) + } + if (ids != null) { + put("ids", JSONObject.wrap(ids)) + } + if (disjunctions != null) { + put("disjunctions", JSONObject.wrap(disjunctions)) + } + } + return builder + } + } data class GeoTargeting( val location: String, diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/AuctionResponse.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/AuctionResponse.kt index cdc3c9e..0097a05 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/AuctionResponse.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/auctions/AuctionResponse.kt @@ -44,26 +44,33 @@ data class AuctionResponse private constructor( val type: EntityType, val id: String, val resolvedBidId: String, - val asset: Asset? = null, + val asset: List? = null, ) { companion object { fun fromJsonObject(json: JSONObject): AuctionWinnerItem { + val assetArray = json.optJSONArray("asset") + var assets: List? = null + if (assetArray != null) { + assets = (0 until assetArray.length()).map { + Asset.fromJsonObject(assetArray.getJSONObject(it)) + } + } return AuctionWinnerItem( rank = json.getInt("rank"), type = EntityType.fromValue(json.getString("type")), id = json.getString("id"), resolvedBidId = json.getString("resolvedBidId"), - asset = Asset.fromJsonObject(json), + asset = assets, ) } } } + data class Asset(val url: String) { companion object { - fun fromJsonObject(json: JSONObject): Asset? { - val asset = json.optJSONObject("asset") ?: return null - val url = asset.getString("url") + fun fromJsonObject(json: JSONObject): Asset { + val url = json.getString("url") return Asset(url = url) } } diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAuctionsHttpService.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAuctionsHttpService.kt index ea81b45..ce98f7a 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAuctionsHttpService.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAuctionsHttpService.kt @@ -1,5 +1,6 @@ package com.topsort.analytics.service +import android.util.Log import com.topsort.analytics.Cache import com.topsort.analytics.core.HttpClient import com.topsort.analytics.core.HttpResponse @@ -17,12 +18,15 @@ internal object TopsortAuctionsHttpService { val response = executeRunAuctions(auctionRequest) if (response.isSuccessful()) { return AuctionResponse.fromJson(response.body) + } else { + Log.w("TopsortAuctionsHttpService", "Auction message: " + response.message) + Log.w("TopsortAuctionsHttpService", "Auction response: " + response.body.toString()) } return null } private fun executeRunAuctions(auctionRequest: AuctionRequest): HttpResponse { - if(!this::httpClient.isInitialized){ + if (!this::httpClient.isInitialized) { httpClient = HttpClient("${baseApiUrl}${AUCTION_ENDPOINT}") } val json = auctionRequest.toJsonObject().toString() diff --git a/app/build.gradle b/app/build.gradle index 26e852f..281861b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,11 +9,11 @@ plugins { def properties = { k -> "\"${project.properties.get(k)}\"" } android { - namespace 'com.topsort.analytics' + namespace 'com.topsort.example' compileSdk 34 defaultConfig { - applicationId "com.topsort.analytics" + applicationId "com.topsort.example" minSdk 24 targetSdk 34 versionCode 1 @@ -54,6 +54,7 @@ tasks.withType(Detekt).configureEach { dependencies { implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4' implementation 'com.google.android.material:material:1.12.0' implementation 'com.topsort:topsort-kt:1.0.0-alpha.0' diff --git a/app/src/androidTest/java/com/topsort/analytics/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/topsort/example/ExampleInstrumentedTest.kt similarity index 85% rename from app/src/androidTest/java/com/topsort/analytics/ExampleInstrumentedTest.kt rename to app/src/androidTest/java/com/topsort/example/ExampleInstrumentedTest.kt index 9329903..8b087b7 100644 --- a/app/src/androidTest/java/com/topsort/analytics/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/topsort/example/ExampleInstrumentedTest.kt @@ -1,11 +1,10 @@ -package com.topsort.analytics +package com.topsort.example -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.assertEquals /** * Instrumented test, which will execute on an Android device. @@ -18,6 +17,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.topsort.analytics", appContext.packageName) + assertEquals("com.topsort.example", appContext.packageName) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e13d4bf..cfd043e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - - + tools:targetApi="34"> - + diff --git a/app/src/main/java/com/topsort/analytics/JavaSampleActivity.java b/app/src/main/java/com/topsort/example/JavaSampleActivity.java similarity index 98% rename from app/src/main/java/com/topsort/analytics/JavaSampleActivity.java rename to app/src/main/java/com/topsort/example/JavaSampleActivity.java index 986cb4b..4e30265 100644 --- a/app/src/main/java/com/topsort/analytics/JavaSampleActivity.java +++ b/app/src/main/java/com/topsort/example/JavaSampleActivity.java @@ -1,10 +1,11 @@ -package com.topsort.analytics; +package com.topsort.example; import android.os.Bundle; import androidx.activity.ComponentActivity; import androidx.annotation.Nullable; +import com.topsort.analytics.Analytics; import com.topsort.analytics.model.Entity; import com.topsort.analytics.model.EntityType; import com.topsort.analytics.model.Placement; diff --git a/app/src/main/java/com/topsort/analytics/SampleActivity.kt b/app/src/main/java/com/topsort/example/SampleActivity.kt similarity index 72% rename from app/src/main/java/com/topsort/analytics/SampleActivity.kt rename to app/src/main/java/com/topsort/example/SampleActivity.kt index f3f870d..a090c40 100644 --- a/app/src/main/java/com/topsort/analytics/SampleActivity.kt +++ b/app/src/main/java/com/topsort/example/SampleActivity.kt @@ -1,16 +1,35 @@ -package com.topsort.analytics +package com.topsort.example -import android.app.Activity import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.topsort.analytics.Analytics +import com.topsort.analytics.banners.BannerConfig +import com.topsort.analytics.banners.BannerView import com.topsort.analytics.model.Entity import com.topsort.analytics.model.EntityType import com.topsort.analytics.model.Placement import com.topsort.analytics.model.PurchasedItem +import kotlinx.coroutines.launch +import com.topsort.analytics.model.auctions.EntityType as BannerEntityType -class SampleActivity : Activity() { +class SampleActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContentView(R.layout.sample_activity) + + this.lifecycleScope.launch { + val bannerView = findViewById(R.id.bannerView) + val bannerConfig = + BannerConfig.CategorySingle(slotId = "slot", category = "category") + bannerView.setup( + bannerConfig, + "sample_activity", + null, + { id, entityType -> onBannerClick(id, entityType) }) + } reportPurchaseWithResolvedBidId() reportClickWithResolvedBidId() @@ -21,6 +40,7 @@ class SampleActivity : Activity() { reportImpression() } + private fun reportPurchaseWithResolvedBidId() { val item = PurchasedItem( resolvedBidId = "WyJiX01mazE1IiwiMTJhNTU4MjgtOGVhZC00Mjk5LTgzMjctY2ViYjAwMmEwZmE4IiwibGlzdGluZ3MiLCJkZWZhdWx0IiwiIl0=", @@ -99,3 +119,8 @@ class SampleActivity : Activity() { ) } } + +fun onBannerClick(id: String, entityType: BannerEntityType) { + Log.i("BannerClick", "Clicked banner for $entityType with id $id") + +} \ No newline at end of file diff --git a/app/src/main/java/com/topsort/analytics/TestApplication.kt b/app/src/main/java/com/topsort/example/TestApplication.kt similarity index 73% rename from app/src/main/java/com/topsort/analytics/TestApplication.kt rename to app/src/main/java/com/topsort/example/TestApplication.kt index 9b9f643..995cc31 100644 --- a/app/src/main/java/com/topsort/analytics/TestApplication.kt +++ b/app/src/main/java/com/topsort/example/TestApplication.kt @@ -1,6 +1,8 @@ -package com.topsort.analytics +package com.topsort.example import android.app.Application +import com.topsort.analytics.Analytics +import com.topsort.analytics.banners.BannerConfig class TestApplication : Application() { @@ -14,5 +16,6 @@ class TestApplication : Application() { opaqueUserId = sessionId, token = BuildConfig.TOKEN ) + } } diff --git a/app/src/main/res/layout/sample_activity.xml b/app/src/main/res/layout/sample_activity.xml new file mode 100644 index 0000000..9302dac --- /dev/null +++ b/app/src/main/res/layout/sample_activity.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/topsort/analytics/ExampleUnitTest.kt b/app/src/test/java/com/topsort/example/ExampleUnitTest.kt similarity index 91% rename from app/src/test/java/com/topsort/analytics/ExampleUnitTest.kt rename to app/src/test/java/com/topsort/example/ExampleUnitTest.kt index ccf0b36..f7921fa 100644 --- a/app/src/test/java/com/topsort/analytics/ExampleUnitTest.kt +++ b/app/src/test/java/com/topsort/example/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.topsort.analytics +package com.topsort.example import org.junit.Assert.assertEquals import org.junit.Test