diff --git a/android/build.gradle b/android/build.gradle index 73799ff..171c874 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -117,8 +117,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' } sourceSets { @@ -143,4 +147,3 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(":react-native-nitro-modules") } - diff --git a/android/src/main/java/com/margelo/nitro/toadly/LoggingService.kt b/android/src/main/java/com/margelo/nitro/toadly/LoggingService.kt new file mode 100644 index 0000000..8429609 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/toadly/LoggingService.kt @@ -0,0 +1,89 @@ +package com.margelo.nitro.toadly + +import android.util.Log +import android.R +import java.text.SimpleDateFormat +import java.util.* + +/** + * LoggingService manages log collection and provides logging utilities + */ +object LoggingService { + private const val TAG = "Toadly" + private const val MAX_LOGS = 50 + private val logs = mutableListOf() + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Log a message with the specified level + */ + private fun log(message: String, level: String = "INFO") { + val timestamp = dateFormat.format(Date()) + val logEntry = "[$timestamp] [$level] $message" + + synchronized(logs) { + logs.add(logEntry) + + if (logs.size > MAX_LOGS) { + logs.removeAt(0) + } + } + + when (level) { + "INFO" -> Log.i(TAG, message) + "WARN" -> Log.w(TAG, message) + "ERROR" -> Log.e(TAG, message) + else -> Log.d(TAG, message) + } + } + + /** + * Log an info message + */ + fun info(message: String) { + log(message, "INFO") + } + + /** + * Log a warning message + */ + fun warn(message: String) { + log(message, "WARN") + } + + /** + * Log an error message + */ + fun error(message: String) { + log(message, "ERROR") + } + + /** + * Get all recent logs as a single string + */ + fun getRecentLogs(): String { + synchronized(logs) { + return logs.joinToString("\n") + } + } + + /** + * Get all collected logs as a single string + */ + fun getLogs(): String { + synchronized(logs) { + return logs.joinToString("\n") + } + } + + /** + * Clear all stored logs + */ + fun clearLogs() { + synchronized(logs) { + logs.clear() + } + } +} diff --git a/android/src/main/java/com/margelo/nitro/toadly/Toadly.kt b/android/src/main/java/com/margelo/nitro/toadly/Toadly.kt index 4267827..5f072df 100644 --- a/android/src/main/java/com/margelo/nitro/toadly/Toadly.kt +++ b/android/src/main/java/com/margelo/nitro/toadly/Toadly.kt @@ -1,10 +1,177 @@ package com.margelo.nitro.toadly - + +import android.app.Activity +import android.content.Context +import com.facebook.react.bridge.UiThreadUtil import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.toadly.dialog.BugReportDialog +import com.margelo.nitro.toadly.github.GitHubService +import kotlin.collections.Map @DoNotStrip -class Toadly : HybridToadlySpec() { - override fun multiply(a: Double, b: Double): Double { - return a * b - } +object Toadly : HybridToadlySpec() { + private var hasSetupBeenCalled = false + private val jsLogs = mutableListOf() + private var githubToken: String = "" + private var repoOwner: String = "" + private var repoName: String = "" + private lateinit var githubService: GitHubService + + override fun setup(githubToken: String, repoOwner: String, repoName: String) { + if (hasSetupBeenCalled) { + return + } + hasSetupBeenCalled = true + + this.githubToken = githubToken + this.repoOwner = repoOwner + this.repoName = repoName + this.githubService = GitHubService(githubToken, repoOwner, repoName) + + LoggingService.info("Setting up Toadly with GitHub integration") + } + + override fun addJSLogs(logs: String) { + this.jsLogs.add(logs) + LoggingService.info("Received JavaScript logs") + } + + override fun createIssueWithTitle(title: String, reportType: String?) { + LoggingService.info("Creating issue with title: $title, type: ${reportType ?: "bug"}") + + val description = "User submitted bug report" + val type = reportType ?: "bug" + val email = "auto-generated@toadly.app" + val jsLogsContent = jsLogs.joinToString("\n") + val nativeLogs = LoggingService.getLogs() + + val currentActivity = getCurrentActivity() + + if (currentActivity == null) { + LoggingService.error("Cannot create GitHub issue: no activity context found") + return + } + + Thread { + try { + val success = githubService.createIssue( + context = currentActivity, + title = title, + details = description, + email = email, + jsLogs = jsLogsContent, + nativeLogs = nativeLogs, + reportType = type + ) + + if (success) { + LoggingService.info("Successfully created GitHub issue") + } else { + LoggingService.info("Failed to create GitHub issue") + } + } catch (e: Exception) { + LoggingService.error("Error creating GitHub issue: ${e.message}") + } + }.start() + } + + override fun show() { + LoggingService.info("Show bug report dialog requested") + + val currentActivity = getCurrentActivity() + + if (currentActivity == null) { + LoggingService.error("Cannot show bug report dialog: no activity found") + return + } + + BugReportDialog(currentActivity) { title, reportType, email -> + createIssueWithEmailAndTitle(title, reportType, email) + }.show() + } + + private fun createIssueWithEmailAndTitle(title: String, reportType: String, email: String) { + LoggingService.info("Creating issue with title: $title, type: $reportType, email: $email") + + val description = "User submitted bug report" + val type = reportType + val jsLogsContent = jsLogs.joinToString("\n") + val nativeLogs = LoggingService.getLogs() + + val currentActivity = getCurrentActivity() + + if (currentActivity == null) { + LoggingService.error("Cannot create GitHub issue: no activity context found") + return + } + + Thread { + try { + val success = githubService.createIssue( + context = currentActivity, + title = title, + details = description, + email = email, + jsLogs = jsLogsContent, + nativeLogs = nativeLogs, + reportType = type + ) + + if (success) { + LoggingService.info("Successfully created GitHub issue") + } else { + LoggingService.info("Failed to create GitHub issue") + } + } catch (e: Exception) { + LoggingService.error("Error creating GitHub issue: ${e.message}") + } + }.start() + } + + override fun crashNative() { + LoggingService.warn("Crash native requested - this is for testing only") + throw RuntimeException("Manually triggered crash from Toadly") + } + + // Helper method to get the current activity + private fun getCurrentActivity(): Context? { + try { + val activityThreadClass = Class.forName("android.app.ActivityThread") + val activityThread = activityThreadClass.getMethod("currentActivityThread").invoke(null) + val activityField = activityThreadClass.getDeclaredField("mActivities") + + activityField.isAccessible = true + + @Suppress("UNCHECKED_CAST") + val activities = activityField.get(activityThread) as? Map + + if (activities == null) { + return null + } + + activities.values.forEach { activityRecord -> + activityRecord?.let { record -> + val activityRecordClass = record::class.java + val pausedField = activityRecordClass.getDeclaredField("paused") + + pausedField.isAccessible = true + + if (!pausedField.getBoolean(record)) { + val activityField = activityRecordClass.getDeclaredField("activity") + activityField.isAccessible = true + val activity = activityField.get(record) + + if (activity is Context) { + return activity + } + } + } + } + + return null + } catch (e: Exception) { + LoggingService.error("Error getting current activity: ${e.message}") + return null + } + } } diff --git a/android/src/main/java/com/margelo/nitro/toadly/dialog/BugReportDialog.kt b/android/src/main/java/com/margelo/nitro/toadly/dialog/BugReportDialog.kt new file mode 100644 index 0000000..4575714 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/toadly/dialog/BugReportDialog.kt @@ -0,0 +1,82 @@ +package com.margelo.nitro.toadly.dialog + +import android.app.AlertDialog +import android.content.Context +import android.view.LayoutInflater +import android.widget.EditText +import android.widget.Spinner +import android.widget.ArrayAdapter +import android.widget.Toast +import android.widget.ImageButton +import android.os.Handler +import android.os.Looper +import androidx.appcompat.widget.AppCompatSpinner +import com.margelo.nitro.toadly.LoggingService +import com.margelo.nitro.toadly.R + +class BugReportDialog(private val context: Context, private val onSubmit: (String, String, String) -> Unit) { + + private val reportTypesMap = mapOf( + "🐞 Bug" to "bug", + "💡 Suggestion" to "enhancement", + "❓ Question" to "question" + ) + + + private val reportTypeDisplays = reportTypesMap.keys.toTypedArray() + + fun show() { + Handler(Looper.getMainLooper()).post { + try { + val dialog = AlertDialog.Builder(context, R.style.CustomDialog) + .create() + + val layout = LayoutInflater.from(context).inflate(R.layout.dialog_bug_report, null) + + val emailEditText = layout.findViewById(R.id.emailEditText) + val reportTypeSpinner = layout.findViewById(R.id.reportTypeSpinner) + val descriptionEditText = layout.findViewById(R.id.descriptionEditText) + val closeButton = layout.findViewById(R.id.closeButton) + val sendButton = layout.findViewById(R.id.sendButton) + + // Set up the spinner with report types + val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, reportTypeDisplays) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + reportTypeSpinner.adapter = adapter + + closeButton.setOnClickListener { + dialog.dismiss() + } + + sendButton.setOnClickListener { + val email = emailEditText.text?.toString() ?: "" + val description = descriptionEditText.text?.toString() ?: "" + val selectedTypeDisplay = reportTypeSpinner.selectedItem.toString() + val typeLabel = reportTypesMap[selectedTypeDisplay] ?: "bug" // Get GitHub label without emoji + + if (email.isEmpty() || description.isEmpty()) { + Toast.makeText(context, "Please fill all fields", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + val title = if (description.length > 50) { + description.substring(0, 47) + "..." + } else { + description + } + + onSubmit(title, typeLabel, email) + Toast.makeText(context, "Bug report submitted", Toast.LENGTH_SHORT).show() + dialog.dismiss() + } + + dialog.setView(layout) + dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) + dialog.show() + LoggingService.info("Bug report dialog shown") + } catch (e: Exception) { + LoggingService.error("Error showing bug report dialog: ${e.message}") + } + } + } +} diff --git a/android/src/main/java/com/margelo/nitro/toadly/github/GitHubIssueTemplate.kt b/android/src/main/java/com/margelo/nitro/toadly/github/GitHubIssueTemplate.kt new file mode 100644 index 0000000..2c78770 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/toadly/github/GitHubIssueTemplate.kt @@ -0,0 +1,152 @@ +package com.margelo.nitro.toadly.github + +import android.os.Build +import android.content.Context +import android.content.pm.PackageManager +import android.os.Environment +import android.os.StatFs +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class GitHubIssueTemplate { + companion object { + fun generateIssueBody( + context: Context, + email: String, + details: String, + jsLogs: String, + nativeLogs: String, + reportType: String? = null + ): String { + val packageInfo = try { + context.packageManager.getPackageInfo(context.packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + + val appVersion = packageInfo?.versionName ?: "Unknown" + val buildNumber = packageInfo?.versionCode?.toString() ?: "Unknown" + val deviceModel = Build.MODEL + val systemName = "Android" + val systemVersion = Build.VERSION.RELEASE + val deviceName = Build.DEVICE + val deviceIdentifier = Build.FINGERPRINT + + val timestamp = Date() + val dateFormatter = SimpleDateFormat("MMM dd, yyyy HH:mm:ss", Locale.US) + val dateString = dateFormatter.format(timestamp) + + // Get memory information + val memoryInfo = android.app.ActivityManager.MemoryInfo() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager + activityManager.getMemoryInfo(memoryInfo) + val totalMemory = formatSize(memoryInfo.totalMem) + + // Get screen information + val displayMetrics = context.resources.displayMetrics + val screenWidth = displayMetrics.widthPixels + val screenHeight = displayMetrics.heightPixels + val screenDensity = displayMetrics.density + + // Get locale information + val locale = Locale.getDefault() + val language = locale.language + val region = locale.country + + // Get disk space information + val freeSpace = getFreeSpaceInBytes() + val totalSpace = getTotalSpaceInBytes() + val freeSpaceString = formatSize(freeSpace) + val totalSpaceString = formatSize(totalSpace) + + // Get report type information + val reportTypeText = reportType ?: "Bug" + val reportTypeIcon = getIconForReportType(reportType) + + var issueBody = """ +### Description +$details + +### Report Information +| Property | Value | +| ----- | ----- | +| Report Type | $reportTypeIcon $reportTypeText | +| Email | $email | +| Timestamp | $dateString | + +### Device & App Information +| Property | Value | +| ----- | ----- | +| App Version | $appVersion ($buildNumber) | +| Device Model | $deviceModel | +| Device Name | $deviceName | +| OS | $systemName $systemVersion | +| Device ID | $deviceIdentifier | +| Memory | $totalMemory | +| Free Disk Space | $freeSpaceString / $totalSpaceString | +| Screen | ${screenWidth}x${screenHeight} @${screenDensity}x | +| Language | ${language}_${region} | +""" + + issueBody += """ + +### Logs + +#### JavaScript Logs +``` +$jsLogs +``` + +#### Native Logs +``` +$nativeLogs +``` +""" + + return issueBody + } + + private fun getIconForReportType(reportType: String?): String { + if (reportType == null) return "🐛" + + return when (reportType.lowercase()) { + "bug" -> "🐛" + "enhancement" -> "💡" + "question" -> "❓" + else -> "🐛" + } + } + + private fun formatSize(size: Long): String { + val kb = 1024L + val mb = kb * 1024 + val gb = mb * 1024 + + return when { + size >= gb -> String.format("%.2f GB", size.toFloat() / gb) + size >= mb -> String.format("%.2f MB", size.toFloat() / mb) + size >= kb -> String.format("%.2f KB", size.toFloat() / kb) + else -> "$size bytes" + } + } + + private fun getFreeSpaceInBytes(): Long { + try { + val stat = StatFs(Environment.getExternalStorageDirectory().path) + return stat.availableBlocksLong * stat.blockSizeLong + } catch (e: Exception) { + return 0 + } + } + + private fun getTotalSpaceInBytes(): Long { + try { + val stat = StatFs(Environment.getExternalStorageDirectory().path) + return stat.blockCountLong * stat.blockSizeLong + } catch (e: Exception) { + return 0 + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/margelo/nitro/toadly/github/GitHubService.kt b/android/src/main/java/com/margelo/nitro/toadly/github/GitHubService.kt new file mode 100644 index 0000000..43665d6 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/toadly/github/GitHubService.kt @@ -0,0 +1,85 @@ +package com.margelo.nitro.toadly.github + +import android.content.Context +import com.margelo.nitro.toadly.LoggingService +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.json.JSONArray +import java.io.IOException +import java.util.Date + +class GitHubService( + private val token: String, + private val repoOwner: String, + private val repoName: String +) { + private val client = OkHttpClient() + private val baseUrl = "https://api.github.com" + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + private val labelMap = mapOf( + "bug" to "bug", + "enhancement" to "enhancement", + "question" to "question", + "crash" to "crash" + ) + + fun createIssue( + context: Context, + title: String, + details: String, + jsLogs: String, + nativeLogs: String, + reportType: String, + email: String + ): Boolean { + val url = "$baseUrl/repos/$repoOwner/$repoName/issues" + + val issueBody = GitHubIssueTemplate.generateIssueBody( + context = context, + email = email, + details = details, + jsLogs = jsLogs, + nativeLogs = nativeLogs, + reportType = reportType + ) + + val label = labelMap[reportType] ?: "bug" // Default to "bug" if type not found in map + val labels = JSONArray().apply { + put(label) + } + + val jsonBody = JSONObject().apply { + put("title", title) + put("body", issueBody) + put("labels", labels) + } + + val request = Request.Builder() + .url(url) + .addHeader("Authorization", "token $token") + .addHeader("Accept", "application/vnd.github.v3+json") + .post(jsonBody.toString().toRequestBody(jsonMediaType)) + .build() + + return try { + val response = client.newCall(request).execute() + val success = response.isSuccessful + if (success) { + LoggingService.info("Successfully created GitHub issue: $title") + } else { + val responseBody = response.body?.string() ?: "" + LoggingService.info("Failed to create GitHub issue. Status: ${response.code}. Body: $responseBody") + response.body?.close() + } + response.body?.close() + success + } catch (e: IOException) { + LoggingService.info("Error creating GitHub issue: ${e.message}") + false + } + } +} diff --git a/android/src/main/res/drawable/dialog_background.xml b/android/src/main/res/drawable/dialog_background.xml new file mode 100644 index 0000000..afbe190 --- /dev/null +++ b/android/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/src/main/res/drawable/edit_text_background.xml b/android/src/main/res/drawable/edit_text_background.xml new file mode 100644 index 0000000..d34c97f --- /dev/null +++ b/android/src/main/res/drawable/edit_text_background.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/src/main/res/drawable/header_background.xml b/android/src/main/res/drawable/header_background.xml new file mode 100644 index 0000000..7335a39 --- /dev/null +++ b/android/src/main/res/drawable/header_background.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_close.xml b/android/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..a5182e6 --- /dev/null +++ b/android/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/src/main/res/drawable/ic_send.xml b/android/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..565382b --- /dev/null +++ b/android/src/main/res/drawable/ic_send.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/src/main/res/layout/dialog_bug_report.xml b/android/src/main/res/layout/dialog_bug_report.xml new file mode 100644 index 0000000..16ed60b --- /dev/null +++ b/android/src/main/res/layout/dialog_bug_report.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..5b678f5 --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + Toadly + Need help? + Email + Description + Report Type + Submit + Cancel + diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml new file mode 100644 index 0000000..1c7fc36 --- /dev/null +++ b/android/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + +