Skip to content

Commit cde0818

Browse files
kaibolaylfkellogg
andauthored
Implement notification trigger (#4159)
* Implement notification trigger. * address Lee's feedback * pick proper icon Co-authored-by: Lee Kellogg <[email protected]>
1 parent b427fe2 commit cde0818

File tree

7 files changed

+257
-19
lines changed

7 files changed

+257
-19
lines changed

firebase-appdistribution/test-app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
package="com.googletest.firebase.appdistribution.testapp">
44
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
5-
5+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
66
<application
77
android:allowBackup="true"
88
android:icon="@mipmap/ic_launcher"
@@ -14,11 +14,10 @@
1414
<activity android:name="com.googletest.firebase.appdistribution.testapp.MainActivity" android:exported="true">
1515
<intent-filter>
1616
<action android:name="android.intent.action.MAIN" />
17-
1817
<category android:name="android.intent.category.LAUNCHER" />
1918
</intent-filter>
2019
</activity>
21-
<activity android:name="com.googletest.firebase.appdistribution.testapp.SecondActivity"></activity>
20+
<activity android:name="com.googletest.firebase.appdistribution.testapp.SecondActivity" />
21+
<activity android:name="com.googletest.firebase.appdistribution.testapp.TakeScreenshotAndTriggerFeedbackActivity" />
2222
</application>
23-
24-
</manifest>
23+
</manifest>

firebase-appdistribution/test-app/src/main/java/com/googletest/firebase/appdistribution/testapp/AppDistroTestApplication.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ class AppDistroTestApplication : Application() {
88

99
// Perform any required trigger initialization here
1010
ScreenshotDetectionFeedbackTrigger.initialize(this, R.string.terms_and_conditions);
11+
NotificationFeedbackTrigger.initialize(this);
1112

1213
// Default feedback triggers can optionally be enabled application-wide here
1314
// ShakeForFeedback.enable(this)
1415
// ScreenshotDetectionFeedbackTrigger.enable()
16+
// NotificationFeedbackTrigger.enable()
1517
}
1618
}

firebase-appdistribution/test-app/src/main/java/com/googletest/firebase/appdistribution/testapp/MainActivity.kt

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,9 @@ import java.util.concurrent.ExecutorService
2626
import java.util.concurrent.Executors
2727

