implemented foreground service for timer alerts

This commit is contained in:
vishnuraghavb 2021-05-26 21:14:50 +05:30
parent dbd08f2eb3
commit e3d690e42d
7 changed files with 189 additions and 140 deletions

View file

@ -1,51 +1,39 @@
var ForegroundService = /** @class */ (function(_super) { import { TimerNotif } from './shared/utils'
__extends(ForegroundService, _super) const superProto = android.app.Service.prototype
function ForegroundService() { android.app.Service.extend('com.tns.ForegroundService', {
return (_super !== null && _super.apply(this, arguments)) || this onStartCommand: function(intent, flags, startId) {
}
ForegroundService.prototype.onStartCommand = function(
intent,
flags,
startId
) {
console.log('onStartCommand') console.log('onStartCommand')
_super.prototype.onStartCommand.call(this, intent, flags, startId) superProto.onStartCommand.call(this, intent, flags, startId)
return android.app.Service.START_STICKY return android.app.Service.START_STICKY
} },
ForegroundService.prototype.onCreate = function() { onCreate: function() {
console.log('onCreate') console.log('onCreate')
_super.prototype.onCreate.call(this) superProto.onCreate.call(this)
this.startForeground(1, this.getNotification()) this.startForeground(777, this.getNotification())
} },
ForegroundService.prototype.onBind = function(intent) { onBind: function(intent) {
return _super.prototype.onBind.call(this, intent) return superProto.onBind.call(this, intent)
} },
ForegroundService.prototype.onUnbind = function(intent) { onUnbind: function(intent) {
return _super.prototype.onUnbind.call(this, intent) return superProto.onUnbind.call(this, intent)
} },
ForegroundService.prototype.onDestroy = function() { onDestroy: function() {
console.log('onDestroy') console.log('onDestroy')
this.stopForeground(true) this.stopForeground(true)
} },
ForegroundService.prototype.getNotification = function() { getNotification: function() {
var channel = new android.app.NotificationChannel( return TimerNotif.getNotification(
'channel_01', {
'ForegroundService Channel', bID: 'bringToFront',
android.app.NotificationManager.IMPORTANCE_DEFAULT cID: 'cti',
cName: 'Cooking Timer info',
description: `0 ongoing, 0 paused`,
nID: 777,
priority: -2,
sound: null,
title: 'EnRecipes is running',
},
this.getApplicationContext()
) )
var notificationManager = this.getSystemService( },
android.content.Context.NOTIFICATION_SERVICE })
)
notificationManager.createNotificationChannel(channel)
var builder = new android.app.Notification.Builder(
this.getApplicationContext(),
'channel_01'
)
return builder.build()
}
ForegroundService = __decorate(
[JavaProxy('com.tns.ForegroundService')],
ForegroundService
)
return ForegroundService
})(android.app.Service)

View file

