使用Ktor實作Android訂閱-Part4

YungHsin
26 min readApr 1, 2021

--

這一篇要來講怎麼從Google Cloud的Pub/Sub服務去接收訂閱訂單的部分。

這裡Android的訂閱官網有提到了這個機制的專有名詞叫做:Real-time developer notifications (RTDN)。 讓你在用戶的訂閱狀態一有任何更動,你就可以在你自己的My Server去做對應的訂單處理(例如:用戶取消了訂閱或是用戶有新的續約等等),而不用定時地一直去拿Google Devleoper API去問這筆訂閱訂單的狀態(因為Google API似乎都有呼叫配額限制)。

官網參考文件:https://developer.android.com/google/play/billing/getting-ready#configure-rtdn

Google Pub/Sub Service設定

(1)啟用Pub/Sub服務

先到你的Google Developer Console找到Pub/Sub服務,進行啟用

(2)建立主題及訂閱項目

Google Developer Console的Pub/Sub TopicList主題列表建立一個新的Topic(主題)

點選建立主題按鈕會請你輸入主題ID,同時籲社會勾選”新增預設訂閱項目”

按下建立主題後,即可看見你的主題被建立了

然後點選訂閱項目,也能看見你主題之下的訂閱項目

(3)權限新增成員並給予發布者角色

建立Google Pub/Sub服務是為了提供給Google Play在處理訂閱訂單狀態變更時,Google Play將狀態推送給Google Pub/Sub,因此我們需要在Google Developer Console的右手邊,看到權限 →新增成員

將google-play-developer-notifications@system.gserviceaccount.com 這個service account加入

並給予Pub/Sub 發布/訂閱發布者這個角色,才能把訊息Publish到我們建立的MySubscription主題

Google Developer Console設定

(1)即時開發人員通知的主題名稱設定

Google Cloud Pub/Sub設定完後,接著就可以到Google Developer Console → 你的APP →營利設定,將“主題名稱”填入在Google Pub/Sub Service設定第(2)步建立的主題名稱。

(2)按下傳送測試通知

填完主題後,試試看按下傳送通知,如果設定都有正確,會在左下角看到”已傳送測試通知”

到了這一步,算是把Real-time developer notifications (RTDN)跟Google Play搭上線了,剩下的就是我們怎麼去將放在Google Cloud Pub/Sub的通知給提取出來,而要提取的Google Pub/Sub通知的API跟呼叫Google Developer API一樣,都需要OAuth登入授權,才能進行使用,所以又會需要進行OAuth登入動作了,只是這次的scope是需要兩個

文件位置:https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/pull

Requires one of the following OAuth scopes:

OAuth登入

跟Part3的OAuth登入一樣,只是scope改為https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/pubsub

•https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?client_id=用戶端ClientId編號&redirect_uri=http://localhost&response_type=code&scope=https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/pubsub&prompt=consent&access_type=offline

按下允許後,OAuth登入會被呼叫redirect uri: http://localhost:8080的畫面:

我們的Server接收到就會在DB再新增一筆專給Google Cloud Pub/Sub服務的AccessToken,這樣我們就能在自己的My Server進行API的呼叫了。

提取Google Cloud Pub/Sub的通知

(1)有了呼叫Google Cloud Pub/Sub服務的access_token後,來呼叫提取通知的API了,裡頭的參數{subscription}就是你在Google Pub/Sub Service設定第(2)步建立的主題名稱時,預設會建立的訂閱名稱,將它填入,然後還要帶的一個必要參數是maxMassage,這裡我先給10。

POST /v1/projects/{subscription}:pull HTTP/1.1Host: pubsub.googleapis.comAuthorization: Bearer yaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxContent-Type: application/x-www-form-urlencodedContent-Length: 14maxMessages=10

(2) 提取receiveMessages

呼叫pull API後,我們會收到我們在Google Developer Console設定第(2)步測試的傳送測試通知,要注意data的格式是base64-encoded string,所以需要再encode才能確實看到明文的內容

{
"receivedMessages": [
{
"ackId": "IT4wPkVTRFAGFixdRkhRNxkIaFEOT14jPzUgKEUQBAgUBXx9cEFZdV1ccGhRDRlyfWByYlhFB1FNUndaURsHaE5tdSVxDxh2dWV8b1sTBwNEW3ZbXzOW05Kt45eBPgNOReif7OglIeO7y-ltZiU9XxJLLD5-MTJFQV5AEkw6B0RJUytDCypYEU4EIQ",
"message": {
"data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20ueXVuZ2hzaW4ubXlzdWJzY3JpcHRpb24iLCJldmVudFRpbWVNaWxsaXMiOiIxNjE1NzAzMzQzNDYzIiwidGVzdE5vdGlmaWNhdGlvbiI6eyJ2ZXJzaW9uIjoiMS4wIn19",
"messageId": "2148495106118844",
"publishTime": "2021-03-14T06:29:03.735Z"
}
}
]
}

