blob: 884dcf2dfbad0d176d4bc84264096a24f90e8cbd [file] [log] [blame]
Ihab Awad60ac30b2014-05-20 22:32:12 -07001/*
2 * Copyright 2014, The Android Open Source Project
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
Tyler Gunnef9f6f92014-09-12 22:16:17 -070017package android.telecom;
Ihab Awad60ac30b2014-05-20 22:32:12 -070018
Tyler Gunnffbcd892020-05-04 15:01:59 -070019import android.annotation.NonNull;
Artur Satayev2ebb31c2020-01-08 12:24:36 +000020import android.compat.annotation.UnsupportedAppUsage;
Tyler Gunnffbcd892020-05-04 15:01:59 -070021import android.content.ComponentName;
Brad Ebinger4fb372f2016-10-05 15:47:28 -070022import android.content.Context;
Santos Cordon3c20d632016-02-25 16:12:35 -080023import android.net.Uri;
youhei.x.miyoshib3cd7b52016-12-12 21:10:54 +090024import android.os.Build;
Brad Ebinger51b98342016-09-22 16:30:46 -070025import android.telecom.Logging.EventManager;
26import android.telecom.Logging.Session;
27import android.telecom.Logging.SessionManager;
Santos Cordon3c20d632016-02-25 16:12:35 -080028import android.telephony.PhoneNumberUtils;
29import android.text.TextUtils;
Hall Liue362e502016-01-07 17:35:54 -080030
Brad Ebinger51b98342016-09-22 16:30:46 -070031import com.android.internal.annotations.VisibleForTesting;
32import com.android.internal.util.IndentingPrintWriter;
33
Tyler Gunnffbcd892020-05-04 15:01:59 -070034import java.util.Arrays;
Ihab Awad60ac30b2014-05-20 22:32:12 -070035import java.util.IllegalFormatException;
36import java.util.Locale;
Tyler Gunnffbcd892020-05-04 15:01:59 -070037import java.util.stream.Collectors;
Ihab Awad60ac30b2014-05-20 22:32:12 -070038
39/**
40 * Manages logging for the entire module.
41 *
42 * @hide
43 */
Brad Ebinger51b98342016-09-22 16:30:46 -070044public class Log {
Ihab Awad60ac30b2014-05-20 22:32:12 -070045
Brad Ebinger51b98342016-09-22 16:30:46 -070046 private static final long EXTENDED_LOGGING_DURATION_MILLIS = 60000 * 30; // 30 minutes
Ihab Awad60ac30b2014-05-20 22:32:12 -070047
Brad Ebinger51b98342016-09-22 16:30:46 -070048 private static final int EVENTS_TO_CACHE = 10;
49 private static final int EVENTS_TO_CACHE_DEBUG = 20;
50
Tyler Gunn9bc35112018-04-23 09:52:25 -070051 /**
52 * When generating a bug report, include the last X dialable digits when logging phone numbers.
53 */
54 private static final int NUM_DIALABLE_DIGITS_TO_LOG = Build.IS_USER ? 0 : 2;
55
Brad Ebinger51b98342016-09-22 16:30:46 -070056 // Generic tag for all Telecom logging
57 @VisibleForTesting
58 public static String TAG = "TelecomFramework";
Brad Ebinger0c3541b2016-11-01 14:11:38 -070059 public static boolean DEBUG = isLoggable(android.util.Log.DEBUG);
60 public static boolean INFO = isLoggable(android.util.Log.INFO);
61 public static boolean VERBOSE = isLoggable(android.util.Log.VERBOSE);
62 public static boolean WARN = isLoggable(android.util.Log.WARN);
63 public static boolean ERROR = isLoggable(android.util.Log.ERROR);
Brad Ebinger51b98342016-09-22 16:30:46 -070064
65 private static final boolean FORCE_LOGGING = false; /* STOP SHIP if true */
Jeff Sharkey5ab02432017-06-27 11:01:36 -060066 private static final boolean USER_BUILD = Build.IS_USER;
Ihab Awad60ac30b2014-05-20 22:32:12 -070067
Brad Ebinger51b98342016-09-22 16:30:46 -070068 // Used to synchronize singleton logging lazy initialization
69 private static final Object sSingletonSync = new Object();
70 private static EventManager sEventManager;
71 private static SessionManager sSessionManager;
72
73 /**
74 * Tracks whether user-activated extended logging is enabled.
75 */
76 private static boolean sIsUserExtendedLoggingEnabled = false;
77
78 /**
Thomas Stuarta1ca66f2022-04-28 11:04:40 -070079 * Enabled in telecom testing to help gate log statements causing log spew.
80 */
81 private static boolean sIsUnitTestingEnabled = false;
82
83 /**
Brad Ebinger51b98342016-09-22 16:30:46 -070084 * The time when user-activated extended logging should be ended. Used to determine when
85 * extended logging should automatically be disabled.
86 */
87 private static long sUserExtendedLoggingStopTime = 0;
88
89 private Log() {
90 }
91
92 public static void d(String prefix, String format, Object... args) {
93 if (sIsUserExtendedLoggingEnabled) {
94 maybeDisableLogging();
95 android.util.Slog.i(TAG, buildMessage(prefix, format, args));
96 } else if (DEBUG) {
97 android.util.Slog.d(TAG, buildMessage(prefix, format, args));
98 }
99 }
100
101 public static void d(Object objectPrefix, String format, Object... args) {
102 if (sIsUserExtendedLoggingEnabled) {
103 maybeDisableLogging();
104 android.util.Slog.i(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args));
105 } else if (DEBUG) {
106 android.util.Slog.d(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args));
107 }
108 }
109
Mathew Inwood5d123b62020-11-04 09:29:36 +0000110 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Brad Ebinger51b98342016-09-22 16:30:46 -0700111 public static void i(String prefix, String format, Object... args) {
112 if (INFO) {
113 android.util.Slog.i(TAG, buildMessage(prefix, format, args));
114 }
115 }
116
117 public static void i(Object objectPrefix, String format, Object... args) {
118 if (INFO) {
119 android.util.Slog.i(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args));
120 }
121 }
122
123 public static void v(String prefix, String format, Object... args) {
124 if (sIsUserExtendedLoggingEnabled) {
125 maybeDisableLogging();
126 android.util.Slog.i(TAG, buildMessage(prefix, format, args));
127 } else if (VERBOSE) {
128 android.util.Slog.v(TAG, buildMessage(prefix, format, args));
129 }
130 }
131
132 public static void v(Object objectPrefix, String format, Object... args) {
133 if (sIsUserExtendedLoggingEnabled) {
134 maybeDisableLogging();
135 android.util.Slog.i(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args));
136 } else if (VERBOSE) {
137 android.util.Slog.v(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args));
138 }
139 }
140
Mathew Inwood5d123b62020-11-04 09:29:36 +0000141 @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Brad Ebinger51b98342016-09-22 16:30:46 -0700142 public static void w(String prefix, String format, Object... args) {
143 if (WARN) {
144 android.util.Slog.w(TAG, buildMessage(prefix, format, args));
145 }
146 }
147
148 public static void w(Object objectPrefix, String format, Object... args) {
149 if (WARN) {
150 android.util.Slog.w(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args));
151 }
152 }
153
154 public static void e(String prefix, Throwable tr, String format, Object... args) {
155 if (ERROR) {
156 android.util.Slog.e(TAG, buildMessage(prefix, format, args), tr);
157 }
158 }
159
160 public static void e(Object objectPrefix, Throwable tr, String format, Object... args) {
161 if (ERROR) {
162 android.util.Slog.e(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args),
163 tr);
164 }
165 }
166
167 public static void wtf(String prefix, Throwable tr, String format, Object... args) {
168 android.util.Slog.wtf(TAG, buildMessage(prefix, format, args), tr);
169 }
170
171 public static void wtf(Object objectPrefix, Throwable tr, String format, Object... args) {
172 android.util.Slog.wtf(TAG, buildMessage(getPrefixFromObject(objectPrefix), format, args),
173 tr);
174 }
175
176 public static void wtf(String prefix, String format, Object... args) {
177 String msg = buildMessage(prefix, format, args);
178 android.util.Slog.wtf(TAG, msg, new IllegalStateException(msg));
179 }
180
181 public static void wtf(Object objectPrefix, String format, Object... args) {
182 String msg = buildMessage(getPrefixFromObject(objectPrefix), format, args);
183 android.util.Slog.wtf(TAG, msg, new IllegalStateException(msg));
184 }
185
186 /**
187 * The ease of use methods below only act mostly as proxies to the Session and Event Loggers.
188 * They also control the lazy loaders of the singleton instances, which will never be loaded if
189 * the proxy methods aren't used.
190 *
191 * Please see each method's documentation inside of their respective implementations in the
192 * loggers.
193 */
194
Brad Ebinger4fb372f2016-10-05 15:47:28 -0700195 public static void setSessionContext(Context context) {
196 getSessionManager().setContext(context);
197 }
198
Brad Ebinger51b98342016-09-22 16:30:46 -0700199 public static void startSession(String shortMethodName) {
200 getSessionManager().startSession(shortMethodName, null);
201 }
202
Brad Ebinger3445f822016-10-24 16:40:49 -0700203 public static void startSession(Session.Info info, String shortMethodName) {
204 getSessionManager().startSession(info, shortMethodName, null);
205 }
206
Brad Ebinger51b98342016-09-22 16:30:46 -0700207 public static void startSession(String shortMethodName, String callerIdentification) {
208 getSessionManager().startSession(shortMethodName, callerIdentification);
209 }
210
Brad Ebingera0dc9762016-10-21 09:41:29 -0700211 public static void startSession(Session.Info info, String shortMethodName,
212 String callerIdentification) {
213 getSessionManager().startSession(info, shortMethodName, callerIdentification);
214 }
215
Brad Ebinger51b98342016-09-22 16:30:46 -0700216 public static Session createSubsession() {
217 return getSessionManager().createSubsession();
218 }
219
Brad Ebinger3445f822016-10-24 16:40:49 -0700220 public static Session.Info getExternalSession() {
221 return getSessionManager().getExternalSession();
222 }
223
Tyler Gunnffbcd892020-05-04 15:01:59 -0700224 /**
225 * Retrieves external session information, providing a context for the recipient of the session
226 * info where the external session came from.
227 * @param ownerInfo The external owner info.
228 * @return New {@link Session.Info} instance with owner info set.
229 */
230 public static Session.Info getExternalSession(@NonNull String ownerInfo) {
231 return getSessionManager().getExternalSession(ownerInfo);
232 }
233
Brad Ebinger51b98342016-09-22 16:30:46 -0700234 public static void cancelSubsession(Session subsession) {
235 getSessionManager().cancelSubsession(subsession);
236 }
237
238 public static void continueSession(Session subsession, String shortMethodName) {
239 getSessionManager().continueSession(subsession, shortMethodName);
240 }
241
242 public static void endSession() {
243 getSessionManager().endSession();
244 }
245
Brad Ebinger836efad2016-10-18 13:48:17 -0700246 public static void registerSessionListener(SessionManager.ISessionListener l) {
247 getSessionManager().registerSessionListener(l);
248 }
249
Brad Ebinger51b98342016-09-22 16:30:46 -0700250 public static String getSessionId() {
251 // If the Session logger has not been initialized, then there have been no sessions logged.
252 // Don't load it now!
253 synchronized (sSingletonSync) {
254 if (sSessionManager != null) {
255 return getSessionManager().getSessionId();
256 } else {
257 return "";
258 }
259 }
260 }
261
262 public static void addEvent(EventManager.Loggable recordEntry, String event) {
263 getEventManager().event(recordEntry, event, null);
264 }
265
266 public static void addEvent(EventManager.Loggable recordEntry, String event, Object data) {
267 getEventManager().event(recordEntry, event, data);
268 }
269
270 public static void addEvent(EventManager.Loggable recordEntry, String event, String format,
271 Object... args) {
272 getEventManager().event(recordEntry, event, format, args);
273 }
274
275 public static void registerEventListener(EventManager.EventListener e) {
276 getEventManager().registerEventListener(e);
277 }
278
279 public static void addRequestResponsePair(EventManager.TimedEventPair p) {
280 getEventManager().addRequestResponsePair(p);
281 }
282
283 public static void dumpEvents(IndentingPrintWriter pw) {
284 // If the Events logger has not been initialized, then there have been no events logged.
285 // Don't load it now!
286 synchronized (sSingletonSync) {
287 if (sEventManager != null) {
288 getEventManager().dumpEvents(pw);
289 } else {
290 pw.println("No Historical Events Logged.");
291 }
292 }
293 }
294
295 /**
Tyler Gunn2db81b52017-05-19 10:10:23 -0700296 * Dumps the events in a timeline format.
297 * @param pw The {@link IndentingPrintWriter} to write to.
298 * @hide
299 */
300 public static void dumpEventsTimeline(IndentingPrintWriter pw) {
301 // If the Events logger has not been initialized, then there have been no events logged.
302 // Don't load it now!
303 synchronized (sSingletonSync) {
304 if (sEventManager != null) {
305 getEventManager().dumpEventsTimeline(pw);
306 } else {
307 pw.println("No Historical Events Logged.");
308 }
309 }
310 }
311
312 /**
Brad Ebinger51b98342016-09-22 16:30:46 -0700313 * Enable or disable extended telecom logging.
314 *
315 * @param isExtendedLoggingEnabled {@code true} if extended logging should be enabled,
316 * {@code false} if it should be disabled.
317 */
318 public static void setIsExtendedLoggingEnabled(boolean isExtendedLoggingEnabled) {
319 // If the state hasn't changed, bail early.
320 if (sIsUserExtendedLoggingEnabled == isExtendedLoggingEnabled) {
321 return;
322 }
323
324 if (sEventManager != null) {
325 sEventManager.changeEventCacheSize(isExtendedLoggingEnabled ?
326 EVENTS_TO_CACHE_DEBUG : EVENTS_TO_CACHE);
327 }
328
329 sIsUserExtendedLoggingEnabled = isExtendedLoggingEnabled;
330 if (sIsUserExtendedLoggingEnabled) {
331 sUserExtendedLoggingStopTime = System.currentTimeMillis()
332 + EXTENDED_LOGGING_DURATION_MILLIS;
333 } else {
334 sUserExtendedLoggingStopTime = 0;
335 }
336 }
337
Thomas Stuarta1ca66f2022-04-28 11:04:40 -0700338 /**
339 * Enabled when tests are running to help gate log statements causing log spew.
340 *
341 * @param isEnabled {@code true} if running unit tests. false otherwise.
342 *
343 */
344 public static void setUnitTestingEnabled(boolean isEnabled) {
345 sIsUnitTestingEnabled = isEnabled;
346 }
347
348 public static boolean isUnitTestingEnabled() {
349 return sIsUnitTestingEnabled;
350 }
351
Brad Ebinger51b98342016-09-22 16:30:46 -0700352 private static EventManager getEventManager() {
353 // Checking for null again outside of synchronization because we only need to synchronize
354 // during the lazy loading of the events logger. We don't need to synchronize elsewhere.
355 if (sEventManager == null) {
356 synchronized (sSingletonSync) {
357 if (sEventManager == null) {
358 sEventManager = new EventManager(Log::getSessionId);
359 return sEventManager;
360 }
361 }
362 }
363 return sEventManager;
364 }
365
Brad Ebinger8adafe72017-06-08 15:44:40 -0700366 @VisibleForTesting
367 public static SessionManager getSessionManager() {
Brad Ebinger51b98342016-09-22 16:30:46 -0700368 // Checking for null again outside of synchronization because we only need to synchronize
369 // during the lazy loading of the session logger. We don't need to synchronize elsewhere.
370 if (sSessionManager == null) {
371 synchronized (sSingletonSync) {
372 if (sSessionManager == null) {
373 sSessionManager = new SessionManager();
374 return sSessionManager;
375 }
376 }
377 }
378 return sSessionManager;
379 }
380
Brad Ebinger51b98342016-09-22 16:30:46 -0700381 public static void setTag(String tag) {
382 TAG = tag;
Brad Ebinger0c3541b2016-11-01 14:11:38 -0700383 DEBUG = isLoggable(android.util.Log.DEBUG);
384 INFO = isLoggable(android.util.Log.INFO);
385 VERBOSE = isLoggable(android.util.Log.VERBOSE);
386 WARN = isLoggable(android.util.Log.WARN);
387 ERROR = isLoggable(android.util.Log.ERROR);
Brad Ebinger51b98342016-09-22 16:30:46 -0700388 }
389
390 /**
391 * If user enabled extended logging is enabled and the time limit has passed, disables the
392 * extended logging.
393 */
394 private static void maybeDisableLogging() {
395 if (!sIsUserExtendedLoggingEnabled) {
396 return;
397 }
398
399 if (sUserExtendedLoggingStopTime < System.currentTimeMillis()) {
400 sUserExtendedLoggingStopTime = 0;
401 sIsUserExtendedLoggingEnabled = false;
402 }
403 }
404
Ihab Awad60ac30b2014-05-20 22:32:12 -0700405 public static boolean isLoggable(int level) {
406 return FORCE_LOGGING || android.util.Log.isLoggable(TAG, level);
407 }
408
Tyler Gunnfd74d53f2019-02-13 13:15:56 -0800409 /**
410 * Generates an obfuscated string for a calling handle in {@link Uri} format, or a raw phone
411 * phone number in {@link String} format.
412 * @param pii The information to obfuscate.
413 * @return The obfuscated string.
414 */
Brad Ebinger51b98342016-09-22 16:30:46 -0700415 public static String piiHandle(Object pii) {
416 if (pii == null || VERBOSE) {
417 return String.valueOf(pii);
Ihab Awad60ac30b2014-05-20 22:32:12 -0700418 }
Ihab Awad60ac30b2014-05-20 22:32:12 -0700419
Brad Ebinger51b98342016-09-22 16:30:46 -0700420 StringBuilder sb = new StringBuilder();
421 if (pii instanceof Uri) {
422 Uri uri = (Uri) pii;
423 String scheme = uri.getScheme();
424
425 if (!TextUtils.isEmpty(scheme)) {
426 sb.append(scheme).append(":");
427 }
428
429 String textToObfuscate = uri.getSchemeSpecificPart();
430 if (PhoneAccount.SCHEME_TEL.equals(scheme)) {
Tyler Gunnfd74d53f2019-02-13 13:15:56 -0800431 obfuscatePhoneNumber(sb, textToObfuscate);
Brad Ebinger51b98342016-09-22 16:30:46 -0700432 } else if (PhoneAccount.SCHEME_SIP.equals(scheme)) {
433 for (int i = 0; i < textToObfuscate.length(); i++) {
434 char c = textToObfuscate.charAt(i);
435 if (c != '@' && c != '.') {
436 c = '*';
437 }
438 sb.append(c);
439 }
440 } else {
441 sb.append(pii(pii));
442 }
Tyler Gunnfd74d53f2019-02-13 13:15:56 -0800443 } else if (pii instanceof String) {
444 String number = (String) pii;
445 obfuscatePhoneNumber(sb, number);
Ihab Awad60ac30b2014-05-20 22:32:12 -0700446 }
Ihab Awad60ac30b2014-05-20 22:32:12 -0700447
Brad Ebinger51b98342016-09-22 16:30:46 -0700448 return sb.toString();
Ihab Awad60ac30b2014-05-20 22:32:12 -0700449 }
450
451 /**
Tyler Gunnfd74d53f2019-02-13 13:15:56 -0800452 * Obfuscates a phone number, allowing NUM_DIALABLE_DIGITS_TO_LOG digits to be exposed for the
453 * phone number.
454 * @param sb String buffer to write obfuscated number to.
455 * @param phoneNumber The number to obfuscate.
456 */
457 private static void obfuscatePhoneNumber(StringBuilder sb, String phoneNumber) {
458 int numDigitsToObfuscate = getDialableCount(phoneNumber)
459 - NUM_DIALABLE_DIGITS_TO_LOG;
460 for (int i = 0; i < phoneNumber.length(); i++) {
461 char c = phoneNumber.charAt(i);
462 boolean isDialable = PhoneNumberUtils.isDialable(c);
463 if (isDialable) {
464 numDigitsToObfuscate--;
465 }
466 sb.append(isDialable && numDigitsToObfuscate >= 0 ? "*" : c);
467 }
468 }
469
470 /**
Tyler Gunn9bc35112018-04-23 09:52:25 -0700471 * Determines the number of dialable characters in a string.
472 * @param toCount The string to count dialable characters in.
473 * @return The count of dialable characters.
474 */
475 private static int getDialableCount(String toCount) {
476 int numDialable = 0;
477 for (char c : toCount.toCharArray()) {
478 if (PhoneNumberUtils.isDialable(c)) {
479 numDialable++;
480 }
481 }
482 return numDialable;
483 }
484
485 /**
Ihab Awad60ac30b2014-05-20 22:32:12 -0700486 * Redact personally identifiable information for production users.
youhei.x.miyoshib3cd7b52016-12-12 21:10:54 +0900487 * If we are running in verbose mode, return the original string,
Brad Ebingerf784b292017-12-22 13:45:27 -0800488 * and return "***" otherwise.
Ihab Awad60ac30b2014-05-20 22:32:12 -0700489 */
490 public static String pii(Object pii) {
491 if (pii == null || VERBOSE) {
492 return String.valueOf(pii);
493 }
Brad Ebingerf784b292017-12-22 13:45:27 -0800494 return "***";
Ihab Awad60ac30b2014-05-20 22:32:12 -0700495 }
496
497 private static String getPrefixFromObject(Object obj) {
498 return obj == null ? "<null>" : obj.getClass().getSimpleName();
499 }
500
501 private static String buildMessage(String prefix, String format, Object... args) {
Brad Ebinger51b98342016-09-22 16:30:46 -0700502 // Incorporate thread ID and calling method into prefix
503 String sessionName = getSessionId();
504 String sessionPostfix = TextUtils.isEmpty(sessionName) ? "" : ": " + sessionName;
505
Ihab Awad60ac30b2014-05-20 22:32:12 -0700506 String msg;
507 try {
508 msg = (args == null || args.length == 0) ? format
509 : String.format(Locale.US, format, args);
510 } catch (IllegalFormatException ife) {
Brad Ebinger4fb372f2016-10-05 15:47:28 -0700511 e(TAG, ife, "Log: IllegalFormatException: formatString='%s' numArgs=%d", format,
Ihab Awad60ac30b2014-05-20 22:32:12 -0700512 args.length);
513 msg = format + " (An error occurred while formatting the message.)";
514 }
Brad Ebinger51b98342016-09-22 16:30:46 -0700515 return String.format(Locale.US, "%s: %s%s", prefix, msg, sessionPostfix);
Ihab Awad60ac30b2014-05-20 22:32:12 -0700516 }
Tyler Gunnffbcd892020-05-04 15:01:59 -0700517
518 /**
519 * Generates an abbreviated version of the package name from a component.
520 * E.g. com.android.phone becomes cap
521 * @param componentName The component name to abbreviate.
522 * @return Abbreviation of empty string if component is null.
523 * @hide
524 */
525 public static String getPackageAbbreviation(ComponentName componentName) {
526 if (componentName == null) {
527 return "";
528 }
529 return getPackageAbbreviation(componentName.getPackageName());
530 }
531
532 /**
533 * Generates an abbreviated version of the package name.
534 * E.g. com.android.phone becomes cap
535 * @param packageName The packageName name to abbreviate.
536 * @return Abbreviation of empty string if package is null.
537 * @hide
538 */
539 public static String getPackageAbbreviation(String packageName) {
540 if (packageName == null) {
541 return "";
542 }
543 return Arrays.stream(packageName.split("\\."))
Tyler Gunnd5821842021-02-05 11:12:57 -0800544 .map(s -> s.length() == 0 ? "" : s.substring(0, 1))
Tyler Gunnffbcd892020-05-04 15:01:59 -0700545 .collect(Collectors.joining(""));
546 }
Ihab Awad60ac30b2014-05-20 22:32:12 -0700547}