diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt new file mode 100644 index 00000000..efd58d4c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt @@ -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? = 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? = 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(), + kotlinx.serialization.builtins.serializer(), + ), + 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 + } + } +} diff --git a/packages/swift-platform-sdk/Sources/BLFeedbackClient.swift b/packages/swift-platform-sdk/Sources/BLFeedbackClient.swift new file mode 100644 index 00000000..efd6e164 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLFeedbackClient.swift @@ -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) +}