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:
parent
698e114b65
commit
85d9356a19
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
269
packages/swift-platform-sdk/Sources/BLFeedbackClient.swift
Normal file
269
packages/swift-platform-sdk/Sources/BLFeedbackClient.swift
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user