@ -19,7 +19,6 @@
<Timer <Timer
v-for="(timer, i) in activeTimers" v-for="(timer, i) in activeTimers"
:key="timer.id" :key="timer.id"
ref="singleTimer"
:timer="timer" :timer="timer"
:timerIndex="i" :timerIndex="i"
:formattedTime="formattedTime" :formattedTime="formattedTime"
@ -62,21 +61,23 @@ import {
ApplicationSettings, ApplicationSettings,
AndroidApplication, AndroidApplication,
Utils, Utils,
Device,
} from "@nativescript/core"; } from "@nativescript/core";
import { mapState, mapActions } from "vuex"; import { mapState, mapActions } from "vuex";
import Action from "./modals/Action.vue"; import Action from "./modals/Action.vue";
import CookingTimer from "./CookingTimer.vue";
import CTSettings from "./settings/CTSettings.vue"; import CTSettings from "./settings/CTSettings.vue";
import TimePickerHMS from "./modals/TimePickerHMS.vue"; import TimePickerHMS from "./modals/TimePickerHMS.vue";
import TimerReminder from "./modals/TimerReminder.vue";
import Timer from "./sub/Timer.vue"; import Timer from "./sub/Timer.vue";
import Toast from "./sub/Toast.vue"; import Toast from "./sub/Toast.vue";
import SnackBar from "./sub/SnackBar.vue"; import SnackBar from "./sub/SnackBar.vue";
import * as utils from "~/shared/utils"; import * as utils from "~/shared/utils";
// import { fgs } from "~/foreground.android";
import { EventBus } from "~/main"; import { EventBus } from "~/main";
let undoTimer; let undoTimer,
firingTimers = [];
declare const com: any;
export default { export default {
components: { Timer, Toast, SnackBar }, components: { Timer, Toast, SnackBar },
@ -177,33 +178,12 @@ export default {
cID: "cti", cID: "cti",
cName: "Cooking Timer info", cName: "Cooking Timer info",
description: `${ongoingCount} ongoing, ${pausedCount} paused`, description: `${ongoingCount} ongoing, ${pausedCount} paused`,
nID: 999, nID: 777,
priority: -2, priority: -2,
sound: null, sound: null,
title: localize("timer"), title: localize("timer"),
}); });
if (activeCount <= 0) utils.TimerNotif.clear(999); if (activeCount <= 0) this.foregroundService(false);
},
intentListener({ intent, android }) {
let ct = "CookingTimer";
let action = (intent || android).getStringExtra("action");
console.log("calling: ", action);
let comp = this.currentComponent;
if (action == "open_timer" && this.currentComponent != ct) {
let openTimer = setInterval(() => {
if (comp == ct) clearInterval(openTimer);
else {
if (comp == "CTSettings") this.$navigateBack();
else this.$navigateTo(CookingTimer);
comp = ct;
}
Application.off(Application.launchEvent, this.intentListener);
Application.android.off(
AndroidApplication.activityNewIntentEvent,
this.intentListener
);
}, 250);
}
}, },
fireTimer(timer) { fireTimer(timer) {
console.log("firing"); console.log("firing");
@ -230,18 +210,39 @@ export default {
console.log(action, "firing"); console.log(action, "firing");
EventBus.$emit(bID, action); EventBus.$emit(bID, action);
}); });
firingTimers.push(timer);
// if (firingTimers.length == 1) {
// this.$showModal(TimerReminder, {
// fullscreen: true,
// props: {
// timers: firingTimers,
// stop: this.stopFiringTimers,
// formattedTime: this.formattedTime,
// },
// });
// }
}, },
startForegroundService() { stopFiringTimers() {
firingTimers.forEach((e) => utils.TimerNotif.clear(e.id));
firingTimers = [];
},
openReminder() {},
foregroundService(bool) {
const ctx = Utils.ad.getApplicationContext(); const ctx = Utils.ad.getApplicationContext();
const intent = new android.content.Intent(); const intent = new android.content.Intent(
intent.setClassName(ctx, "com.tns.ForegroundService"); ctx,
ctx.startService(intent); com.tns.ForegroundService.class
);
if (bool)
parseInt(Device.sdkVersion) < 26
? ctx.startService(intent)
: ctx.startForegroundService(intent);
else ctx.stopService(intent);
}, },
// DATA HANDLERS // DATA HANDLERS
addTimer() { addTimer() {
// fgs.class this.foregroundService(true);
this.startForegroundService();
this.$showModal(TimePickerHMS, { this.$showModal(TimePickerHMS, {
props: { props: {
title: "ntmr", title: "ntmr",
@ -300,6 +301,7 @@ export default {
if (!noUndo) { if (!noUndo) {
this.showUndoBar("tmrClr") this.showUndoBar("tmrClr")
.then(() => { .then(() => {
this.foregroundService(true);
this.addActiveTimer({ this.addActiveTimer({
timer: temp, timer: temp,
index, index,
@ -385,11 +387,6 @@ export default {
}, },
}, },
created() { created() {
Application.on(Application.launchEvent, this.intentListener);
Application.android.on(
AndroidApplication.activityNewIntentEvent,
this.intentListener
);
this.clearTimerInterval(); this.clearTimerInterval();
}, },
}; };

View file

@ -4,9 +4,9 @@
columns="auto, *, auto, auto, auto" columns="auto, *, auto, auto, auto"
class="singleTimer" class="singleTimer"
> >
<!-- :class="{ blink: done }" -->
<Button <Button
class="ico" class="ico"
:class="{ blink: done }"
:text="done ? icon.ring : timer.isPaused ? icon.start : icon.pause" :text="done ? icon.ring : timer.isPaused ? icon.start : icon.pause"
@tap="!done && toggleProgress()" @tap="!done && toggleProgress()"
/> />

View file

@ -12,22 +12,38 @@ const keepScreenOn = () => {
console.log('keepScreenOn') console.log('keepScreenOn')
const window = Application.android.startActivity.getWindow() const window = Application.android.startActivity.getWindow()
const windowMgr = android.view.WindowManager const windowMgr = android.view.WindowManager
window.addFlags( const flags =
windowMgr.LayoutParams.FLAG_SHOW_WHEN_LOCKED | windowMgr.LayoutParams.FLAG_SHOW_WHEN_LOCKED |
windowMgr.LayoutParams.FLAG_TURN_SCREEN_ON | windowMgr.LayoutParams.FLAG_TURN_SCREEN_ON |
windowMgr.LayoutParams.FLAG_KEEP_SCREEN_ON windowMgr.LayoutParams.FLAG_KEEP_SCREEN_ON
window.addFlags(flags)
function clearFlags(args) {
args.cancel = true
window.clearFlags(flags)
Application.android.off(
AndroidApplication.activityBackPressedEvent,
clearFlags
)
}
Application.android.on(
AndroidApplication.activityBackPressedEvent,
clearFlags
) )
} }
} }
Application.on(Application.resumeEvent, keepScreenOn) Application.on(Application.resumeEvent, keepScreenOn)
Application.on(Application.launchEvent, ({ android }) => { Application.on(Application.launchEvent, (args) => {
console.log('launching') console.log('launching')
if (android) androidLaunchEventLocalizationHandler() if (args.android) {
androidLaunchEventLocalizationHandler()
intentListener(args)
}
}) })
import Vue from 'nativescript-vue' import Vue from 'nativescript-vue'
import EnRecipes from './components/EnRecipes.vue' import EnRecipes from './components/EnRecipes.vue'
import CookingTimer from './components/CookingTimer.vue'
import store from './store' import store from './store'
export const EventBus = new Vue() export const EventBus = new Vue()
@ -46,3 +62,35 @@ new Vue({
store, store,
render: (h) => h(EnRecipes), render: (h) => h(EnRecipes),
}).$start() }).$start()
const intentListener = ({ intent, android }: any) => {
let ct = 'CookingTimer'
let action = (intent || android).getStringExtra('action')
console.log('calling: ', action)
if (action == 'open_timer' && store.state.currentComponent != ct) {
let openTimer = setInterval(() => {
let comp = store.state.currentComponent
if (comp == ct) clearInterval(openTimer)
else {
if (comp == 'CTSettings') Vue.navigateBack()
else {
Vue.navigateTo(CookingTimer)
store.commit('setComponent', 'CookingTimer')
}
}
}, 250)
}
}
Application.on(Application.launchEvent, () => {
Application.android.on(
AndroidApplication.activityNewIntentEvent,
intentListener
)
})
Application.on(Application.exitEvent, () => {
store.commit('setComponent', 'EnRecipes')
Application.android.off(
AndroidApplication.activityNewIntentEvent,
intentListener
)
})

View file

@ -3,8 +3,8 @@
<supports-screens android:smallScreens="true" android:normalScreens="true" android:largeScreens="true" android:xlargeScreens="true" /> <supports-screens android:smallScreens="true" android:normalScreens="true" android:largeScreens="true" android:xlargeScreens="true" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:targetApi="28" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" tools:targetApi="29" />
<uses-permission android:name="android.permission.CAMERA" tools:node="remove" /> <uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />

View file

@ -6,6 +6,7 @@ import {
Color, Color,
path, path,
knownFolders, knownFolders,
TimerInfo,
} from '@nativescript/core' } from '@nativescript/core'
let timerOne let timerOne
declare const global, android, androidx, com, java, Array: any declare const global, android, androidx, com, java, Array: any
@ -318,39 +319,45 @@ export class TimerNotif {
const NotifySrv = ctx.getSystemService( const NotifySrv = ctx.getSystemService(
android.content.Context.NOTIFICATION_SERVICE android.content.Context.NOTIFICATION_SERVICE
) )
// nID ? NotifySrv.cancel(nID) : NotifySrv.cancelAll()
NotifySrv.cancel(nID) NotifySrv.cancel(nID)
} }
static show({
actions, static getNotification(
bID, {
cID, actions,
cName, bID,
description, cID,
nID, cName,
priority, description,
sound, nID,
title, priority,
vibrate, sound,
}: { title,
actions?: boolean vibrate,
bID: string }: {
cID: string actions?: boolean
cName: string bID: string
description: string cID: string
nID: number cName: string
priority: number description: string
sound: string nID: number
title: string priority: number
vibrate?: number sound: string
}) { title: string
vibrate?: number
},
ctx
) {
let sdkv: number = parseInt(Device.sdkVersion) let sdkv: number = parseInt(Device.sdkVersion)
let soundUri: any let soundUri: any
if (sound) soundUri = new android.net.Uri.parse(sound) if (sound) soundUri = new android.net.Uri.parse(sound)
const NotifyMgr = android.app.NotificationManager const NotifyMgr = android.app.NotificationManager
let ctx = Utils.ad.getApplicationContext()
const NotifySrv = ctx.getSystemService( const NotifySrv = ctx.getSystemService(
android.content.Context.NOTIFICATION_SERVICE android.content.Context.NOTIFICATION_SERVICE
) )
const NotificationCompat = androidx.core.app.NotificationCompat
const AudioManager = android.media.AudioManager
if (sdkv >= 26) { if (sdkv >= 26) {
const importance = const importance =
priority > 0 ? NotifyMgr.IMPORTANCE_HIGH : NotifyMgr.IMPORTANCE_MIN priority > 0 ? NotifyMgr.IMPORTANCE_HIGH : NotifyMgr.IMPORTANCE_MIN
@ -371,6 +378,7 @@ export class TimerNotif {
if (sound) Channel.setSound(soundUri, audioAttributes) if (sound) Channel.setSound(soundUri, audioAttributes)
else Channel.setSound(null, null) else Channel.setSound(null, null)
Channel.setShowBadge(true) Channel.setShowBadge(true)
Channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC)
NotifySrv.createNotificationChannel(Channel) NotifySrv.createNotificationChannel(Channel)
} }
@ -390,7 +398,7 @@ export class TimerNotif {
let actionInt1, actionInt2, actionPInt1, actionPInt2 let actionInt1, actionInt2, actionPInt1, actionPInt2
if (actions) { if (actions) {
actionInt1 = new Intent(bID) actionInt1 = new Intent(bID)
actionInt1.putExtra('action', 'delay') actionInt1.putExtra('action', 'stop')
actionPInt1 = PendingIntent.getBroadcast( actionPInt1 = PendingIntent.getBroadcast(
ctx, ctx,
2, 2,
@ -398,7 +406,7 @@ export class TimerNotif {
PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_UPDATE_CURRENT
) )
actionInt2 = new Intent(bID) actionInt2 = new Intent(bID)
actionInt2.putExtra('action', 'stop') actionInt2.putExtra('action', 'delay')
actionPInt2 = PendingIntent.getBroadcast( actionPInt2 = PendingIntent.getBroadcast(
ctx, ctx,
3, 3,
@ -408,8 +416,7 @@ export class TimerNotif {
} }
// CREATE NOTIFICATION // CREATE NOTIFICATION
const NotificationCompat = androidx.core.app.NotificationCompat
const AudioManager = android.media.AudioManager
let icon = TimerNotif.getIcon(ctx, 'ic_stat_notify_silhouette') let icon = TimerNotif.getIcon(ctx, 'ic_stat_notify_silhouette')
let builder = new NotificationCompat.Builder(ctx, cID) let builder = new NotificationCompat.Builder(ctx, cID)
.setColor(new Color('#ff5200').android) .setColor(new Color('#ff5200').android)
@ -421,6 +428,7 @@ export class TimerNotif {
.setSmallIcon(icon) .setSmallIcon(icon)
.setTicker(title) .setTicker(title)
.setAutoCancel(false) .setAutoCancel(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
if (sound) builder.setSound(soundUri, AudioManager.STREAM_ALARM) if (sound) builder.setSound(soundUri, AudioManager.STREAM_ALARM)
else builder.setSound(null) else builder.setSound(null)
if (description) builder.setContentText(description) if (description) builder.setContentText(description)
@ -428,13 +436,21 @@ export class TimerNotif {
if (actions) { if (actions) {
builder.setDeleteIntent(actionPInt2) builder.setDeleteIntent(actionPInt2)
builder.setFullScreenIntent(mainPInt, true) builder.setFullScreenIntent(mainPInt, true)
builder.addAction(null, 'Delay', actionPInt1) builder.addAction(null, 'Stop', actionPInt1)
builder.addAction(null, 'Stop', actionPInt2) builder.addAction(null, 'Delay', actionPInt2)
} }
let notification = builder.build() let notification = builder.build()
notification.flags = notification.flags =
NotificationCompat.FLAG_INSISTENT | NotificationCompat.FLAG_ONGOING_EVENT NotificationCompat.FLAG_INSISTENT | NotificationCompat.FLAG_ONGOING_EVENT
NotifySrv.notify(nID, notification)
return notification
}
static show(data) {
const ctx = Utils.ad.getApplicationContext()
const NotifySrv = ctx.getSystemService(
android.content.Context.NOTIFICATION_SERVICE
)
NotifySrv.notify(data.nID, TimerNotif.getNotification(data, ctx))
} }
} }

36
package-lock.json generated
View file

@ -1388,9 +1388,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001228", "version": "1.0.30001230",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz",
"integrity": "sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==", "integrity": "sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==",
"dev": true, "dev": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -1877,9 +1877,9 @@
"dev": true "dev": true
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.3.738", "version": "1.3.739",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.738.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz",
"integrity": "sha512-vCMf4gDOpEylPSLPLSwAEsz+R3ShP02Y3cAKMZvTqule3XcPp7tgc/0ESI7IS6ZeyBlGClE50N53fIOkcIVnpw==", "integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==",
"dev": true "dev": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@ -4718,9 +4718,9 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "7.4.5", "version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==", "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=8.3.0" "node": ">=8.3.0"
@ -5914,9 +5914,9 @@
"dev": true "dev": true
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001228", "version": "1.0.30001230",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz",
"integrity": "sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A==", "integrity": "sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==",
"dev": true "dev": true
}, },
"chalk": { "chalk": {
@ -6281,9 +6281,9 @@
"dev": true "dev": true
}, },
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.3.738", "version": "1.3.739",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.738.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.739.tgz",
"integrity": "sha512-vCMf4gDOpEylPSLPLSwAEsz+R3ShP02Y3cAKMZvTqule3XcPp7tgc/0ESI7IS6ZeyBlGClE50N53fIOkcIVnpw==", "integrity": "sha512-+LPJVRsN7hGZ9EIUUiWCpO7l4E3qBYHNadazlucBfsXBbccDFNKUBAgzE68FnkWGJPwD/AfKhSzL+G+Iqb8A4A==",
"dev": true "dev": true
}, },
"emoji-regex": { "emoji-regex": {
@ -8386,9 +8386,9 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
}, },
"ws": { "ws": {
"version": "7.4.5", "version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==", "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
"dev": true "dev": true
}, },
"xmlbuilder": { "xmlbuilder": {