diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index bd0279263..ae05b6769 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -13,7 +13,7 @@ object LibsVersion { const val JUNIT = "4.13.2" const val ASM = "9.2" const val SQLITE = "2.1.0" - const val SENTRY = "5.1.2" + const val SENTRY = "5.5.0" } object Libs { diff --git a/examples/android-room/src/main/AndroidManifest.xml b/examples/android-room/src/main/AndroidManifest.xml index 2a76f2318..e68bd17bf 100644 --- a/examples/android-room/src/main/AndroidManifest.xml +++ b/examples/android-room/src/main/AndroidManifest.xml @@ -16,6 +16,10 @@ android:name=".ui.EditActivity" android:theme="@style/Theme.AppCompat.NoActionBar" /> + + diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/SampleApp.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/SampleApp.kt index 27420ca7f..e49451e85 100644 --- a/examples/android-room/src/main/java/io/sentry/android/roomsample/SampleApp.kt +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/SampleApp.kt @@ -1,14 +1,25 @@ package io.sentry.android.roomsample import android.app.Application +import android.content.Context +import android.content.SharedPreferences import androidx.room.Room import io.sentry.android.roomsample.data.TracksDatabase +import io.sentry.android.roomsample.util.DEFAULT_LYRICS +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch class SampleApp : Application() { companion object { lateinit var database: TracksDatabase private set + + lateinit var analytics: SharedPreferences + private set } override fun onCreate() { @@ -17,5 +28,23 @@ class SampleApp : Application() { .createFromAsset("tracks.db") .fallbackToDestructiveMigration() .build() + + analytics = getSharedPreferences("analytics", Context.MODE_PRIVATE) + + GlobalScope.launch(Dispatchers.IO) { + database.tracksDao().all() + .collect { tracks -> + tracks.forEachIndexed { index, track -> + // add lyrics for every 2nd track + if (index % 2 == 0) { + val dir = File("$filesDir${File.separatorChar}lyrics") + dir.mkdirs() + + val file = File(dir, "${track.id}.txt") + file.writeText(DEFAULT_LYRICS) + } + } + } + } } } diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/EditActivity.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/EditActivity.kt index 4e1983df7..70c86930b 100644 --- a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/EditActivity.kt +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/EditActivity.kt @@ -56,8 +56,14 @@ class EditActivity : ComponentActivity() { } else { if (originalTrack == null) { addNewTrack(name, composer, duration.toLong(), unitPrice.toFloat()) + + val createCount = SampleApp.analytics.getInt("create_count", 0) + 1 + SampleApp.analytics.edit().putInt("create_count", createCount).apply() } else { originalTrack.update(name, composer, duration.toLong(), unitPrice.toFloat()) + + val editCount = SampleApp.analytics.getInt("edit_count", 0) + 1 + SampleApp.analytics.edit().putInt("edit_count", editCount).apply() } transaction.finish(SpanStatus.OK) finish() diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/LyricsActivity.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/LyricsActivity.kt new file mode 100644 index 000000000..f2f2ce82a --- /dev/null +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/LyricsActivity.kt @@ -0,0 +1,62 @@ +package io.sentry.android.roomsample.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.widget.EditText +import androidx.activity.ComponentActivity +import androidx.appcompat.widget.Toolbar +import io.sentry.Sentry +import io.sentry.SpanStatus +import io.sentry.android.roomsample.R +import io.sentry.android.roomsample.data.Track +import java.io.File + +@SuppressLint("SetTextI18n") +class LyricsActivity : ComponentActivity() { + private lateinit var file: File + private lateinit var lyricsInput: EditText + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_lyrics) + + val transaction = Sentry.startTransaction( + "Track Interaction", + "ui.action.lyrics", + true + ) + + lyricsInput = findViewById(R.id.lyrics) + val toolbar = findViewById(R.id.toolbar) + + val track: Track = intent.getSerializableExtra(TRACK_EXTRA_KEY) as Track + toolbar.title = "Lyrics for ${track.name}" + + val dir = File("$filesDir${File.separatorChar}lyrics") + dir.mkdirs() + + file = File(dir, "${track.id}.txt") + if (file.exists()) { + lyricsInput.setText(file.readText()) + } + transaction.finish(SpanStatus.OK) + } + + override fun onBackPressed() { + val transaction = Sentry.getSpan() ?: Sentry.startTransaction( + "Track Interaction", + "ui.action.lyrics_finish", + true + ) + if (!file.exists()) { + file.createNewFile() + } + file.writeText(lyricsInput.text.toString()) + transaction.finish(SpanStatus.OK) + super.onBackPressed() + } + + companion object { + const val TRACK_EXTRA_KEY = "LyricsActivity.Track" + } +} diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/MainActivity.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/MainActivity.kt index f489d1a1d..7f852baaa 100644 --- a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/MainActivity.kt +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/MainActivity.kt @@ -1,15 +1,7 @@ package io.sentry.android.roomsample.ui -import android.annotation.SuppressLint -import android.content.Context import android.content.Intent import android.os.Bundle -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.TextView import androidx.activity.ComponentActivity import androidx.appcompat.widget.Toolbar import androidx.lifecycle.lifecycleScope @@ -19,9 +11,8 @@ import io.sentry.Sentry import io.sentry.SpanStatus import io.sentry.android.roomsample.R import io.sentry.android.roomsample.SampleApp -import io.sentry.android.roomsample.data.Track +import io.sentry.android.roomsample.ui.list.TrackAdapter import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.runBlocking class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -55,77 +46,3 @@ class MainActivity : ComponentActivity() { } } } - -class TrackRow( - context: Context, - attrs: AttributeSet -) : LinearLayout(context, attrs) { - - val deleteButton: View get() = findViewById(R.id.delete_track) - val editButton: View get() = findViewById(R.id.edit_track) - - @SuppressLint("SetTextI18n") - fun populate(track: Track) { - val mins = (track.millis / 1000) / 60 - val secs = (track.millis / 1000) % 60 - - findViewById(R.id.track_name).text = track.name - findViewById(R.id.track_duration).text = "${mins}m ${secs}s" - findViewById(R.id.band_name).text = track.composer - } -} - -class TrackAdapter : RecyclerView.Adapter() { - - private var data: List = listOf() - - fun populate(data: List) { - this.data = data - notifyDataSetChanged() - } - - override fun getItemCount() = data.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder( - LayoutInflater.from(parent.context).inflate( - R.layout.track_row, - parent, - false - ) as TrackRow - ) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.row.populate(data[position]) - holder.row.deleteButton.setOnClickListener { - val transaction = Sentry.startTransaction( - "Track Interaction", - "ui.action.delete", - true - ) - runBlocking { - SampleApp.database.tracksDao().delete(data[holder.bindingAdapterPosition]) - } - transaction.finish(SpanStatus.OK) - } - holder.row.editButton.setOnClickListener { - val context = holder.row.context - val track = data[holder.bindingAdapterPosition] - context.startActivity( - Intent( - context, - EditActivity::class.java - ).putExtra(EditActivity.TRACK_EXTRA_KEY, track) - ) - } - } - - override fun onViewRecycled(holder: ViewHolder) { - super.onViewRecycled(holder) - holder.row.deleteButton.setOnClickListener(null) - holder.row.editButton.setOnClickListener(null) - } - - inner class ViewHolder(val row: TrackRow) : RecyclerView.ViewHolder(row) -} diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/list/TrackAdapter.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/list/TrackAdapter.kt new file mode 100644 index 000000000..59d44fcb7 --- /dev/null +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/list/TrackAdapter.kt @@ -0,0 +1,81 @@ +package io.sentry.android.roomsample.ui.list + +import android.content.Intent +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.sentry.Sentry +import io.sentry.SpanStatus +import io.sentry.android.roomsample.R +import io.sentry.android.roomsample.SampleApp +import io.sentry.android.roomsample.data.Track +import io.sentry.android.roomsample.ui.EditActivity +import io.sentry.android.roomsample.ui.LyricsActivity +import kotlinx.coroutines.runBlocking + +class TrackAdapter : RecyclerView.Adapter() { + + private var data: List = listOf() + + fun populate(data: List) { + this.data = data + notifyDataSetChanged() + } + + override fun getItemCount() = data.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + LayoutInflater.from(parent.context).inflate( + R.layout.track_row, + parent, + false + ) as TrackRow + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.row.populate(data[position]) + holder.row.deleteButton.setOnClickListener { + val transaction = Sentry.startTransaction( + "Track Interaction", + "ui.action.delete", + true + ) + runBlocking { + SampleApp.database.tracksDao().delete(data[holder.bindingAdapterPosition]) + val deleteCount = SampleApp.analytics.getInt("delete_count", 0) + 1 + SampleApp.analytics.edit().putInt("delete_count", deleteCount).apply() + } + transaction.finish(SpanStatus.OK) + } + holder.row.editButton.setOnClickListener { + val context = holder.row.context + val track = data[holder.bindingAdapterPosition] + context.startActivity( + Intent( + context, + EditActivity::class.java + ).putExtra(EditActivity.TRACK_EXTRA_KEY, track) + ) + } + holder.row.infoButton.setOnClickListener { + val context = holder.row.context + val track = data[holder.bindingAdapterPosition] + context.startActivity( + Intent( + context, + LyricsActivity::class.java + ).putExtra(LyricsActivity.TRACK_EXTRA_KEY, track) + ) + } + } + + override fun onViewRecycled(holder: ViewHolder) { + super.onViewRecycled(holder) + holder.row.deleteButton.setOnClickListener(null) + holder.row.editButton.setOnClickListener(null) + } + + inner class ViewHolder(val row: TrackRow) : RecyclerView.ViewHolder(row) +} diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/list/TrackRow.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/list/TrackRow.kt new file mode 100644 index 000000000..bc6aab38f --- /dev/null +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/ui/list/TrackRow.kt @@ -0,0 +1,30 @@ +package io.sentry.android.roomsample.ui.list + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import io.sentry.android.roomsample.R +import io.sentry.android.roomsample.data.Track + +class TrackRow( + context: Context, + attrs: AttributeSet +) : LinearLayout(context, attrs) { + + val deleteButton: View get() = findViewById(R.id.delete_track) + val editButton: View get() = findViewById(R.id.edit_track) + val infoButton: View get() = findViewById(R.id.track_info) + + @SuppressLint("SetTextI18n") + fun populate(track: Track) { + val mins = (track.millis / 1000) / 60 + val secs = (track.millis / 1000) % 60 + + findViewById(R.id.track_name).text = track.name + findViewById(R.id.track_duration).text = "${mins}m ${secs}s" + findViewById(R.id.band_name).text = track.composer + } +} diff --git a/examples/android-room/src/main/java/io/sentry/android/roomsample/util/Lyrics.kt b/examples/android-room/src/main/java/io/sentry/android/roomsample/util/Lyrics.kt new file mode 100644 index 000000000..616e2c716 --- /dev/null +++ b/examples/android-room/src/main/java/io/sentry/android/roomsample/util/Lyrics.kt @@ -0,0 +1,14 @@ +package io.sentry.android.roomsample.util + +val DEFAULT_LYRICS = + """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi dapibus elit vel commodo varius. Suspendisse eget tempus est. Pellentesque egestas mi vitae massa ultrices vulputate. Aenean tempor nec sem eu congue. Phasellus mollis tellus odio, ut tincidunt arcu ullamcorper at. Nunc quis lorem vel risus auctor ultricies. Quisque ornare congue sagittis. Donec sit amet arcu vitae mi sodales porta vitae et mi. Nullam eu viverra urna, quis elementum risus. In at accumsan justo. Nam hendrerit, lorem ac lacinia semper, diam dolor pulvinar turpis, non tincidunt dolor lacus non quam. Etiam tempus, dui ut rutrum ornare, quam eros feugiat nisi, et blandit nisi lacus nec neque. Mauris aliquam id tellus vel molestie. Quisque ultricies ante nec lacus condimentum, at aliquet diam volutpat. + +Vivamus fermentum eu ante non aliquam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum interdum semper orci, et auctor enim pharetra vel. Ut porttitor neque in blandit scelerisque. Cras fermentum urna sit amet metus tincidunt, eu porttitor nisi mattis. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Mauris pharetra leo vitae turpis commodo, sit amet cursus nunc varius. Etiam bibendum interdum dolor, a scelerisque massa mattis eu. Suspendisse consequat tortor ac tincidunt vulputate. + +Maecenas non lectus sit amet dui porta dapibus. Etiam vitae velit nibh. Morbi purus urna, cursus convallis feugiat vel, vulputate eget turpis. Vivamus et tincidunt lectus. Nullam id risus est. Sed ullamcorper pellentesque massa, dignissim ultricies urna maximus ac. Vivamus maximus eu ex quis ultricies. Donec facilisis diam arcu, id cursus sapien condimentum ac. Aenean suscipit, nibh ac tempus pharetra, ligula augue bibendum arcu, sed fringilla magna nisl vel felis. + +Cras lacinia efficitur elit, ac sagittis nulla commodo eu. Aliquam a est at augue imperdiet eleifend. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas luctus est non vulputate porta. Phasellus eros libero, auctor id justo ac, mollis eleifend enim. Suspendisse pulvinar mi lorem, a maximus massa tincidunt ac. Duis mattis nibh a mauris laoreet laoreet. Donec malesuada quis dui feugiat ultricies. Ut at sem consectetur, tincidunt massa in, bibendum metus. + +Sed fermentum eros ac odio venenatis, id varius est mollis. Fusce sollicitudin tellus risus, vel accumsan ante auctor in. Phasellus vel blandit massa. Suspendisse potenti. Donec vitae elementum enim, quis dictum mauris. Nullam consectetur enim at turpis bibendum, consequat facilisis sem ultrices. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eget nisl id libero tempus fringilla vitae eget urna. Etiam dui neque, vestibulum a mi vulputate, placerat interdum sem. Mauris sit amet aliquam felis. Suspendisse potenti. Nullam odio purus, ultricies in tincidunt sit amet, interdum ac orci. Nulla volutpat auctor velit, sed malesuada urna auctor sed. Suspendisse non sollicitudin tortor. Vivamus vulputate lacinia nisi, pellentesque sagittis sapien. + """.trimIndent() diff --git a/examples/android-room/src/main/res/layout/activity_lyrics.xml b/examples/android-room/src/main/res/layout/activity_lyrics.xml new file mode 100644 index 000000000..bb74f57e3 --- /dev/null +++ b/examples/android-room/src/main/res/layout/activity_lyrics.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/examples/android-room/src/main/res/layout/track_row.xml b/examples/android-room/src/main/res/layout/track_row.xml index c0bd4541c..03af189dd 100644 --- a/examples/android-room/src/main/res/layout/track_row.xml +++ b/examples/android-room/src/main/res/layout/track_row.xml @@ -1,5 +1,5 @@ - + + @@ -46,4 +54,4 @@ android:layout_height="24dp" android:src="@android:drawable/ic_menu_delete" tools:ignore="ContentDescription" /> - + diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/Instrumentable.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/Instrumentable.kt index 1dcbbf073..7cb2dd9c1 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/Instrumentable.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/Instrumentable.kt @@ -13,7 +13,7 @@ interface Instrumentable { * Class: androidx.sqlite.db.framework.FrameworkSQLiteDatabase * Method: query */ - val fqName: String + val fqName: String get() = "" /** * Provides a visitor for this instrumentable. A visitor can be one of the visitors defined @@ -31,12 +31,6 @@ interface Instrumentable { parameters: SpanAddingClassVisitorFactory.SpanAddingParameters ): Visitor - /** - * Provides children instrumentables that are going to be used when visiting the current - * class/method/field/etc. - */ - val children: List> get() = emptyList() - /** * Defines whether this object is instrumentable or not based on [data] */ diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt index af2b4b7f6..69a0c6976 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/SpanAddingClassVisitorFactory.kt @@ -8,6 +8,7 @@ import io.sentry.android.gradle.SentryPlugin import io.sentry.android.gradle.instrumentation.androidx.room.AndroidXRoomDao import io.sentry.android.gradle.instrumentation.androidx.sqlite.database.AndroidXSQLiteDatabase import io.sentry.android.gradle.instrumentation.androidx.sqlite.statement.AndroidXSQLiteStatement +import io.sentry.android.gradle.instrumentation.wrap.WrappingInstrumentable import io.sentry.android.gradle.util.warn import java.io.File import org.gradle.api.provider.Property @@ -42,7 +43,8 @@ abstract class SpanAddingClassVisitorFactory : private val instrumentables: List = listOf( AndroidXSQLiteDatabase(), AndroidXSQLiteStatement(), - AndroidXRoomDao() + AndroidXRoomDao(), + WrappingInstrumentable() ) } diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/database/AndroidXSQLiteDatabase.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/database/AndroidXSQLiteDatabase.kt index 667e511f7..808e79e83 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/database/AndroidXSQLiteDatabase.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/database/AndroidXSQLiteDatabase.kt @@ -25,14 +25,9 @@ class AndroidXSQLiteDatabase : ClassInstrumentable { apiVersion = apiVersion, classVisitor = originalVisitor, className = fqName.substringAfterLast('.'), - methodInstrumentables = children, + methodInstrumentables = listOf(Query(), ExecSql()), parameters = parameters ) - - override val children: List = listOf( - Query(), - ExecSql() - ) } class Query : MethodInstrumentable { diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/statement/AndroidXSQLiteStatement.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/statement/AndroidXSQLiteStatement.kt index 4f9f39a46..0869bd670 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/statement/AndroidXSQLiteStatement.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/androidx/sqlite/statement/AndroidXSQLiteStatement.kt @@ -26,14 +26,9 @@ class AndroidXSQLiteStatement : ClassInstrumentable { apiVersion = apiVersion, classVisitor = originalVisitor, className = fqName.substringAfterLast('.'), - methodInstrumentables = children, + methodInstrumentables = listOf(ExecuteInsert(), ExecuteUpdateDelete()), parameters = parameters ) - - override val children: List = listOf( - ExecuteInsert(), - ExecuteUpdateDelete() - ) } class ExecuteInsert : MethodInstrumentable { diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/Replacements.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/Replacements.kt new file mode 100644 index 000000000..23fc41183 --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/Replacements.kt @@ -0,0 +1,115 @@ +// ktlint-disable filename +package io.sentry.android.gradle.instrumentation.wrap + +data class Replacement( + val owner: String, + val name: String, + val descriptor: String +) { + object FileInputStream { + val STRING = Replacement( + "java/io/FileInputStream", + "", + "(Ljava/lang/String;)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileInputStream${'$'}Factory", + "create", + "(Ljava/io/FileInputStream;Ljava/lang/String;)Ljava/io/FileInputStream;" + ) + val FILE = Replacement( + "java/io/FileInputStream", + "", + "(Ljava/io/File;)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileInputStream${'$'}Factory", + "create", + "(Ljava/io/FileInputStream;Ljava/io/File;)Ljava/io/FileInputStream;" + ) + val FILE_DESCRIPTOR = + Replacement( + "java/io/FileInputStream", + "", + "(Ljava/io/FileDescriptor;)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileInputStream${'$'}Factory", + "create", + "(Ljava/io/FileInputStream;Ljava/io/FileDescriptor;)Ljava/io/FileInputStream;" + ) + } + + object FileOutputStream { + val STRING = + Replacement( + "java/io/FileOutputStream", + "", + "(Ljava/lang/String;)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileOutputStream${'$'}Factory", + "create", + "(Ljava/io/FileOutputStream;Ljava/lang/String;)Ljava/io/FileOutputStream;" + ) + val STRING_BOOLEAN = + Replacement( + "java/io/FileOutputStream", + "", + "(Ljava/lang/String;Z)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileOutputStream${'$'}Factory", + "create", + "(Ljava/io/FileOutputStream;Ljava/lang/String;Z)Ljava/io/FileOutputStream;" + ) + val FILE = Replacement( + "java/io/FileOutputStream", + "", + "(Ljava/io/File;)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileOutputStream${'$'}Factory", + "create", + "(Ljava/io/FileOutputStream;Ljava/io/File;)Ljava/io/FileOutputStream;" + ) + val FILE_BOOLEAN = + Replacement( + "java/io/FileOutputStream", + "", + "(Ljava/io/File;Z)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileOutputStream${'$'}Factory", + "create", + "(Ljava/io/FileOutputStream;Ljava/io/File;Z)Ljava/io/FileOutputStream;" + ) + val FILE_DESCRIPTOR = + Replacement( + "java/io/FileOutputStream", + "", + "(Ljava/io/FileDescriptor;)V" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileOutputStream${'$'}Factory", + "create", + "(Ljava/io/FileOutputStream;Ljava/io/FileDescriptor;)Ljava/io/FileOutputStream;" + ) + } + + object Context { + val OPEN_FILE_INPUT = + Replacement( + "", + "openFileInput", + "(Ljava/lang/String;)Ljava/io/FileInputStream;" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileInputStream${'$'}Factory", + "create", + "(Ljava/io/FileInputStream;Ljava/lang/String;)Ljava/io/FileInputStream;" + ) + + val OPEN_FILE_OUTPUT = + Replacement( + "", + "openFileOutput", + "(Ljava/lang/String;I)Ljava/io/FileOutputStream;" + ) to Replacement( + "io/sentry/instrumentation/file/SentryFileOutputStream${'$'}Factory", + "create", + "(Ljava/io/FileOutputStream;Ljava/lang/String;)Ljava/io/FileOutputStream;" + ) + } +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/WrappingInstrumentable.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/WrappingInstrumentable.kt new file mode 100644 index 000000000..1cbfd566b --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/WrappingInstrumentable.kt @@ -0,0 +1,81 @@ +@file:Suppress("UnstableApiUsage") + +package io.sentry.android.gradle.instrumentation.wrap + +import com.android.build.api.instrumentation.ClassContext +import com.android.build.api.instrumentation.ClassData +import io.sentry.android.gradle.instrumentation.ClassInstrumentable +import io.sentry.android.gradle.instrumentation.CommonClassVisitor +import io.sentry.android.gradle.instrumentation.MethodContext +import io.sentry.android.gradle.instrumentation.MethodInstrumentable +import io.sentry.android.gradle.instrumentation.SpanAddingClassVisitorFactory +import io.sentry.android.gradle.instrumentation.wrap.visitor.WrappingVisitor +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.MethodVisitor + +class WrappingInstrumentable : ClassInstrumentable { + + override fun getVisitor( + instrumentableContext: ClassContext, + apiVersion: Int, + originalVisitor: ClassVisitor, + parameters: SpanAddingClassVisitorFactory.SpanAddingParameters + ): ClassVisitor { + val simpleClassName = + instrumentableContext.currentClassData.className.substringAfterLast('.') + return CommonClassVisitor( + apiVersion = apiVersion, + classVisitor = originalVisitor, + className = simpleClassName, + methodInstrumentables = listOf(Wrap(instrumentableContext.currentClassData)), + parameters = parameters + ) + } + + override fun isInstrumentable(data: ClassContext): Boolean { + return when { + data.currentClassData.className.startsWith("io.sentry") && + !data.currentClassData.className.startsWith("io.sentry.android.roomsample") -> false + else -> true + } + } +} + +class Wrap(private val classContext: ClassData) : MethodInstrumentable { + + companion object { + private val replacements = mapOf( + // FileInputStream to SentryFileInputStream + Replacement.FileInputStream.STRING, + Replacement.FileInputStream.FILE, + Replacement.FileInputStream.FILE_DESCRIPTOR, + // FileOutputStream to SentryFileOutputStream + Replacement.FileOutputStream.STRING, + Replacement.FileOutputStream.STRING_BOOLEAN, + Replacement.FileOutputStream.FILE, + Replacement.FileOutputStream.FILE_BOOLEAN, + Replacement.FileOutputStream.FILE_DESCRIPTOR + // TODO: enable, once https://github.com/getsentry/sentry-java/issues/1842 is resolved + // Context.openFileInput to SentryFileInputStream +// Replacement.Context.OPEN_FILE_INPUT, + // Context.openFileOutput to SentryFileOutputStream +// Replacement.Context.OPEN_FILE_OUTPUT + ) + } + + override fun getVisitor( + instrumentableContext: MethodContext, + apiVersion: Int, + originalVisitor: MethodVisitor, + parameters: SpanAddingClassVisitorFactory.SpanAddingParameters + ): MethodVisitor = + WrappingVisitor( + api = apiVersion, + originalVisitor = originalVisitor, + classContext = classContext, + context = instrumentableContext, + replacements = replacements + ) + + override fun isInstrumentable(data: MethodContext): Boolean = true +} diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/visitor/WrappingVisitor.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/visitor/WrappingVisitor.kt new file mode 100644 index 000000000..f6085437b --- /dev/null +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/instrumentation/wrap/visitor/WrappingVisitor.kt @@ -0,0 +1,140 @@ +@file:Suppress("UnstableApiUsage") + +package io.sentry.android.gradle.instrumentation.wrap.visitor + +import com.android.build.api.instrumentation.ClassData +import io.sentry.android.gradle.SentryPlugin +import io.sentry.android.gradle.instrumentation.MethodContext +import io.sentry.android.gradle.instrumentation.wrap.Replacement +import io.sentry.android.gradle.util.info +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.commons.GeneratorAdapter +import org.objectweb.asm.commons.Method +import org.slf4j.Logger + +class WrappingVisitor( + api: Int, + originalVisitor: MethodVisitor, + private val classContext: ClassData, + private val context: MethodContext, + private val replacements: Map, + private val logger: Logger = SentryPlugin.logger +) : GeneratorAdapter( + api, + originalVisitor, + context.access, + context.name, + context.descriptor +) { + + private val className = classContext.className.replace('.', '/') + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + val methodSig = Replacement(owner, name, descriptor) + val replacement = if (methodSig in replacements) { + replacements[methodSig] + } else { + // try to look up for a replacement without owner (as the owner sometimes can differ) + replacements[methodSig.copy(owner = "")] + } + when { + opcode == Opcodes.INVOKEDYNAMIC -> { + // we don't instrument invokedynamic, because it's just forwarding to a synthetic method + // which will be instrumented thanks to condition below + logger.info { + "INVOKEDYNAMIC skipped from instrumentation for" + + " ${className.prettyPrintClassName()}.${context.name}" + } + super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) + } + replacement != null -> { + val isSuperCallInOverride = opcode == Opcodes.INVOKESPECIAL && + owner != className && + name == context.name && + descriptor == context.descriptor + + val isSuperCallInCtor = opcode == Opcodes.INVOKESPECIAL && + name == "" && + classContext.superClasses.firstOrNull()?.fqName() == owner + + when { + isSuperCallInOverride -> { + // this will be instrumented on the calling side of the overriding class + logger.info { + "${className.prettyPrintClassName()} skipped from instrumentation " + + "in overridden method $name.$descriptor" + } + super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) + } + isSuperCallInCtor -> { + // this has to be manually instrumented (e.g. by inheriting our runtime classes) + logger.info { + "${className.prettyPrintClassName()} skipped from instrumentation " + + "in constructor $name.$descriptor" + } + super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) + } + else -> { + logger.info { + "Wrapping $owner.$name with ${replacement.owner}.${replacement.name} " + + "in ${className.prettyPrintClassName()}.${context.name}" + } + visitWrapping(replacement, opcode, owner, name, descriptor, isInterface) + } + } + } + else -> super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) + } + } + + private fun String.prettyPrintClassName() = replace('/', '.') + + private fun String.fqName() = replace('.', '/') + + private fun GeneratorAdapter.visitWrapping( + replacement: Replacement, + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + // create a new method to figure out the number of arguments + val originalMethod = Method(name, descriptor) + + // replicate arguments on stack, so we can later re-use them for our wrapping + val locals = IntArray(originalMethod.argumentTypes.size) + for (i in locals.size - 1 downTo 0) { + locals[i] = newLocal(originalMethod.argumentTypes[i]) + storeLocal(locals[i]) + } + + // load arguments from stack for the original method call + locals.forEach { + loadLocal(it) + } + super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) + + // load arguments from stack for the wrapping method call + // only load as many as the new method requires (replacement method may have less arguments) + val newMethod = Method(replacement.name, replacement.descriptor) + for (i in 0 until newMethod.argumentTypes.size - 1) { + loadLocal(locals[i]) + } + // call wrapping (it's always a static method) + super.visitMethodInsn( + Opcodes.INVOKESTATIC, + replacement.owner, + replacement.name, + replacement.descriptor, + false + ) + } +} diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/fakes/CapturingTestLogger.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/fakes/CapturingTestLogger.kt index 8239d185b..f0abd0aa6 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/fakes/CapturingTestLogger.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/fakes/CapturingTestLogger.kt @@ -15,4 +15,9 @@ class CapturingTestLogger : BaseTestLogger() { capturedMessage = msg capturedThrowable = throwable } + + override fun info(msg: String, throwable: Throwable?) { + capturedMessage = msg + capturedThrowable = throwable + } } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/wrap/visitor/WrappingVisitorTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/wrap/visitor/WrappingVisitorTest.kt new file mode 100644 index 000000000..76d0b7e26 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/instrumentation/wrap/visitor/WrappingVisitorTest.kt @@ -0,0 +1,249 @@ +package io.sentry.android.gradle.instrumentation.wrap.visitor + +import com.android.build.api.instrumentation.ClassData +import io.sentry.android.gradle.instrumentation.MethodContext +import io.sentry.android.gradle.instrumentation.fakes.CapturingTestLogger +import io.sentry.android.gradle.instrumentation.fakes.TestClassData +import io.sentry.android.gradle.instrumentation.wrap.Replacement +import kotlin.test.assertEquals +import org.junit.Test +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes + +class WrappingVisitorTest { + + class Fixture { + val logger = CapturingTestLogger() + val visitor = CapturingMethodVisitor() + + fun getSut( + methodContext: MethodContext, + classContext: ClassData = TestClassData("io/sentry/RandomClass"), + replacements: Map = mapOf() + ) = WrappingVisitor( + Opcodes.ASM9, + visitor, + classContext, + methodContext, + replacements, + logger + ) + } + + private val fixture = Fixture() + + @Test + fun `invokedynamic is skipped from instrumentation`() { + val context = MethodContext(Opcodes.ACC_PUBLIC, "test", "()V", null, null) + val methodVisit = MethodVisit( + opcode = Opcodes.INVOKEDYNAMIC, + owner = "java/io/FileInputStream", + name = "", + descriptor = "(Ljava/lang/String;)V", + isInterface = false + ) + fixture.getSut(context).visitMethodInsn( + opcode = methodVisit.opcode, + owner = methodVisit.owner, + name = methodVisit.name, + descriptor = methodVisit.descriptor, + isInterface = methodVisit.isInterface + ) + + assertEquals( + fixture.logger.capturedMessage, + "[sentry] INVOKEDYNAMIC skipped from instrumentation for io.sentry.RandomClass.test" + ) + // method visit should remain unchanged + assertEquals(fixture.visitor.methodVisits.size, 1) + assertEquals(fixture.visitor.methodVisits.first(), methodVisit) + } + + @Test + fun `when no replacements found does not modify method visit`() { + val context = MethodContext(Opcodes.ACC_PUBLIC, "test", "()V", null, null) + val methodVisit = MethodVisit( + opcode = Opcodes.INVOKEVIRTUAL, + owner = "java/io/FileInputStream", + name = "", + descriptor = "(Ljava/lang/String;)V", + isInterface = false + ) + fixture.getSut(context).visitMethodInsn( + opcode = methodVisit.opcode, + owner = methodVisit.owner, + name = methodVisit.name, + descriptor = methodVisit.descriptor, + isInterface = methodVisit.isInterface + ) + + // method visit should remain unchanged + assertEquals(fixture.visitor.methodVisits.size, 1) + assertEquals(fixture.visitor.methodVisits.first(), methodVisit) + } + + @Test + fun `when replacement found and super call in override does not modify method visit`() { + val context = MethodContext( + Opcodes.ACC_PUBLIC, + "openFileInput", + "(Ljava/lang/String;)Ljava/io/FileInputStream;", + null, + null + ) + val methodVisit = MethodVisit( + opcode = Opcodes.INVOKESPECIAL, + owner = "android/content/Context", + name = "openFileInput", + descriptor = "(Ljava/lang/String;)Ljava/io/FileInputStream;", + isInterface = false + ) + fixture.getSut( + context, + classContext = TestClassData("io/sentry/android/sample/LyricsActivity"), + replacements = mapOf(Replacement.Context.OPEN_FILE_INPUT) + ).visitMethodInsn( + opcode = methodVisit.opcode, + owner = methodVisit.owner, + name = methodVisit.name, + descriptor = methodVisit.descriptor, + isInterface = methodVisit.isInterface + ) + + assertEquals( + fixture.logger.capturedMessage, + "[sentry] io.sentry.android.sample.LyricsActivity skipped from instrumentation " + + "in overridden method openFileInput.(Ljava/lang/String;)Ljava/io/FileInputStream;" + ) + // method visit should remain unchanged + assertEquals(fixture.visitor.methodVisits.size, 1) + assertEquals(fixture.visitor.methodVisits.first(), methodVisit) + } + + @Test + fun `when replacement found and super call in constructor does not modify method visit`() { + val context = MethodContext( + Opcodes.ACC_PUBLIC, + "", + "()V", + null, + null + ) + val methodVisit = MethodVisit( + opcode = Opcodes.INVOKESPECIAL, + owner = "java/io/FileInputStream", + name = "", + descriptor = "(Ljava/lang/String;)V", + isInterface = false + ) + fixture.getSut( + context, + classContext = TestClassData( + "io/sentry/CustomFileInputStream", + superClasses = listOf("java/io/FileInputStream") + ), + replacements = mapOf(Replacement.FileInputStream.STRING) + ).visitMethodInsn( + opcode = methodVisit.opcode, + owner = methodVisit.owner, + name = methodVisit.name, + descriptor = methodVisit.descriptor, + isInterface = methodVisit.isInterface + ) + + assertEquals( + fixture.logger.capturedMessage, + "[sentry] io.sentry.CustomFileInputStream skipped from instrumentation in " + + "constructor .(Ljava/lang/String;)V" + ) + // method visit should remain unchanged + assertEquals(fixture.visitor.methodVisits.size, 1) + assertEquals(fixture.visitor.methodVisits.first(), methodVisit) + } + + @Test + fun `when replacement found modifies method visit`() { + val context = MethodContext( + Opcodes.ACC_PUBLIC, + "test", + "()V", + null, + null + ) + val methodVisit = MethodVisit( + opcode = Opcodes.INVOKESPECIAL, + owner = "java/io/FileInputStream", + name = "", + descriptor = "(Ljava/lang/String;)V", + isInterface = false + ) + /* ktlint-disable experimental:argument-list-wrapping */ + fixture.getSut(context, replacements = mapOf(Replacement.FileInputStream.STRING)) + .visitMethodInsn( + methodVisit.opcode, + methodVisit.owner, + methodVisit.name, + methodVisit.descriptor, + methodVisit.isInterface + ) + /* ktlint-enable experimental:argument-list-wrapping */ + + assertEquals(fixture.visitor.methodVisits.size, 2) + // store original arguments + assertEquals(fixture.visitor.varVisits[0], VarVisit(Opcodes.ASTORE, 1)) + // load original argument for the original method visit + assertEquals(fixture.visitor.varVisits[1], VarVisit(Opcodes.ALOAD, 1)) + // original method is visited unchanged + assertEquals(fixture.visitor.methodVisits[0], methodVisit) + + // load original argument for the replacement/wrapping method visit + // the target object that we are wrapping will be taken from stack + assertEquals(fixture.visitor.varVisits[2], VarVisit(Opcodes.ALOAD, 1)) + // replacement/wrapping method visited + assertEquals( + fixture.visitor.methodVisits[1], + MethodVisit( + Opcodes.INVOKESTATIC, + "io/sentry/instrumentation/file/SentryFileInputStream${'$'}Factory", + "create", + "(Ljava/io/FileInputStream;Ljava/lang/String;)Ljava/io/FileInputStream;", + isInterface = false + ) + ) + } +} + +data class MethodVisit( + val opcode: Int, + val owner: String, + val name: String, + val descriptor: String, + val isInterface: Boolean +) + +data class VarVisit( + val opcode: Int, + val variable: Int +) + +class CapturingMethodVisitor : MethodVisitor(Opcodes.ASM9) { + + val methodVisits = mutableListOf() + val varVisits = mutableListOf() + + override fun visitVarInsn(opcode: Int, variable: Int) { + super.visitVarInsn(opcode, variable) + varVisits += VarVisit(opcode, variable) + } + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) + methodVisits += MethodVisit(opcode, owner, name, descriptor, isInterface) + } +} diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/fileIO/SQLiteCopyOpenHelper.class b/plugin-build/src/test/resources/testFixtures/instrumentation/fileIO/SQLiteCopyOpenHelper.class new file mode 100644 index 000000000..bd0059646 Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/fileIO/SQLiteCopyOpenHelper.class differ diff --git a/plugin-build/src/test/resources/testFixtures/instrumentation/fileIO/TypefaceCompatUtil.class b/plugin-build/src/test/resources/testFixtures/instrumentation/fileIO/TypefaceCompatUtil.class new file mode 100644 index 000000000..58e3b5b53 Binary files /dev/null and b/plugin-build/src/test/resources/testFixtures/instrumentation/fileIO/TypefaceCompatUtil.class differ