拿到的data內容實際上是:

{
"version": "1.0",
"packageName": "com.yunghsin.mysubscription",
"eventTimeMillis": "1615703343463",
"testNotification": {
"version": "1.0"
}
}

(3)提取通知後要記得Acknowledge

從Google Cloud Pub/Sub提取Message後,要記得做acknowledge的動作,否則下次提取還是會拿到!這要特別注意

POST /v1/{subscription}:acknowledge HTTP/1.1Host: pubsub.googleapis.comAuthorization: Bearer yaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxContent-Type: application/x-www-form-urlencoded
Content-Length: 191

ackIds="IT4wPkVTRFAGFixdRkhRNxkIaFEOT14jPzUgKEUQBAgUBXx9cEFZdV1ccGhRDRlyfWByYlhFB1FNUndaURsHaE5tdSVxDxh2dWV8b1sTBwNEW3ZbXzOW05Kt45eBPgNOReif7OglIeO7y-ltZiU9XxJLLD5-MTJFQV5AEkw6B0RJUytDCypYEU4EIQ"

(4)接收APP新的訂閱的通知

既然已經可以收到測試的通知,那可以用測試帳號來測試新的一個訂閱,然後試著pull看看會收到什麼內容。

APP下一個測試訂單,pull收到的通知內容:

{
"receivedMessages": [{
"ackId": "ISE-MD5FU0RQBhYsXUZIUTcZCGhRDk9eIz81IChFEAgIFAV8fXFES3VeXhoHUQ0ZcnxmfWlaFFNXFlF9VVsRDXptXFcnUwwdentmcm9bFQcFRVR4VnOky77i9eTXYhclSvm08thsM4nasutMZho9XxJLLD5-MSpFQV5AEkw6H0RJUytDCypYEU4E",
"message": {
"data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20ueXVuZ2hzaW4ubXlzdWJzY3JpcHRpb24iLCJldmVudFRpbWVNaWxsaXMiOiIxNjE1NzIxMTMwOTAwIiwic3Vic2NyaXB0aW9uTm90aWZpY2F0aW9uIjp7InZlcnNpb24iOiIxLjAiLCJub3RpZmljYXRpb25UeXBlIjo0LCJwdXJjaGFzZVRva2VuIjoiZm1naWZnaGVraGVucG9iYm9rZnBsbXBqLkFPLUoxT3dJUEVkRVpNZ2NsMGpEX1dNdlNuS09QUXZMaXlkVUlNeV9UajFERFRIVmJhU1JVUWI3LVZkT3ZvUTJTRFBMSjhkbjlxQmV1aEVhcXlPNXJzQk4zMkxkd0YzUXU3RTVsS2Z5Rl9SVTlYWlk5ejhvbmc0Iiwic3Vic2NyaXB0aW9uSWQiOiJteXN1YnNjcmlwdGlvbi5tb250aGx5In19",
"messageId": "2148677516670769",
"publishTime": "2021-03-14T11:25:31.780Z"
}
}
]
}

data內容實際上是:

{
"version": "1.0",
"packageName": "com.yunghsin.mysubscription",
"eventTimeMillis": "1615721130900",
"subscriptionNotification": {
"version": "1.0",
"notificationType": 4,
"purchaseToken": "fmgifghekhenpobbokfplmpj.AO-J1OwIPEdEZMgcl0jD_WMvSnKOPQvLiydUIMy_Tj1DDTHVbaSRUQb7-VdOvoQ2SDPLJ8dn9qBeuhEaqyO5rsBN32LdwF3Qu7E5lKfyF_RU9XZY9z8ong4",
"subscriptionId": "mysubscription.monthly"
}
}

notificationType:4 表示是新的訂閱

訂閱的狀態可以查看官方文件,共有13個狀態

https://developer.android.com/google/play/billing/rtdn-reference

而且會發現pull收到的purchaseToken跟Android APP logcat出來的purchaseToken是一樣的!

2021– 03– 14 19: 25: 32.980 15658– 15658 / com.yunghsin.mysubscription D / TEST: json: {
"orderId": "GPA.3319–5587–5282–72140",
"packageName": "com.yunghsin.mysubscription",
"productId": "mysubscription.monthly",
"purchaseTime": 1615721129856,
"purchaseState": 0,
"purchaseToken": "fmgifghekhenpobbokfplmpj.AO-J1OwIPEdEZMgcl0jD_WMvSnKOPQvLiydUIMy_Tj1DDTHVbaSRUQb7-VdOvoQ2SDPLJ8dn9qBeuhEaqyO5rsBN32LdwF3Qu7E5lKfyF_RU9XZY9z8ong4",
"autoRenewing": true,
"acknowledged": false
}

