Skip to content

Commit 0b3d22e

Browse files
committed
Add Duck.ai widget updater and enhance search widget configuration.
1 parent b2f0770 commit 0b3d22e

File tree

11 files changed

+185
-9
lines changed

11 files changed

+185
-9
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,14 @@
538538
</intent-filter>
539539
</receiver>
540540

541+
<receiver
542+
android:name="com.duckduckgo.widget.DuckAiSearchWidgetUpdaterReceiver"
543+
android:exported="false">
544+
<intent-filter>
545+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
546+
</intent-filter>
547+
</receiver>
548+
541549
<receiver
542550
android:name=".remotemessage.SharePromoLinkRMFBroadCastReceiver"
543551
android:exported="false" />
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.widget
18+
19+
import android.appwidget.AppWidgetManager
20+
import android.content.BroadcastReceiver
21+
import android.content.Context
22+
import android.content.Intent
23+
import com.duckduckgo.anvil.annotations.InjectWith
24+
import com.duckduckgo.di.scopes.ReceiverScope
25+
import dagger.android.AndroidInjection
26+
import javax.inject.Inject
27+
28+
@InjectWith(ReceiverScope::class)
29+
class DuckAiSearchWidgetUpdaterReceiver : BroadcastReceiver() {
30+
31+
@Inject
32+
lateinit var widgetUpdater: WidgetUpdater
33+
34+
override fun onReceive(
35+
context: Context,
36+
intent: Intent,
37+
) {
38+
AndroidInjection.inject(this, context)
39+
if (intent.action == AppWidgetManager.ACTION_APPWIDGET_UPDATE) {
40+
widgetUpdater.updateWidgets(context)
41+
}
42+
}
43+
}

app/src/main/java/com/duckduckgo/widget/SearchWidget.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ import com.duckduckgo.app.systemsearch.SystemSearchActivity
3333
import javax.inject.Inject
3434
import kotlinx.coroutines.CoroutineScope
3535
import kotlinx.coroutines.launch
36+
import logcat.logcat
3637

3738
class SearchWidgetLight : SearchWidget(R.layout.search_widget_light)
3839