2828
class MainActivity : AppCompatActivity() {
29-
30-
var firebaseAppDistribution = Firebase.appDistribution
31-
var updateTask: Task<Void>? = null
32-
var release: AppDistributionRelease? = null
29+
val firebaseAppDistribution = Firebase.appDistribution
3330
val executorService: ExecutorService = Executors.newFixedThreadPool(1)
31+
3432
lateinit var signInButton: AppCompatButton
3533
lateinit var signOutButton: AppCompatButton
3634
lateinit var checkForUpdateButton: AppCompatButton
@@ -47,6 +45,9 @@ class MainActivity : AppCompatActivity() {
4745
lateinit var progressBar: ProgressBar
4846
lateinit var feedbackTriggerMenu: TextInputLayout
4947

48+
var updateTask: Task<Void>? = null
49+
var release: AppDistributionRelease? = null
50+
5051
override fun onCreate(savedInstanceState: Bundle?) {
5152
super.onCreate(savedInstanceState)
5253
setContentView(R.layout.activity_main)
@@ -71,14 +72,15 @@ class MainActivity : AppCompatActivity() {
7172
val items = listOf(
7273
FeedbackTrigger.NONE.label,
7374
FeedbackTrigger.SHAKE.label,
74-
FeedbackTrigger.SCREENSHOT.label
75+
FeedbackTrigger.SCREENSHOT.label,
76+
FeedbackTrigger.NOTIFICATION.label,
7577
)
7678
val adapter = ArrayAdapter(this, R.layout.list_item, items)
7779
val autoCompleteTextView = feedbackTriggerMenu.editText!! as AutoCompleteTextView
7880
autoCompleteTextView.setAdapter(adapter)
7981
// TODO: set it to the actual currently enabled trigger
8082
autoCompleteTextView.setText(FeedbackTrigger.NONE.label, false)
81-
autoCompleteTextView.doOnTextChanged { text, start, before, count ->
83+
autoCompleteTextView.doOnTextChanged { text, _, _, _ ->
8284
when(text.toString()) {
8385
FeedbackTrigger.NONE.label -> {
8486
disableAllFeedbackTriggers()
@@ -93,6 +95,11 @@ class MainActivity : AppCompatActivity() {
9395
Log.i(TAG, "Enabling screenshot detection trigger")
9496
ScreenshotDetectionFeedbackTrigger.enable()
9597
}
98+
FeedbackTrigger.NOTIFICATION.label -> {
99+
disableAllFeedbackTriggers()
100+
Log.i(TAG, "Enabling notification trigger")
101+
NotificationFeedbackTrigger.enable(this)
102+
}
96103
}
97104
}
98105
}
@@ -101,6 +108,7 @@ class MainActivity : AppCompatActivity() {
101108
Log.i(TAG, "Disabling all feedback triggers")
102109
ShakeForFeedback.disable(application)
103110
ScreenshotDetectionFeedbackTrigger.disable()
111+
NotificationFeedbackTrigger.disable();
104112
}
105113

106114
override fun onCreateOptionsMenu(menu: Menu): Boolean {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package com.googletest.firebase.appdistribution.testapp
2+
3+
import android.Manifest.permission.POST_NOTIFICATIONS
4+
import android.annotation.SuppressLint
5+
import android.app.*
6+
import android.content.Context
7+
import android.content.Intent
8+
import android.content.pm.PackageManager.PERMISSION_GRANTED
9+
import android.graphics.Bitmap
10+
import android.graphics.Canvas
11+
import android.net.Uri
12+
import android.os.Build
13+
import android.os.Bundle
14+
import android.util.Log
15+
import androidx.activity.result.ActivityResultCaller
16+
import androidx.activity.result.ActivityResultLauncher
17+
import androidx.activity.result.contract.ActivityResultContracts
18+
import androidx.appcompat.app.AppCompatActivity
19+
import androidx.core.app.NotificationCompat
20+
import androidx.core.app.NotificationManagerCompat
21+
import androidx.core.content.ContextCompat
22+
import com.google.firebase.appdistribution.ktx.appDistribution
23+
import com.google.firebase.ktx.Firebase
24+
import com.googletest.firebase.appdistribution.testapp.NotificationFeedbackTrigger.SCREENSHOT_FILE_NAME
25+
import com.googletest.firebase.appdistribution.testapp.NotificationFeedbackTrigger.takeScreenshot
26+
import java.io.IOException
27+
28+
@SuppressLint("StaticFieldLeak") // Reference to Activity is set to null in onActivityDestroyed
29+
object NotificationFeedbackTrigger : Application.ActivityLifecycleCallbacks {
30+
private const val TAG: String = "NotificationFeedbackTrigger"
31+
private const val FEEBACK_NOTIFICATION_CHANNEL_ID = "InAppFeedbackNotification"
32+
private const val FEEDBACK_NOTIFICATION_ID = 1
33+
const val SCREENSHOT_FILE_NAME =
34+
"com.googletest.firebase.appdistribution.testapp.screenshot.png"
35+
36+
private var isEnabled = false
37+
private var hasRequestedPermission = false
38+
private var currentActivity: Activity? = null
39+
private var requestPermissionLauncher: ActivityResultLauncher<String>? = null
40+
41+
fun initialize(application: Application) {
42+
// Create the NotificationChannel, but only on API 26+ because
43+
// the NotificationChannel class is new and not in the support library
44+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
45+
val channel = NotificationChannel(
46+
FEEBACK_NOTIFICATION_CHANNEL_ID,
47+
application.getString(R.string.feedback_notification_channel_name),
48+
NotificationManager.IMPORTANCE_HIGH
49+
)
50+
channel.description =
51+
application.getString(R.string.feedback_notification_channel_description)
52+
application.getSystemService(NotificationManager::class.java)
53+
.createNotificationChannel(channel)
54+
}
55+
application.registerActivityLifecycleCallbacks(this)
56+
}
57+
58+
fun enable(currentActivity: Activity? = null) {
59+
this.currentActivity = currentActivity
60+
isEnabled = true
61+
if (currentActivity != null) {
62+
showNotification(currentActivity)
63+
}
64+
}
65+
66+
fun disable() {
67+
isEnabled = false
68+
val activity = currentActivity
69+
currentActivity = null
70+
if (activity != null) {
71+
cancelNotification(activity)
72+
}
73+
}
74+
75+
private fun showNotification(activity: Activity) {
76+
if (ContextCompat.checkSelfPermission(activity, POST_NOTIFICATIONS) == PERMISSION_GRANTED) {
77+
val intent = Intent(activity, TakeScreenshotAndTriggerFeedbackActivity::class.java)
78+
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
79+
val pendingIntent = PendingIntent.getActivity(
80+
activity, /* requestCode= */ 0, intent, PendingIntent.FLAG_IMMUTABLE
81+
)
82+
val builder = NotificationCompat.Builder(activity, FEEBACK_NOTIFICATION_CHANNEL_ID)
83+
.setSmallIcon(R.mipmap.ic_launcher)
84+
.setContentTitle(activity.getText(R.string.feedback_notification_title))
85+
.setContentText(activity.getText(R.string.feedback_notification_text))
86+
.setPriority(NotificationCompat.PRIORITY_HIGH)
87+
.setContentIntent(pendingIntent)
88+
val notificationManager = NotificationManagerCompat.from(activity)
89+
Log.i(TAG, "Showing notification")
90+
notificationManager.notify(FEEDBACK_NOTIFICATION_ID, builder.build())
91+
} else {
92+
// no permission to post notifications - try to get it
93+
if (hasRequestedPermission) {
94+
Log.i(
95+
TAG,
96+
"We've already request permission. Not requesting again for the life of the activity."
97+
)
98+
} else {
99+
requestPermission(activity)
100+
}
101+
}
102+
}
103+
104+
private fun requestPermission(activity: Activity) {
105+
var launcher = requestPermissionLauncher
106+
if (launcher == null) {
107+
Log.i(TAG, "Not requesting permission, because of inability to register for result.")
108+
} else {
109+
if (activity.shouldShowRequestPermissionRationale(POST_NOTIFICATIONS)) {
110+
Log.i(TAG, "Showing customer rationale for requesting permission.")
111+
AlertDialog.Builder(activity)
112+
.setMessage(
113+
"Using a notification to initiate feedback to the developer. "
114+
+ "To enable this feature, allow the app to post notifications."
115+
)
116+
.setPositiveButton("OK") { _, _ ->
117+
Log.i(TAG, "Launching request for permission.")
118+
launcher.launch(POST_NOTIFICATIONS)
119+
}
120+
.show()
121+
} else {
122+
Log.i(TAG, "Launching request for permission without rationale.")
123+
launcher.launch(POST_NOTIFICATIONS)
124+
}
125+
hasRequestedPermission = true
126+
}
127+
}
128+
129+
private fun cancelNotification(context: Context) {
130+
val notificationManager = NotificationManagerCompat.from(context)
131+
Log.i(TAG, "Cancelling notification")
132+
notificationManager.cancel(FEEDBACK_NOTIFICATION_ID)
133+
}
134+
135+
override fun onActivityResumed(activity: Activity) {
136+
if (isEnabled) {
137+
if (activity !is TakeScreenshotAndTriggerFeedbackActivity) {
138+
Log.d(TAG, "setting current activity")
139+
currentActivity = activity
140+
}
141+
showNotification(activity)
142+
}
143+
}
144+
145+
override fun onActivityPaused(activity: Activity) {
146+
requestPermissionLauncher = null
147+
cancelNotification(activity)
148+
}
149+
150+
override fun onActivityDestroyed(activity: Activity) {
151+
Log.d(TAG, "clearing current activity")
152+
currentActivity = null
153+
}
154+
155+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
156+
if (activity is ActivityResultCaller && !hasRequestedPermission) {
157+
requestPermissionLauncher =
158+
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
159+
if (!isEnabled) {
160+
Log.w(
161+
TAG,
162+
"Trigger disabled after permission check. Abandoning notification."
163+
)
164+
} else if (isGranted) {
165+
showNotification(activity)
166+
} else {
167+
Log.i(TAG, "Permission not granted")
168+
// TODO: Ideally we would show a message indicating the impact of not
169+
// enabling the permission, but there's no way to know if they've
170+
// permanently denied the permission, and we don't want to show them a
171+
// message after each time we try to post a notification.
172+
}
173+
}
174+
} else {
175+
Log.w(
176+
TAG,
177+
"Not showing notification because this activity can't register for permission request results: $activity"
178+
)
179+
}
180+
}
181+
182+
// Other lifecycle methods
183+
override fun onActivityStarted(activity: Activity) {}
184+
override fun onActivityStopped(activity: Activity) {}
185+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
186+
187+
fun takeScreenshot() {
188+
val activity = currentActivity
189+
if (activity != null) {
190+
val view = activity.window.decorView.rootView
191+
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.RGB_565)
192+
val canvas = Canvas(bitmap)
193+
view.draw(canvas)
194+
try {
195+
activity.openFileOutput(SCREENSHOT_FILE_NAME, Context.MODE_PRIVATE)
196+
.use { outputStream ->
197+
bitmap.compress(
198+
Bitmap.CompressFormat.PNG, /* quality = */ 100, outputStream
199+
)
200+
}
201+
Log.i(TAG, "Wrote screenshot to $SCREENSHOT_FILE_NAME")
202+
} catch (e: IOException) {
203+
Log.e(TAG, "Can't write $SCREENSHOT_FILE_NAME", e)
204+
}
205+
} else {
206+
Log.e(TAG, "Can't take screenshot because current activity is unknown")
207+
return
208+
}
209+
}
210+
}
211+
212+
class TakeScreenshotAndTriggerFeedbackActivity : AppCompatActivity() {
213+
override fun onCreate(savedInstanceState: Bundle?) {
214+
super.onCreate(savedInstanceState)
215+
takeScreenshot()
216+
}
217+
218+
override fun onResume() {
219+
super.onResume()
220+
val screenshotUri = Uri.fromFile(getFileStreamPath(SCREENSHOT_FILE_NAME))
221+
Firebase.appDistribution.startFeedback(R.string.terms_and_conditions, screenshotUri)
222+
}
223+
}

firebase-appdistribution/test-app/src/main/java/com/googletest/firebase/appdistribution/testapp/ScreenshotDetectionFeedbackTrigger.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import androidx.activity.result.ActivityResultLauncher
2121
import androidx.activity.result.contract.ActivityResultContracts
2222
import androidx.annotation.RequiresApi
2323
import androidx.core.content.ContextCompat
24-
import com.google.firebase.appdistribution.FirebaseAppDistribution
24+
import com.google.firebase.appdistribution.ktx.appDistribution
25+
import com.google.firebase.ktx.Firebase
2526
import java.util.*
27+
import kotlin.collections.HashSet
2628

2729
class ScreenshotDetectionFeedbackTrigger
2830
private constructor(private val infoTextResourceId: Int, handler: Handler) :
@@ -125,7 +127,7 @@ private constructor(private val infoTextResourceId: Int, handler: Handler) :
125127
val path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
126128
Log.i(TAG, "Path: $path")
127129
if (path.lowercase(Locale.getDefault()).contains("screenshot")) {
128-
FirebaseAppDistribution.getInstance().startFeedback(infoTextResourceId, uri)
130+
Firebase.appDistribution.startFeedback(infoTextResourceId, uri)
129131
}
130132
}
131133
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<resources>
22
<string name="app_name">App Distro Sample</string>
3-
<string name="terms_and_conditions"><b>Before giving feedback</b> you might want to check out the <a href="http://google.com">Terms and Conditions</a></string>
3+
<string name="terms_and_conditions"><b>Before giving feedback</b> you might want to check out the <a href="https://policies.google.com/terms">Terms and Conditions</a></string>
44
<string name="feedbackTriggerMenuLabel">Choose a feedback trigger</string>
55
<string name="startFeedbackLabel">Start feedback</string>
6+
<string name="feedback_notification_title">We want your Feedback!</string>
7+
<string name="feedback_notification_text">Click to leave feedback for the App Distro Sample App</string>
8+
<string name="feedback_notification_channel_name">Feedback Notification</string>
9+
<string name="feedback_notification_channel_description">Used to trigger Feedback</string>
610
</resources>

firebase-appdistribution/test-app/test-app.gradle

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ android {
2525
applicationId "com.googletest.firebase.appdistribution.testapp"
2626
minSdkVersion 23
2727
targetSdkVersion 33
28-
versionName "1.0"
29-
versionCode 1
28+
versionName "2.0"
29+
versionCode 2
3030

3131
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
3232
}
@@ -72,15 +72,15 @@ dependencies {
7272

7373
// Other dependencies
7474
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
75-
implementation "androidx.activity:activity-ktx:1.5.1"
76-
implementation "androidx.fragment:fragment-ktx:1.5.2"
75+
implementation "androidx.activity:activity-ktx:1.6.0"
76+
implementation "androidx.fragment:fragment-ktx:1.5.3"
7777
implementation 'androidx.core:core-ktx:1.9.0'
7878
implementation "androidx.core:core:1.9.0"
7979
implementation 'androidx.appcompat:appcompat:1.5.1'
8080
implementation 'com.google.android.material:material:1.6.1'
8181
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
8282

83-
testImplementation 'junit:junit:4.+'
83+
testImplementation 'junit:junit:4.13.2'
8484

8585
// Shake detection
8686
implementation 'com.squareup:seismic:1.0.3'

0 commit comments

Comments
 (0)