(5)接收APP續約的訂閱的通知

測試的訂閱會在五分鐘後再送一次續約的通知,你可以收到notificationType變為2,表示變成一個新週期的訂閱。

{
"version": "1.0",
"packageName": "com.yunghsin.mysubscription",
"eventTimeMillis": "1615721431629",
"subscriptionNotification": {
"version": "1.0",
"notificationType": 2,
"purchaseToken": "fmgifghekhenpobbokfplmpj.AO-J1OwIPEdEZMgcl0jD_WMvSnKOPQvLiydUIMy_Tj1DDTHVbaSRUQb7-VdOvoQ2SDPLJ8dn9qBeuhEaqyO5rsBN32LdwF3Qu7E5lKfyF_RU9XZY9z8ong4",
"subscriptionId": "mysubscription.monthly"
}
}

(6)取消APP訂閱的通知

{
"version": "1.0",
"packageName": "com.yunghsin.mysubscription",
"eventTimeMillis": "1615722607820",
"subscriptionNotification": {
"version": "1.0",
"notificationType": 3,
"purchaseToken": "fmgifghekhenpobbokfplmpj.AO-J1OwIPEdEZMgcl0jD_WMvSnKOPQvLiydUIMy_Tj1DDTHVbaSRUQb7-VdOvoQ2SDPLJ8dn9qBeuhEaqyO5rsBN32LdwF3Qu7E5lKfyF_RU9XZY9z8ong4",
"subscriptionId": "mysubscription.monthly"
}
}

如果將測試的訂閱取消,則會你收到notificationType變為3,表示這個該訂閱已經被取消。

(7)訂閱到期的通知

訂閱如果被取消,則到了訂閱過期時間,就會收到訂閱過期的通知notificationType變為13。

{
"version": "1.0",
"packageName": "com.yunghsin.mysubscription",
"eventTimeMillis": "1615722628037",
"subscriptionNotification": {
"version": "1.0",
"notificationType": 13,
"purchaseToken": "fmgifghekhenpobbokfplmpj.AO-J1OwIPEdEZMgcl0jD_WMvSnKOPQvLiydUIMy_Tj1DDTHVbaSRUQb7-VdOvoQ2SDPLJ8dn9qBeuhEaqyO5rsBN32LdwF3Qu7E5lKfyF_RU9XZY9z8ong4",
"subscriptionId": "mysubscription.monthly"
}
}

其他的未列到的通知就留給各位自己實際測試,因為有的狀態像是SUBSCRIPTION_IN_GRACE_PERIOD,要特去Google Play Console後台設定還有要剛好有那個情境才有辦法測出來,我的話是只處理最一般的續訂跟取消以及過期狀態了。

Ktor程式實作

這裡要實作接收Real-Time Developer Notification的話,需要寫一個定期呼叫的排成來去Google Pub/Sub來去pull通知,所以要在Ktor寫一個排程的程式。而呼叫Google Pub/Sub API也要另外去寫一個Http Post來處理,並且還要去acknowledge這些Message,加上前面的提取通知我們只解析了data的部分,其實我們還需要再拿purchaseToken再去跟Google Developer API去詢問訂單,解析SubscriptionPurchase物件才能確定最後訂單的狀態
歸納起來程式要實作的部分:

  • 排程呼叫Pull方法
  • Http Post Google Cloud Pub/Sub Pull跟Acknowledge
  • Http Post Google Developer API獲得處理最後的訂閱狀態

排程呼叫Pull方法

(1)實作呼叫Pull方法

先把呼叫Http Post pull方法的Code寫起來,然後自定義PullReponse的model,好直接接收JSON轉換成物件使用

suspend fun pull(accessToken: String): PullResponse {
return HttpClientUtil.client.post<PullResponse> {
url("${ANDROID_PUBSUB_PULL_URL}/${topic}/subscriptions/${subscription}:pull")
header("Authorization", "Bearer $accessToken")
header("Content", "application/x-www-form-urlencoded")
parameter("maxMessages", 10)
parameter("returnImmediately", true)
}
}

PullResponse裡會收到List<ReceivedMessage>?,如果沒有Message的話確實也是會收到null。

class PullResponse {
var receivedMessages:List<ReceivedMessage>?=null
}

ReceviedMessage裡頭包含了Message的資料,裡頭會有base64格式的data以及messageId跟被publish的時間。

class Message {
var data:String =""
var messageId:String = ""
var publishTime: Date?=null

}

(2)進行排程

排程的寫法使用Timer的ScheduleAtFixedRate的方法:https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.concurrent/java.util.-timer/schedule-at-fixed-rate.html