3940
open class SearchWidget(val layoutId: Int = R.layout.search_widget_dark) : AppWidgetProvider() {
4041

4142
@Inject
42-
lateinit var voiceSearchWidgetConfigurator: VoiceSearchWidgetConfigurator
43+
lateinit var searchWidgetConfigurator: SearchWidgetConfigurator
4344

4445
@Inject
4546
lateinit var searchWidgetLifecycleDelegate: SearchWidgetLifecycleDelegate
@@ -73,8 +74,10 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget_dark) : AppWi
7374
appWidgetManager: AppWidgetManager,
7475
appWidgetIds: IntArray,
7576
) {
77+
logcat { "SearchWidget onUpdate" }
7678
// There may be multiple widgets active, so update all of them
7779
for (appWidgetId in appWidgetIds) {
80+
logcat { "SearchWidget onUpdate called for widget id = $appWidgetId" }
7881
updateAppWidget(context, appWidgetManager, appWidgetId, null)
7982
}
8083
}
@@ -85,6 +88,7 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget_dark) : AppWi
8588
appWidgetId: Int,
8689
newOptions: Bundle?,
8790
) {
91+
logcat { "SearchWidget onAppWidgetOptionsChanged called for widget id = $appWidgetId" }
8892
updateAppWidget(context, appWidgetManager, appWidgetId, newOptions)
8993
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
9094
}
@@ -96,6 +100,7 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget_dark) : AppWi
96100
newOptions: Bundle?,
97101
) {
98102
val appWidgetOptions = appWidgetManager.getAppWidgetOptions(appWidgetId)
103+
logcat { "SearchWidget updateAppWidget called for widget id = $appWidgetId" }
99104
var portraitWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
100105

101106
if (newOptions != null) {
@@ -108,7 +113,7 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget_dark) : AppWi
108113
views.setViewVisibility(R.id.searchInputBox, if (shouldShowHint) View.VISIBLE else View.INVISIBLE)
109114
views.setOnClickPendingIntent(R.id.widgetContainer, buildPendingIntent(context))
110115

111-
voiceSearchWidgetConfigurator.configureVoiceSearch(context, views, false)
116+
searchWidgetConfigurator.configureWidget(context, views, false)
112117
appWidgetManager.updateAppWidget(appWidgetId, views)
113118
}
114119

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.widget
18+
19+
import android.app.PendingIntent
20+
import android.content.Context
21+
import android.view.View
22+
import android.widget.RemoteViews
23+
import com.duckduckgo.app.browser.R
24+
import com.duckduckgo.app.di.AppCoroutineScope
25+
import com.duckduckgo.app.systemsearch.SystemSearchActivity
26+
import com.duckduckgo.duckchat.api.DuckChat
27+
import com.duckduckgo.voice.api.VoiceSearchAvailability
28+
import javax.inject.Inject
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.runBlocking
31+
import logcat.logcat
32+
33+
class SearchWidgetConfigurator @Inject constructor(
34+
private val voiceSearchAvailability: VoiceSearchAvailability,
35+
private val duckChat: DuckChat,
36+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
37+
) {
38+
39+
fun configureWidget(
40+
context: Context,
41+
remoteViews: RemoteViews,
42+
fromFavWidget: Boolean,
43+
) {
44+
// TODO ANA: Improve this method to avoid blocking the main thread.
45+
runBlocking {
46+
val voiceSearchEnabled = voiceSearchAvailability.isVoiceSearchAvailable
47+
val duckAiEnabled = duckChat.isEnabled() && duckChat.isDuckChatUserEnabled() && duckChat.wasOpenedBefore()
48+
49+
logcat { "SearchWidgetConfigurator voiceSearchEnabled=$voiceSearchEnabled, duckAiEnabled=$duckAiEnabled" }
50+
remoteViews.setViewVisibility(R.id.voiceSearch, if (voiceSearchEnabled) View.VISIBLE else View.GONE)
51+
remoteViews.setViewVisibility(R.id.duckAi, if (duckAiEnabled) View.VISIBLE else View.GONE)
52+
remoteViews.setViewVisibility(R.id.separator, if (voiceSearchEnabled && duckAiEnabled) View.VISIBLE else View.GONE)
53+
remoteViews.setViewVisibility(R.id.search, if (!voiceSearchEnabled && !duckAiEnabled) View.VISIBLE else View.GONE)
54+
55+
if (voiceSearchEnabled) {
56+
val pendingIntent = buildVoiceSearchPendingIntent(context, fromFavWidget)
57+
remoteViews.setOnClickPendingIntent(R.id.voiceSearch, pendingIntent)
58+
}
59+
60+
if (duckAiEnabled) {
61+
val pendingIntent = buildDuckAiPendingIntent(context)
62+
pendingIntent?.let { remoteViews.setOnClickPendingIntent(R.id.duckAi, it) }
63+
}
64+
}
65+
}
66+
67+
private fun buildVoiceSearchPendingIntent(
68+
context: Context,
69+
fromFavWidget: Boolean,
70+
): PendingIntent {
71+
val intent = if (fromFavWidget) SystemSearchActivity.fromFavWidget(context, true) else SystemSearchActivity.fromWidget(context, true)
72+
return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
73+
}
74+
75+
private fun buildDuckAiPendingIntent(
76+
context: Context,
77+
): PendingIntent? {
78+
val intent = duckChat.openDuckChatIntent()
79+
if (intent == null) {
80+
return null
81+
}
82+
return PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
83+
}
84+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="2dp"
3+
android:height="20dp"
4+
android:viewportWidth="2"
5+
android:viewportHeight="20">
6+
<path
7+
android:pathData="M0.5,0.5C0.5,0.224 0.724,0 1,0C1.276,0 1.5,0.224 1.5,0.5V19.5C1.5,19.776 1.276,20 1,20C0.724,20 0.5,19.776 0.5,19.5V0.5Z"
8+
android:fillColor="#ffffff"
9+
android:fillAlpha="0.12"/>
10+
</vector>

app/src/main/res/layout/search_widget_dark.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
<ImageView
4949
android:id="@+id/separator"
5050
style="@style/Widget.DuckDuckGo.SearchWidgetSearchIcon"
51-
android:src="@drawable/ic_vertical_separator"
51+
android:src="@drawable/ic_vertical_separator_dark"
5252
android:visibility="gone"
5353
tools:ignore="ContentDescription" />
5454

app/src/main/res/layout/search_widget_light.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
<ImageView
4949
android:id="@+id/separator"
5050
style="@style/Widget.DuckDuckGo.SearchWidgetSearchIcon"
51-
android:src="@drawable/ic_vertical_separator"
51+
android:src="@drawable/ic_vertical_separator_light"
5252
android:visibility="gone"
5353
tools:ignore="ContentDescription" />
5454

duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.duckduckgo.duckchat.api
1818

19+
import android.content.Intent
1920
import android.net.Uri
2021

2122
/**
@@ -35,6 +36,13 @@ interface DuckChat {
3536
*/
3637
fun openDuckChat()
3738

39+
/**
40+
* Opens the DuckChat WebView with an intent.
41+
*
42+
* @return Intent to open DuckChat.
43+
*/
44+
fun openDuckChatIntent(): Intent?
45+
3846
/**
3947
* Auto-prompts the DuckChat WebView with the provided [String] query.
4048
*/
@@ -56,4 +64,9 @@ interface DuckChat {
5664
* Returns `true` if Duck Chat was ever opened before.
5765
*/
5866
suspend fun wasOpenedBefore(): Boolean
67+
68+
/**
69+
* Returns whether DuckChat is user enabled or not.
70+
*/
71+
fun isDuckChatUserEnabled(): Boolean
5972
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,6 @@ interface DuckChatInternal : DuckChat {
130130
*/
131131
fun isAddressBarEntryPointEnabled(): Boolean
132132

133-
/**
134-
* Returns whether DuckChat is user enabled or not.
135-
*/
136-
fun isDuckChatUserEnabled(): Boolean
137-
138133
/**
139134
* Updates the current chat state.
140135
*/
@@ -391,6 +386,16 @@ class RealDuckChat @Inject constructor(
391386
openDuckChat(emptyMap())
392387
}
393388

389+
override fun openDuckChatIntent(): Intent? {
390+
logcat { "Duck.ai: openDuckChatIntent" }
391+
val parameters = addChatParameters("", autoPrompt = false)
392+
val url = appendParameters(parameters, duckChatLink)
393+
394+
return browserNav.openDuckChat(context, duckChatUrl = url, hasSessionActive = true)
395+
.apply {
396+
flags = Intent.FLAG_ACTIVITY_NEW_TASK
397+
}
398+
}
394399
override fun openDuckChatWithAutoPrompt(query: String) {
395400
logcat { "Duck.ai: openDuckChatWithAutoPrompt query $query" }
396401
val parameters = addChatParameters(query, autoPrompt = true)

0 commit comments

Comments
 (0)