File indexing completed on 2024-12-22 04:41:40
0001 /* 0002 * SPDX-FileCopyrightText: 2014 Albert Vaca Cintora <albertvaka@gmail.com> 0003 * 0004 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL 0005 */ 0006 0007 package org.kde.kdeconnect.Plugins.NotificationsPlugin; 0008 0009 import android.app.Activity; 0010 import android.app.KeyguardManager; 0011 import android.app.Notification; 0012 import android.app.PendingIntent; 0013 import android.app.RemoteInput; 0014 import android.content.Context; 0015 import android.content.Intent; 0016 import android.content.SharedPreferences; 0017 import android.content.pm.PackageManager; 0018 import android.content.res.Resources; 0019 import android.graphics.Bitmap; 0020 import android.graphics.Canvas; 0021 import android.graphics.drawable.Drawable; 0022 import android.graphics.drawable.Icon; 0023 import android.os.Build; 0024 import android.os.Bundle; 0025 import android.os.Parcelable; 0026 import android.provider.Settings; 0027 import android.service.notification.StatusBarNotification; 0028 import android.text.SpannableString; 0029 import android.text.TextUtils; 0030 import android.util.Log; 0031 import android.util.Pair; 0032 0033 import androidx.annotation.NonNull; 0034 import androidx.annotation.Nullable; 0035 import androidx.annotation.RequiresApi; 0036 import androidx.core.app.NotificationCompat; 0037 import androidx.fragment.app.DialogFragment; 0038 0039 import org.apache.commons.collections4.MultiValuedMap; 0040 import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; 0041 import org.apache.commons.lang3.ArrayUtils; 0042 import org.apache.commons.lang3.StringUtils; 0043 import org.json.JSONArray; 0044 import org.kde.kdeconnect.Helpers.AppsHelper; 0045 import org.kde.kdeconnect.NetworkPacket; 0046 import org.kde.kdeconnect.Plugins.Plugin; 0047 import org.kde.kdeconnect.Plugins.PluginFactory; 0048 import org.kde.kdeconnect.UserInterface.MainActivity; 0049 import org.kde.kdeconnect.UserInterface.PluginSettingsFragment; 0050 import org.kde.kdeconnect.UserInterface.StartActivityAlertDialogFragment; 0051 import org.kde.kdeconnect_tp.R; 0052 0053 import java.io.ByteArrayOutputStream; 0054 import java.security.MessageDigest; 0055 import java.security.NoSuchAlgorithmException; 0056 import java.util.Arrays; 0057 import java.util.HashMap; 0058 import java.util.HashSet; 0059 import java.util.Map; 0060 import java.util.Objects; 0061 import java.util.Set; 0062 0063 @PluginFactory.LoadablePlugin 0064 public class NotificationsPlugin extends Plugin implements NotificationReceiver.NotificationListener { 0065 0066 private final static String PACKET_TYPE_NOTIFICATION = "kdeconnect.notification"; 0067 private final static String PACKET_TYPE_NOTIFICATION_REQUEST = "kdeconnect.notification.request"; 0068 private final static String PACKET_TYPE_NOTIFICATION_REPLY = "kdeconnect.notification.reply"; 0069 private final static String PACKET_TYPE_NOTIFICATION_ACTION = "kdeconnect.notification.action"; 0070 private final static String PREF_KEY = "prefKey"; 0071 protected static final int PREF_NOTIFICATION_SCREEN_OFF = R.string.screen_off_notification_state; 0072 0073 private final static String TAG = "KDE/NotificationsPlugin"; 0074 0075 private AppDatabase appDatabase; 0076 0077 private Set<String> currentNotifications; 0078 private Map<String, RepliableNotification> pendingIntents; 0079 private MultiValuedMap<String, Notification.Action> actions; 0080 private boolean serviceReady; 0081 private SharedPreferences sharedPreferences; 0082 private KeyguardManager keyguardManager; 0083 0084 @Override 0085 public @NonNull String getDisplayName() { 0086 return context.getResources().getString(R.string.pref_plugin_notifications); 0087 } 0088 0089 @Override 0090 public @NonNull String getDescription() { 0091 return context.getResources().getString(R.string.pref_plugin_notifications_desc); 0092 } 0093 0094 @Override 0095 public boolean hasSettings() { 0096 return true; 0097 } 0098 0099 @Override 0100 public PluginSettingsFragment getSettingsFragment(Activity activity) { 0101 Intent intent = new Intent(activity, NotificationFilterActivity.class); 0102 intent.putExtra(PREF_KEY, this.getSharedPreferencesName()); 0103 activity.startActivity(intent); 0104 return null; 0105 } 0106 0107 @Override 0108 public boolean checkRequiredPermissions() { 0109 //Notifications use a different kind of permission, because it was added before the current runtime permissions model 0110 String notificationListenerList = Settings.Secure.getString(context.getContentResolver(), "enabled_notification_listeners"); 0111 return StringUtils.contains(notificationListenerList, context.getPackageName()); 0112 } 0113 0114 @Override 0115 public boolean onCreate() { 0116 0117 pendingIntents = new HashMap<>(); 0118 currentNotifications = new HashSet<>(); 0119 actions = new ArrayListValuedHashMap<>(); 0120 0121 sharedPreferences = context.getSharedPreferences(getSharedPreferencesName(),Context.MODE_PRIVATE); 0122 0123 keyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); 0124 0125 appDatabase = new AppDatabase(context, true); 0126 0127 NotificationReceiver.RunCommand(context, service -> { 0128 0129 service.addListener(NotificationsPlugin.this); 0130 0131 serviceReady = service.isConnected(); 0132 }); 0133 0134 return true; 0135 } 0136 0137 @Override 0138 public void onDestroy() { 0139 0140 NotificationReceiver.RunCommand(context, service -> service.removeListener(NotificationsPlugin.this)); 0141 } 0142 0143 @Override 0144 public void onListenerConnected(NotificationReceiver service) { 0145 serviceReady = true; 0146 } 0147 0148 @Override 0149 public void onNotificationRemoved(StatusBarNotification statusBarNotification) { 0150 if (statusBarNotification == null) { 0151 Log.w(TAG, "onNotificationRemoved: notification is null"); 0152 return; 0153 } 0154 String id = getNotificationKeyCompat(statusBarNotification); 0155 0156 actions.remove(id); 0157 0158 if (!appDatabase.isEnabled(statusBarNotification.getPackageName())) { 0159 currentNotifications.remove(id); 0160 return; 0161 } 0162 0163 NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION); 0164 np.set("id", id); 0165 np.set("isCancel", true); 0166 device.sendPacket(np); 0167 currentNotifications.remove(id); 0168 } 0169 0170 @Override 0171 public void onNotificationPosted(StatusBarNotification statusBarNotification) { 0172 if (sharedPreferences != null && sharedPreferences.getBoolean(context.getString(PREF_NOTIFICATION_SCREEN_OFF),false)){ 0173 if (keyguardManager != null && keyguardManager.inKeyguardRestrictedInputMode()){ 0174 sendNotification(statusBarNotification, false); 0175 } 0176 }else { 0177 sendNotification(statusBarNotification, false); 0178 } 0179 } 0180 0181 // isPreexisting is true for notifications that we are sending in response to a request command 0182 // and that we want to send with the "silent" flag set 0183 private void sendNotification(StatusBarNotification statusBarNotification, boolean isPreexisting) { 0184 0185 Notification notification = statusBarNotification.getNotification(); 0186 0187 if ((notification.flags & Notification.FLAG_FOREGROUND_SERVICE) != 0 0188 || (notification.flags & Notification.FLAG_ONGOING_EVENT) != 0 0189 || (notification.flags & Notification.FLAG_LOCAL_ONLY) != 0 0190 || (notification.flags & NotificationCompat.FLAG_GROUP_SUMMARY) != 0 //The notification that groups other notifications 0191 ) 0192 { 0193 //This is not a notification we want! 0194 return; 0195 } 0196 0197 if (!appDatabase.isEnabled(statusBarNotification.getPackageName())) { 0198 return; 0199 // we don't want notification from this app 0200 } 0201 0202 String key = getNotificationKeyCompat(statusBarNotification); 0203 String packageName = statusBarNotification.getPackageName(); 0204 String appName = AppsHelper.appNameLookup(context, packageName); 0205 0206 if ("com.facebook.orca".equals(packageName) && 0207 (statusBarNotification.getId() == 10012) && 0208 "Messenger".equals(appName) && 0209 notification.tickerText == null) { 0210 //HACK: Hide weird Facebook empty "Messenger" notification that is actually not shown in the phone 0211 return; 0212 } 0213 0214 if ("com.android.systemui".equals(packageName) && 0215 "low_battery".equals(statusBarNotification.getTag())) { 0216 //HACK: Android low battery notification are posted again every few seconds. Ignore them, as we already have a battery indicator. 0217 return; 0218 } 0219 0220 if ("org.kde.kdeconnect_tp".equals(packageName)) { 0221 // Don't send our own notifications 0222 return; 0223 } 0224 0225 NetworkPacket np = new NetworkPacket(PACKET_TYPE_NOTIFICATION); 0226 0227 boolean isUpdate = currentNotifications.contains(key); 0228 //If it's an update, the other end should have the icon already: no need to extract it and create the payload again 0229 if (!isUpdate) { 0230 0231 currentNotifications.add(key); 0232 0233 Bitmap appIcon = extractIcon(statusBarNotification, notification); 0234 0235 if (appIcon != null && !appDatabase.getPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_IMAGES)) { 0236 attachIcon(np, appIcon); 0237 } 0238 } 0239 0240 np.set("actions", extractActions(notification, key)); 0241 0242 np.set("id", key); 0243 np.set("onlyOnce", (notification.flags & NotificationCompat.FLAG_ONLY_ALERT_ONCE) != 0); 0244 np.set("isClearable", statusBarNotification.isClearable()); 0245 np.set("appName", StringUtils.defaultString(appName, packageName)); 0246 np.set("time", Long.toString(statusBarNotification.getPostTime())); 0247 np.set("silent", isPreexisting); 0248 0249 if (!appDatabase.getPrivacy(packageName, AppDatabase.PrivacyOptions.BLOCK_CONTENTS)) { 0250 RepliableNotification rn = extractRepliableNotification(statusBarNotification); 0251 if (rn != null) { 0252 np.set("requestReplyId", rn.id); 0253 pendingIntents.put(rn.id, rn); 0254 } 0255 np.set("ticker", getTickerText(notification)); 0256 0257 Pair<String, String> conversation = extractConversation(notification); 0258 0259 if (conversation.first != null) { 0260 np.set("title", conversation.first); 0261 } else { 0262 np.set("title", extractStringFromExtra(getExtras(notification), NotificationCompat.EXTRA_TITLE)); 0263 } 0264 0265 np.set("text", extractText(notification, conversation)); 0266 } 0267 0268 device.sendPacket(np); 0269 } 0270 0271 private String extractText(Notification notification, Pair<String, String> conversation) { 0272 0273 if (conversation.second != null) { 0274 return conversation.second; 0275 } 0276 0277 Bundle extras = getExtras(notification); 0278 0279 if (extras.containsKey(NotificationCompat.EXTRA_BIG_TEXT)) { 0280 return extractStringFromExtra(extras, NotificationCompat.EXTRA_BIG_TEXT); 0281 } 0282 0283 return extractStringFromExtra(extras, NotificationCompat.EXTRA_TEXT); 0284 } 0285 0286 @NonNull 0287 private static Bundle getExtras(Notification notification) { 0288 // NotificationCompat.getExtras() is expected to return non-null values for JELLY_BEAN+ 0289 return Objects.requireNonNull(NotificationCompat.getExtras(notification)); 0290 } 0291 0292 private void attachIcon(NetworkPacket np, Bitmap appIcon) { 0293 ByteArrayOutputStream outStream = new ByteArrayOutputStream(); 0294 appIcon.compress(Bitmap.CompressFormat.PNG, 90, outStream); 0295 byte[] bitmapData = outStream.toByteArray(); 0296 0297 np.setPayload(new NetworkPacket.Payload(bitmapData)); 0298 np.set("payloadHash", getChecksum(bitmapData)); 0299 } 0300 0301 @Nullable 0302 private Bitmap extractIcon(StatusBarNotification statusBarNotification, Notification notification) { 0303 try { 0304 Context foreignContext = context.createPackageContext(statusBarNotification.getPackageName(), 0); 0305 0306 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && notification.getLargeIcon() != null) { 0307 return iconToBitmap(foreignContext, notification.getLargeIcon()); 0308 } else if (notification.largeIcon != null) { 0309 return notification.largeIcon; 0310 } 0311 0312 PackageManager pm = context.getPackageManager(); 0313 Resources foreignResources = pm.getResourcesForApplication(statusBarNotification.getPackageName()); 0314 Drawable foreignIcon = foreignResources.getDrawable(notification.icon); //Might throw Resources.NotFoundException 0315 return drawableToBitmap(foreignIcon); 0316 0317 } catch (PackageManager.NameNotFoundException | Resources.NotFoundException e) { 0318 Log.e(TAG, "Package not found", e); 0319 } 0320 0321 return null; 0322 } 0323 0324 @Nullable 0325 private JSONArray extractActions(Notification notification, String key) { 0326 if (ArrayUtils.isEmpty(notification.actions)) { 0327 return null; 0328 } 0329 0330 JSONArray jsonArray = new JSONArray(); 0331 0332 for (Notification.Action action : notification.actions) { 0333 0334 if (null == action.title) 0335 continue; 0336 0337 // Check whether it is a reply action. We have special treatment for them 0338 if (ArrayUtils.isNotEmpty(action.getRemoteInputs())) 0339 continue; 0340 0341 jsonArray.put(action.title.toString()); 0342 0343 // A list is automatically created if it doesn't already exist. 0344 actions.put(key, action); 0345 } 0346 0347 return jsonArray; 0348 } 0349 0350 private Pair<String, String> extractConversation(Notification notification) { 0351 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 0352 return new Pair<>(null, null); 0353 0354 if (!notification.extras.containsKey(Notification.EXTRA_MESSAGES)) 0355 return new Pair<>(null, null); 0356 0357 Parcelable[] ms = notification.extras.getParcelableArray(Notification.EXTRA_MESSAGES); 0358 0359 if (ms == null) 0360 return new Pair<>(null, null); 0361 0362 String title = notification.extras.getString(Notification.EXTRA_CONVERSATION_TITLE); 0363 0364 boolean isGroupConversation = notification.extras.getBoolean(NotificationCompat.EXTRA_IS_GROUP_CONVERSATION); 0365 0366 StringBuilder messagesBuilder = new StringBuilder(); 0367 0368 for (Parcelable p : ms) { 0369 Bundle m = (Bundle) p; 0370 0371 if (isGroupConversation && m.containsKey("sender")) { 0372 messagesBuilder.append(m.get("sender")); 0373 messagesBuilder.append(": "); 0374 } 0375 0376 messagesBuilder.append(extractStringFromExtra(m, "text")); 0377 messagesBuilder.append("\n"); 0378 } 0379 0380 return new Pair<>(title, messagesBuilder.toString()); 0381 } 0382 0383 private Bitmap drawableToBitmap(Drawable drawable) { 0384 if (drawable == null) return null; 0385 0386 Bitmap res; 0387 if (drawable.getIntrinsicWidth() > 128 || drawable.getIntrinsicHeight() > 128) { 0388 res = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); 0389 } else if (drawable.getIntrinsicWidth() <= 64 || drawable.getIntrinsicHeight() <= 64) { 0390 res = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888); 0391 } else { 0392 res = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); 0393 } 0394 0395 Canvas canvas = new Canvas(res); 0396 drawable.setBounds(0, 0, res.getWidth(), res.getHeight()); 0397 drawable.draw(canvas); 0398 return res; 0399 } 0400 0401 @RequiresApi(Build.VERSION_CODES.M) 0402 private Bitmap iconToBitmap(Context foreignContext, Icon icon) { 0403 if (icon == null) return null; 0404 0405 return drawableToBitmap(icon.loadDrawable(foreignContext)); 0406 } 0407 0408 private void replyToNotification(String id, String message) { 0409 if (pendingIntents.isEmpty() || !pendingIntents.containsKey(id)) { 0410 Log.e(TAG, "No such notification"); 0411 return; 0412 } 0413 0414 RepliableNotification repliableNotification = pendingIntents.get(id); 0415 if (repliableNotification == null) { 0416 Log.e(TAG, "No such notification"); 0417 return; 0418 } 0419 RemoteInput[] remoteInputs = new RemoteInput[repliableNotification.remoteInputs.size()]; 0420 0421 Intent localIntent = new Intent(); 0422 localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 0423 Bundle localBundle = new Bundle(); 0424 int i = 0; 0425 for (RemoteInput remoteIn : repliableNotification.remoteInputs) { 0426 remoteInputs[i] = remoteIn; 0427 localBundle.putCharSequence(remoteInputs[i].getResultKey(), message); 0428 i++; 0429 } 0430 RemoteInput.addResultsToIntent(remoteInputs, localIntent, localBundle); 0431 0432 try { 0433 repliableNotification.pendingIntent.send(context, 0, localIntent); 0434 } catch (PendingIntent.CanceledException e) { 0435 Log.e(TAG, "replyToNotification error: " + e.getMessage()); 0436 } 0437 pendingIntents.remove(id); 0438 } 0439 0440 @Nullable 0441 private RepliableNotification extractRepliableNotification(StatusBarNotification statusBarNotification) { 0442 0443 if (statusBarNotification.getNotification().actions == null) { 0444 return null; 0445 } 0446 0447 for (Notification.Action act : statusBarNotification.getNotification().actions) { 0448 if (act != null && act.getRemoteInputs() != null) { 0449 // Is a reply 0450 RepliableNotification repliableNotification = new RepliableNotification(); 0451 repliableNotification.remoteInputs.addAll(Arrays.asList(act.getRemoteInputs())); 0452 repliableNotification.pendingIntent = act.actionIntent; 0453 repliableNotification.packageName = statusBarNotification.getPackageName(); 0454 repliableNotification.tag = statusBarNotification.getTag(); //TODO find how to pass Tag with sending PendingIntent, might fix Hangout problem 0455 0456 return repliableNotification; 0457 } 0458 } 0459 0460 return null; 0461 } 0462 0463 private static String extractStringFromExtra(Bundle extras, String key) { 0464 Object extra = extras.get(key); 0465 if (extra == null) { 0466 return null; 0467 } else if (extra instanceof String) { 0468 return (String) extra; 0469 } else if (extra instanceof SpannableString) { 0470 return extra.toString(); 0471 } else { 0472 Log.e(TAG, "Don't know how to extract text from extra of type: " + extra.getClass().getCanonicalName()); 0473 return null; 0474 } 0475 } 0476 0477 /** 0478 * Returns the ticker text of the notification. 0479 * If device android version is KitKat or newer, the title and text of the notification is used 0480 * instead the ticker text. 0481 */ 0482 private String getTickerText(Notification notification) { 0483 String ticker = ""; 0484 0485 try { 0486 Bundle extras = getExtras(notification); 0487 String extraTitle = extractStringFromExtra(extras, NotificationCompat.EXTRA_TITLE); 0488 String extraText = extractStringFromExtra(extras, NotificationCompat.EXTRA_TEXT); 0489 0490 if (extraTitle != null && !TextUtils.isEmpty(extraText)) { 0491 ticker = extraTitle + ": " + extraText; 0492 } else if (extraTitle != null) { 0493 ticker = extraTitle; 0494 } else if (extraText != null) { 0495 ticker = extraText; 0496 } 0497 } catch (Exception e) { 0498 Log.e(TAG, "problem parsing notification extras for " + notification.tickerText, e); 0499 } 0500 0501 if (ticker.isEmpty()) { 0502 ticker = (notification.tickerText != null) ? notification.tickerText.toString() : ""; 0503 } 0504 0505 return ticker; 0506 } 0507 0508 private void sendCurrentNotifications(NotificationReceiver service) { 0509 StatusBarNotification[] notifications = service.getActiveNotifications(); 0510 if (notifications != null) { //Can happen only on API 23 and lower 0511 for (StatusBarNotification notification : notifications) { 0512 sendNotification(notification, true); 0513 } 0514 } 0515 } 0516 0517 @Override 0518 public boolean onPacketReceived(final NetworkPacket np) { 0519 0520 if (np.getType().equals(PACKET_TYPE_NOTIFICATION_ACTION)) { 0521 0522 String key = np.getString("key"); 0523 String title = np.getString("action"); 0524 PendingIntent intent = null; 0525 0526 for (Notification.Action a : actions.get(key)) { 0527 if (a.title.equals(title)) { 0528 intent = a.actionIntent; 0529 } 0530 } 0531 0532 if (intent != null) { 0533 try { 0534 intent.send(); 0535 } catch (PendingIntent.CanceledException e) { 0536 Log.e(TAG, "Triggering action failed", e); 0537 } 0538 } 0539 0540 } else if (np.getBoolean("request")) { 0541 0542 if (serviceReady) { 0543 NotificationReceiver.RunCommand(context, this::sendCurrentNotifications); 0544 } 0545 0546 } else if (np.has("cancel")) { 0547 final String dismissedId = np.getString("cancel"); 0548 currentNotifications.remove(dismissedId); 0549 NotificationReceiver.RunCommand(context, service -> service.cancelNotification(dismissedId)); 0550 } else if (np.has("requestReplyId") && np.has("message")) { 0551 replyToNotification(np.getString("requestReplyId"), np.getString("message")); 0552 } 0553 0554 return true; 0555 } 0556 0557 @Override 0558 public @NonNull DialogFragment getPermissionExplanationDialog() { 0559 return new StartActivityAlertDialogFragment.Builder() 0560 .setTitle(R.string.pref_plugin_notifications) 0561 .setMessage(R.string.no_permissions) 0562 .setPositiveButton(R.string.open_settings) 0563 .setNegativeButton(R.string.cancel) 0564 .setIntentAction("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") 0565 .setStartForResult(true) 0566 .setRequestCode(MainActivity.RESULT_NEEDS_RELOAD) 0567 .create(); 0568 } 0569 0570 @Override 0571 public @NonNull String[] getSupportedPacketTypes() { 0572 return new String[]{PACKET_TYPE_NOTIFICATION_REQUEST, PACKET_TYPE_NOTIFICATION_REPLY, PACKET_TYPE_NOTIFICATION_ACTION}; 0573 } 0574 0575 @Override 0576 public @NonNull String[] getOutgoingPacketTypes() { 0577 return new String[]{PACKET_TYPE_NOTIFICATION}; 0578 } 0579 0580 private static String getNotificationKeyCompat(StatusBarNotification statusBarNotification) { 0581 String result; 0582 // first check if it's one of our remoteIds 0583 String tag = statusBarNotification.getTag(); 0584 if (StringUtils.startsWith(tag, "kdeconnectId:")) 0585 result = Integer.toString(statusBarNotification.getId()); 0586 else { 0587 result = statusBarNotification.getKey(); 0588 } 0589 return result; 0590 } 0591 0592 private String getChecksum(byte[] data) { 0593 0594 try { 0595 MessageDigest md = MessageDigest.getInstance("MD5"); 0596 md.update(data); 0597 return bytesToHex(md.digest()); 0598 } catch (NoSuchAlgorithmException e) { 0599 Log.e(TAG, "Error while generating checksum", e); 0600 } 0601 return null; 0602 } 0603 0604 0605 private static String bytesToHex(byte[] bytes) { 0606 char[] hexArray = "0123456789ABCDEF".toCharArray(); 0607 char[] hexChars = new char[bytes.length * 2]; 0608 for (int j = 0; j < bytes.length; j++) { 0609 int v = bytes[j] & 0xFF; 0610 hexChars[j * 2] = hexArray[v >>> 4]; 0611 hexChars[j * 2 + 1] = hexArray[v & 0x0F]; 0612 } 0613 return new String(hexChars).toLowerCase(); 0614 } 0615 0616 public static String getPrefKey(){ return PREF_KEY;} 0617 }