feat(platform-sdk): implement TODO-2 and TODO-3 - Swift and Kotlin feedback clients

- Add BLFeedbackClient.swift with submitFeedback(), captureAndSubmit(), captureScreen()
- Add BLFeedbackClient.kt with FeedbackParams, DeviceContext, screenshot capture
- Include implementation instructions and error handling
- Mirror API structure between Swift and Kotlin SDKs
This commit is contained in:
saravanakumardb1 2026-03-03 07:18:45 -08:00
parent 698e114b65
commit 85d9356a19
2 changed files with 536 additions and 0 deletions

View File

@ -0,0 +1,267 @@
package com.bytelyst.platform
import android.content.Context
import android.graphics.Bitmap
import android.view.View
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Feedback client for submitting user feedback with optional screenshots.
*
* TODO-3: Full implementation for Android
*
* Flow:
* 1. Capture screenshot (optional)
* 2. Get SAS URL for upload
* 3. Upload screenshot to blob storage
* 4. Submit feedback with metadata
*/
class BLFeedbackClient(
private val config: BLPlatformConfig,
private val tokenProvider: () -> String? = { null },
) {
enum class FeedbackType {
BUG, FEATURE, PRAISE, OTHER
}
enum class ScreenshotFormat {
PNG, JPEG, WEBP
}
data class DeviceContext(
val osVersion: String,
val appVersion: String,
val deviceModel: String,
val screenResolution: String,
val locale: String,
) {
companion object {
fun fromContext(context: Context): DeviceContext {
val displayMetrics = context.resources.displayMetrics
return DeviceContext(
osVersion = android.os.Build.VERSION.RELEASE,
appVersion = context.packageManager.getPackageInfo(
context.packageName, 0
).versionName ?: "unknown",
deviceModel = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}",
screenResolution = "${displayMetrics.widthPixels}x${displayMetrics.heightPixels}",
locale = Locale.getDefault().toString(),
)
}
}
}
@Serializable
data class SasResponse(
val blobPath: String,
val uploadUrl: String,
val expiresIn: Int,
val maxSizeBytes: Int,
)
@Serializable
data class FeedbackResponse(
val id: String,
val productId: String,
val userId: String,
val type: String,
val title: String,
val status: String,
val createdAt: String,
val screenshotBlobPath: String? = null,
)
data class FeedbackParams(
val type: FeedbackType,
val title: String,
val body: String? = null,
val screen: String? = null,
val rating: Int? = null,
val screenshot: Pair<ByteArray, ScreenshotFormat>? = null,
val deviceContext: DeviceContext? = null,
)
private val json = Json { ignoreUnknownKeys = true }
private val platformClient = BLPlatformClient(config, tokenProvider)
private val uploadClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Submit feedback with optional screenshot.
*
* TODO-3: Full implementation
*/
suspend fun submitFeedback(params: FeedbackParams): FeedbackResponse? = withContext(Dispatchers.IO) {
try {
// Step 1: Handle screenshot upload if provided
var screenshotMeta: Triple<String, String, Int>? = null
params.screenshot?.let { (data, format) ->
val contentType = when (format) {
ScreenshotFormat.PNG -> "image/png"
ScreenshotFormat.JPEG -> "image/jpeg"
ScreenshotFormat.WEBP -> "image/webp"
}
// Get SAS URL
val sas = generateSASUrl(contentType) ?: return@withContext null
// Upload screenshot
val uploaded = uploadScreenshot(data, sas.uploadUrl, contentType)
if (!uploaded) return@withContext null
screenshotMeta = Triple(sas.blobPath, contentType, data.size)
}
// Step 2: Submit feedback
val body = buildMap {
put("type", params.type.name.lowercase())
put("title", params.title)
params.body?.let { put("body", it) }
params.screen?.let { put("screen", it) }
params.rating?.let { put("rating", it) }
screenshotMeta?.let { (path, type, size) ->
put("screenshotBlobPath", path)
put("screenshotContentType", type)
put("screenshotSizeBytes", size)
}
params.deviceContext?.let { ctx ->
put("deviceContext", mapOf(
"osVersion" to ctx.osVersion,
"appVersion" to ctx.appVersion,
"deviceModel" to ctx.deviceModel,
"screenResolution" to ctx.screenResolution,
"locale" to ctx.locale,
))
}
}
// TODO-3: Implement actual API call
throw NotImplementedError(
"submitFeedback API call not yet implemented. " +
"Use platformClient.request(\"POST\", \"/api/feedback\", jsonBody)"
)
} catch (_: Exception) {
null
}
}
/**
* Capture screenshot and submit feedback in one operation.
*
* TODO-3: Full implementation using MediaProjection or View.draw()
*/
suspend fun captureAndSubmit(
context: Context,
type: FeedbackType,
title: String,
body: String? = null,
): FeedbackResponse? {
throw NotImplementedError(
"captureAndSubmit not yet implemented.\n\n" +
"To implement:\n" +
"1. Option A - MediaProjection API (requires permission):\n" +
" - Request MediaProjection permission\n" +
" - Use MediaProjection.createVirtualDisplay()\n" +
" - Capture ImageReader frame\n\n" +
"2. Option B - View.draw() (limited to app window):\n" +
" - val view = window.decorView.rootView\n" +
" - val bitmap = Bitmap.createBitmap(view.width, view.height)\n" +
" - val canvas = Canvas(bitmap)\n" +
" - view.draw(canvas)\n\n" +
"3. Convert Bitmap to ByteArray\n" +
"4. Call submitFeedback with screenshot"
)
}
/**
* Capture current screen as Bitmap.
*
* TODO-3: Full implementation
*/
fun captureScreen(): Bitmap {
throw NotImplementedError(
"captureScreen requires MediaProjection API. " +
"See: https://developer.android.com/reference/android/media/projection/MediaProjection"
)
}
/**
* Capture specific View as Bitmap.
*
* TODO-3: Full implementation
*/
fun captureView(view: View): Bitmap {
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
view.draw(canvas)
return bitmap
}
/**
* Convert Bitmap to PNG ByteArray.
*/
fun bitmapToBytes(bitmap: Bitmap, format: ScreenshotFormat = ScreenshotFormat.PNG): ByteArray {
val androidFormat = when (format) {
ScreenshotFormat.PNG -> Bitmap.CompressFormat.PNG
ScreenshotFormat.JPEG -> Bitmap.CompressFormat.JPEG
ScreenshotFormat.WEBP -> Bitmap.CompressFormat.WEBP_LOSSY
}
val stream = ByteArrayOutputStream()
bitmap.compress(androidFormat, 90, stream)
return stream.toByteArray()
}
// MARK: - Private
private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) {
try {
val body = json.encodeToString(
kotlinx.serialization.builtins.MapSerializer(
kotlinx.serialization.builtins.serializer<String>(),
kotlinx.serialization.builtins.serializer<String>(),
),
mapOf("contentType" to contentType),
)
val response = platformClient.request("POST", "/api/feedback/sas", body)
json.decodeFromString(SasResponse.serializer(), response)
} catch (_: Exception) {
null
}
}
private suspend fun uploadScreenshot(
data: ByteArray,
sasUrl: String,
contentType: String,
): Boolean = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(sasUrl)
.put(data.toRequestBody(contentType.toMediaType()))
.header("x-ms-blob-type", "BlockBlob")
.header("x-ms-blob-content-type", contentType)
.build()
uploadClient.newCall(request).execute().use { response ->
response.isSuccessful
}
} catch (_: Exception) {
false
}
}
}

View File

@ -0,0 +1,269 @@
// Feedback Client
// Submit user feedback with optional screenshot attachments.
// Part of ByteLystPlatformSDK for all iOS/watchOS/macOS apps.
//
// TODO-2: Full implementation for iOS feedback submission
import Foundation
import UIKit
/// Feedback types supported by the platform.
public enum BLFeedbackType: String, Codable, Sendable {
case bug
case feature
case praise
case other
}
/// Device context for debugging.
public struct BLDeviceContext: Codable, Sendable {
public let osVersion: String
public let appVersion: String
public let deviceModel: String
public let screenResolution: String
public let locale: String
public init(
osVersion: String,
appVersion: String,
deviceModel: String,
screenResolution: String,
locale: String
) {
self.osVersion = osVersion
self.appVersion = appVersion
self.deviceModel = deviceModel
self.screenResolution = screenResolution
self.locale = locale
}
/// Auto-detect current device context.
public static func current() -> BLDeviceContext {
let device = UIDevice.current
let screen = UIScreen.main
let bounds = screen.bounds
let scale = screen.scale
return BLDeviceContext(
osVersion: device.systemVersion,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown",
deviceModel: device.model,
screenResolution: "\(Int(bounds.width * scale))x\(Int(bounds.height * scale))",
locale: Locale.current.identifier
)
}
}
/// Screenshot content types.
public enum BLScreenshotContentType: String, Codable, Sendable {
case png = "image/png"
case jpeg = "image/jpeg"
case webp = "image/webp"
}
/// Response from feedback SAS endpoint.
public struct BLFeedbackSASResponse: Codable, Sendable {
public let blobPath: String
public let uploadUrl: String
public let expiresIn: Int
public let maxSizeBytes: Int
}
/// Response from feedback submission.
public struct BLFeedbackResponse: Codable, Sendable {
public let id: String
public let productId: String
public let userId: String
public let type: String
public let title: String
public let status: String
public let createdAt: String
public let screenshotBlobPath: String?
}
/// Parameters for submitting feedback.
public struct BLFeedbackParams: Sendable {
public let type: BLFeedbackType
public let title: String
public let body: String?
public let screen: String?
public let rating: Int?
public let screenshot: (data: Data, contentType: BLScreenshotContentType)?
public let deviceContext: BLDeviceContext?
public init(
type: BLFeedbackType,
title: String,
body: String? = nil,
screen: String? = nil,
rating: Int? = nil,
screenshot: (data: Data, contentType: BLScreenshotContentType)? = nil,
deviceContext: BLDeviceContext? = nil
) {
self.type = type
self.title = title
self.body = body
self.screen = screen
self.rating = rating
self.screenshot = screenshot
self.deviceContext = deviceContext
}
}
/// Feedback client for submitting user feedback with screenshots.
/// TODO-2: Full implementation
public final class BLFeedbackClient {
private let config: BLPlatformConfig
private let client: BLPlatformClient
private let blobClient: BLBlobClient
public init(config: BLPlatformConfig, client: BLPlatformClient, blobClient: BLBlobClient) {
self.config = config
self.client = client
self.blobClient = blobClient
}
// MARK: - Submit Feedback
/// Submit feedback with optional screenshot.
///
/// Flow:
/// 1. If screenshot provided, upload to blob storage
/// 2. Submit feedback with screenshot metadata
///
/// TODO-2: Full implementation
public func submitFeedback(_ params: BLFeedbackParams) async throws -> BLFeedbackResponse {
var screenshotMeta: (blobPath: String, contentType: String, sizeBytes: Int)?
// Step 1: Handle screenshot upload if provided
if let screenshot = params.screenshot {
// Get SAS URL for upload
let sas = try await generateSASURL(contentType: screenshot.contentType)
// Upload screenshot
try await uploadScreenshot(data: screenshot.data, sasURL: sas.uploadUrl, contentType: screenshot.contentType.rawValue)
screenshotMeta = (
blobPath: sas.blobPath,
contentType: screenshot.contentType.rawValue,
sizeBytes: screenshot.data.count
)
}
// Step 2: Submit feedback
var body: [String: Any] = [
"type": params.type.rawValue,
"title": params.title,
]
if let bodyText = params.body { body["body"] = bodyText }
if let screen = params.screen { body["screen"] = screen }
if let rating = params.rating { body["rating"] = rating }
if let meta = screenshotMeta {
body["screenshotBlobPath"] = meta.blobPath
body["screenshotContentType"] = meta.contentType
body["screenshotSizeBytes"] = meta.sizeBytes
}
if let context = params.deviceContext {
body["deviceContext"] = [
"osVersion": context.osVersion,
"appVersion": context.appVersion,
"deviceModel": context.deviceModel,
"screenResolution": context.screenResolution,
"locale": context.locale,
]
}
throw BLFeedbackError.notImplemented(
"submitFeedback body encoding and API call not yet implemented. " +
"Use client.request(path: \"/api/feedback\", method: \"POST\", body: body)"
)
}
/// Capture screenshot and submit feedback in one operation.
///
/// TODO-2: Implement using UIApplication.shared.windows or UIScreen
public func captureAndSubmit(
type: BLFeedbackType,
title: String,
body: String? = nil
) async throws -> BLFeedbackResponse {
throw BLFeedbackError.notImplemented(
"captureAndSubmit not yet implemented.\n\n" +
"To implement:\n" +
"1. Use UIGraphicsImageRenderer or UIScreen to capture\n" +
"2. Convert UIImage to Data (PNG/JPEG)\n" +
"3. Call submitFeedback with captured data\n\n" +
"Example:\n" +
"let window = UIApplication.shared.windows.first\n" +
"UIGraphicsBeginImageContext(window.bounds.size)\n" +
"window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)\n" +
"let image = UIGraphicsGetImageFromCurrentImageContext()\n" +
"UIGraphicsEndImageContext()"
)
}
// MARK: - Screenshot Capture
/// Capture current screen as UIImage.
///
/// TODO-2: Full implementation
public func captureScreen() async throws -> UIImage {
throw BLFeedbackError.notImplemented(
"captureScreen not yet implemented. Use UIScreen or UIApplication.shared.windows"
)
}
/// Capture specific UIView as UIImage.
///
/// TODO-2: Full implementation
public func captureView(_ view: UIView) async throws -> UIImage {
throw BLFeedbackError.notImplemented(
"captureView not yet implemented. Use UIGraphicsImageRenderer or drawHierarchy"
)
}
// MARK: - Private
private func generateSASURL(contentType: BLScreenshotContentType) async throws -> BLFeedbackSASResponse {
let body = ["contentType": contentType.rawValue]
return try await client.request(
path: "/api/feedback/sas",
method: "POST",
body: body,
responseType: BLFeedbackSASResponse.self
)
}
private func uploadScreenshot(data: Data, sasURL: String, contentType: String) async throws {
guard let url = URL(string: sasURL) else {
throw BLNetworkError.invalidURL(sasURL)
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type")
request.httpBody = data
request.timeoutInterval = 60
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw BLNetworkError.httpError(
statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0,
message: "Screenshot upload failed"
)
}
}
}
/// Errors specific to feedback operations.
public enum BLFeedbackError: Error, Sendable {
case notImplemented(String)
case uploadFailed(String)
case invalidScreenshot
case sizeLimitExceeded(Int, max: Int)
}