這裡我們排的時間是程式不延遲直接執行一次,之後60秒會持續呼叫(測試用,實際可能可以一小時或更久,看你的服務夠不夠多人訂了)。

fun schedulePull(accessToken: String, scope: String) {
Timer().scheduleAtFixedRate(object : TimerTask() {
override fun run() {
pullRTDN(accessToken, scope)
}
}, 0, 60 * 1000L)//delay 0秒後,一分鐘呼叫一次
}

(3)使用非同步呼叫pull跟acknowledge

因為過程中會需要參數accessToken,而scope是用來發生accessToken失效時預備來refreshToken的。

private fun pullRTDN(accessToken: String, scope: String) {
CoroutineScope(Dispatchers.IO).launch {
try {
pullAndAcknowledge(accessToken)
} catch (e: ClientRequestException) {
if (e.response.status == HttpStatusCode.Unauthorized) {
CoroutineScope(Dispatchers.IO).launch {
println("do OAuth refresh token")
//do OAuth refresh token
val refreshTokenResult =
withContext(Dispatchers.Default) {
OAuthService().refreshToken(scope)
}
refreshTokenResult?.let {
println("retry pullRTDN accessToken:${it.access_token}")
pullAndAcknowledge(it.access_token)

}
}
}
}
}
}
private suspend fun pullAndAcknowledge(accessToken:String){
withContext(Dispatchers.Default) {
pull(accessToken)
}.run {
this.receivedMessages?.run{
for (data in this) {
acknowledge(accessToken, data.ackId)
}
}
}
}
private suspend fun pull(accessToken: String): PullResponse {
return HttpClientUtil.client.post<PullResponse> {
url("${ANDROID_PUBSUB_PULL_URL}/${topic}/subscriptions/${subscription}:pull")
header("Authorization", "Bearer $accessToken")
header("Content", "application/x-www-form-urlencoded")
parameter("maxMessages", 5)
parameter("returnImmediately", true)
}
}
private suspend fun acknowledge(accessToken: String, ackId: String): String {
println("acknowledge message:${ackId}")
return HttpClientUtil.client.post<String> {
url("${ANDROID_PUBSUB_PULL_URL}/${topic}/subscriptions/${subscription}:acknowledge")
header("Authorization", "Bearer $accessToken")
header("Content", "application/x-www-form-urlencoded")
parameter("ackIds", ackId)

}
}

(4)解析PullResponse的ReceiveMessage的base64裡頭會有DeveloperNotification類型,這個類型有分為三種:

詳情見官網:https://developer.android.com/google/play/billing/rtdn-reference

  • SubscriptionNotification(訂閱通知)
  • OneTimeProductNotification(一次性購買通知)
  • TestNotification(測試通知,從Goolge Play developer Console後台發送的)
private fun getDeveloperNotifications(pullResponse: PullResponse): ArrayList<DeveloperNotification> {
println("pullResponse:${pullResponse.receivedMessages?.count()}")
var developerNotifications = arrayListOf<DeveloperNotification>()
return pullResponse.receivedMessages?.run {
for (data in this) {
data.message?.let {
val decodedBytes = Base64.getDecoder().decode(it.data)
val decodedString = String(decodedBytes)
println("decodedString:${decodedString}")
val developerNotification =
Gson().fromJson<DeveloperNotification>(decodedString, DeveloperNotification::class.java)
developerNotifications.add(developerNotification)
println("developerNotification:${developerNotification.packageName}")
}
}
developerNotifications
} ?: developerNotifications
}

(5)最後從developerNotification的SubscriptionNotification裡頭找出subscriptionId跟purchaseToken去詢問實際訂單的狀態,就能從伺服器端去處理訂閱的後續動作了!

if (developerNotifications != null) {
for (data in developerNotifications) {
val subscriptionPurchase = async {
PurchaseSubscriptionService().get(
data.packageName,
data.subscriptionNotification!!.subscriptionId,
data.subscriptionNotification!!.purchaseToken,
OAuthService().getAccessToken(ANDRODI_PUBLISHER_SCOPE)?.access_token ?: "",
ANDRODI_PUBLISHER_SCOPE)
}.await()
println("SubscriptionPurchase: $subscriptionPurchase")
}
}

總結

目前Part4算是訂閱裡內容較多的部分,要先設定好Google Pub/Sub服務,然後OAuth授權,然後要定期去將訂閱通知提取。這裡特別提醒,因為提取時訂閱通知的時間可能順序不定,所以notificationType可能只能當參考用,最後還是要拿purchaseToken用Google Developer API再詢問訂閱的最終情形。

下一篇Part5將會把Part2、Part3、Part4,APP跟Server的功能做整合,訂閱的部分就算告一段落了。

Buy Me a Tea!

--

--

YungHsin
YungHsin

No responses yet