1 /*
2  * Copyright (C) 2016 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 
17 package android.telecom.Logging;
18 
19 import android.annotation.Nullable;
20 import android.content.Context;
21 import android.os.Handler;
22 import android.os.Looper;
23 import android.os.Process;
24 import android.provider.Settings;
25 import android.telecom.Log;
26 import android.util.Base64;
27 
28 import com.android.internal.annotations.VisibleForTesting;
29 
30 import java.nio.ByteBuffer;
31 import java.util.ArrayList;
32 import java.util.Arrays;
33 import java.util.Iterator;
34 import java.util.List;
35 import java.util.concurrent.ConcurrentHashMap;
36 
37 /**
38  * TODO: Create better Sessions Documentation
39  * @hide
40  */
41 
42 public class SessionManager {
43 
44     // Currently using 3 letters, So don't exceed 64^3
45     private static final long SESSION_ID_ROLLOVER_THRESHOLD = 262144;
46     // This parameter can be overridden in Telecom's Timeouts class.
47     private static final long DEFAULT_SESSION_TIMEOUT_MS = 30000L; // 30 seconds
48     private static final String LOGGING_TAG = "Logging";
49     private static final String TIMEOUTS_PREFIX = "telecom.";
50 
51     // Synchronized in all method calls
52     private int sCodeEntryCounter = 0;
53     private Context mContext;
54 
55     @VisibleForTesting
56     public ConcurrentHashMap<Integer, Session> mSessionMapper = new ConcurrentHashMap<>(100);
57     @VisibleForTesting
58     public java.lang.Runnable mCleanStaleSessions = () ->
59             cleanupStaleSessions(getSessionCleanupTimeoutMs());
60     private Handler mSessionCleanupHandler = new Handler(Looper.getMainLooper());
61 
62     // Overridden in LogTest to skip query to ContentProvider
63     private interface ISessionCleanupTimeoutMs {
get()64         long get();
65     }
66 
67     // Overridden in tests to provide test Thread IDs
68     public interface ICurrentThreadId {
get()69         int get();
70     }
71 
72     @VisibleForTesting
73     public ICurrentThreadId mCurrentThreadId = Process::myTid;
74 
75     private ISessionCleanupTimeoutMs mSessionCleanupTimeoutMs = () -> {
76         // mContext may be null in some cases, such as testing. For these cases, use the
77         // default value.
78         if (mContext == null) {
79             return DEFAULT_SESSION_TIMEOUT_MS;
80         }
81         return getCleanupTimeout(mContext);
82     };
83 
84     // Usage is synchronized on this class.
85     private List<ISessionListener> mSessionListeners = new ArrayList<>();
86 
87     public interface ISessionListener {
88         /**
89          * This method is run when a full Session has completed.
90          * @param sessionName The name of the Session that has completed.
91          * @param timeMs The time it took to complete in ms.
92          */
sessionComplete(String sessionName, long timeMs)93         void sessionComplete(String sessionName, long timeMs);
94     }
95 
96     public interface ISessionIdQueryHandler {
getSessionId()97         String getSessionId();
98     }
99 
setContext(Context context)100     public void setContext(Context context) {
101         mContext = context;
102     }
103 
SessionManager()104     public SessionManager() {
105     }
106 
getSessionCleanupTimeoutMs()107     private long getSessionCleanupTimeoutMs() {
108         return mSessionCleanupTimeoutMs.get();
109     }
110 
resetStaleSessionTimer()111     private synchronized void resetStaleSessionTimer() {
112         mSessionCleanupHandler.removeCallbacksAndMessages(null);
113         // Will be null in Log Testing
114         if (mCleanStaleSessions != null) {
115             mSessionCleanupHandler.postDelayed(mCleanStaleSessions, getSessionCleanupTimeoutMs());
116         }
117     }
118 
119     /**
120      * Determines whether or not to start a new session or continue an existing session based on
121      * the {@link Session.Info} info passed into startSession. If info is null, a new Session is
122      * created. This code must be accompanied by endSession() at the end of the Session.
123      */
startSession(Session.Info info, String shortMethodName, String callerIdentification)124     public synchronized void startSession(Session.Info info, String shortMethodName,
125             String callerIdentification) {
126         // Start a new session normally if the
127         if(info == null) {
128             startSession(shortMethodName, callerIdentification);
129         } else {
130             startExternalSession(info, shortMethodName);
131         }
132     }
133 
134     /**
135      * Call at an entry point to the Telecom code to track the session. This code must be
136      * accompanied by a Log.endSession().
137      */
startSession(String shortMethodName, String callerIdentification)138     public synchronized void startSession(String shortMethodName,
139             String callerIdentification) {
140         resetStaleSessionTimer();
141         int threadId = getCallingThreadId();
142         Session activeSession = mSessionMapper.get(threadId);
143         // We have called startSession within an active session that has not ended... Register this
144         // session as a subsession.
145         if (activeSession != null) {
146             Session childSession = createSubsession(true);
147             continueSession(childSession, shortMethodName);
148             return;
149         } else {
150             // Only Log that we are starting the parent session.
151             Log.d(LOGGING_TAG, Session.START_SESSION);
152         }
153         Session newSession = new Session(getNextSessionID(), shortMethodName,
154                 System.currentTimeMillis(), false, callerIdentification);
155         mSessionMapper.put(threadId, newSession);
156     }
157 
158     /**
159      * Registers an external Session with the Manager using that external Session's sessionInfo.
160      * Log.endSession will still need to be called at the end of the session.
161      * @param sessionInfo Describes the external Session's information.
162      * @param shortMethodName The method name of the new session that is being started.
163      */
startExternalSession(Session.Info sessionInfo, String shortMethodName)164     public synchronized void startExternalSession(Session.Info sessionInfo,
165             String shortMethodName) {
166         if(sessionInfo == null) {
167             return;
168         }
169 
170         int threadId = getCallingThreadId();
171         Session threadSession = mSessionMapper.get(threadId);
172         if (threadSession != null) {
173             // We should never get into a situation where there is already an active session AND
174             // an external session is added. We are just using that active session.
175             Log.w(LOGGING_TAG, "trying to start an external session with a session " +
176                     "already active.");
177             return;
178         }
179 
180         // Create Session from Info and add to the sessionMapper under this ID.
181         Log.d(LOGGING_TAG, Session.START_EXTERNAL_SESSION);
182         Session externalSession = new Session(Session.EXTERNAL_INDICATOR + sessionInfo.sessionId,
183                 sessionInfo.methodPath, System.currentTimeMillis(),
184                 false /*isStartedFromActiveSession*/, sessionInfo.ownerInfo);
185         externalSession.setIsExternal(true);
186         // Mark the external session as already completed, since we have no way of knowing when
187         // the external session actually has completed.
188         externalSession.markSessionCompleted(Session.UNDEFINED);
189         // Track the external session with the SessionMapper so that we can create and continue
190         // an active subsession based on it.
191         mSessionMapper.put(threadId, externalSession);
192         // Create a subsession from this external Session parent node
193         Session childSession = createSubsession();
194         continueSession(childSession, shortMethodName);
195     }
196 
197     /**
198      * Notifies the logging system that a subsession will be run at a later point and
199      * allocates the resources. Returns a session object that must be used in
200      * Log.continueSession(...) to start the subsession.
201      */
createSubsession()202     public Session createSubsession() {
203         return createSubsession(false);
204     }
205 
206     /**
207      * Creates a new subsession based on an existing session. Will not be started until
208      * {@link #continueSession(Session, String)} or {@link #cancelSubsession(Session)} is called.
209      * <p>
210      * Only public for testing!
211      * @param isStartedFromActiveSession true if this subsession is being created for a task on the
212      *     same thread, false if it is being created for a related task on another thread.
213      * @return a new {@link Session}, call {@link #continueSession(Session, String)} to continue the
214      * session and {@link #endSession()} when done with this subsession.
215      */
216     @VisibleForTesting
createSubsession(boolean isStartedFromActiveSession)217     public synchronized Session createSubsession(boolean isStartedFromActiveSession) {
218         int threadId = getCallingThreadId();
219         Session threadSession = mSessionMapper.get(threadId);
220         if (threadSession == null) {
221             Log.d(LOGGING_TAG, "Log.createSubsession was called with no session " +
222                     "active.");
223             return null;
224         }
225         // Start execution time of the session will be overwritten in continueSession(...).
226         Session newSubsession = new Session(threadSession.getNextChildId(),
227                 threadSession.getShortMethodName(), System.currentTimeMillis(),
228                 isStartedFromActiveSession, threadSession.getOwnerInfo());
229         threadSession.addChild(newSubsession);
230         newSubsession.setParentSession(threadSession);
231 
232         if (!isStartedFromActiveSession) {
233             Log.v(LOGGING_TAG, Session.CREATE_SUBSESSION + " " +
234                     newSubsession.toString());
235         } else {
236             Log.v(LOGGING_TAG, Session.CREATE_SUBSESSION +
237                     " (Invisible subsession)");
238         }
239         return newSubsession;
240     }
241 
getExternalSession()242     public synchronized Session.Info getExternalSession() {
243         return getExternalSession(null /* ownerInfo */);
244     }
245 
246     /**
247      * Retrieve the information of the currently active Session. This information is parcelable and
248      * is used to create an external Session ({@link #startExternalSession(Session.Info, String)}).
249      * If there is no Session active, this method will return null.
250      * @param ownerInfo Owner information for the session.
251      * @return The session information
252      */
getExternalSession(@ullable String ownerInfo)253     public synchronized Session.Info getExternalSession(@Nullable String ownerInfo) {
254         int threadId = getCallingThreadId();
255         Session threadSession = mSessionMapper.get(threadId);
256         if (threadSession == null) {
257             Log.d(LOGGING_TAG, "Log.getExternalSession was called with no session " +
258                     "active.");
259             return null;
260         }
261         return threadSession.getExternalInfo(ownerInfo);
262     }
263 
264     /**
265      * Cancels a subsession that had Log.createSubsession() called on it, but will never have
266      * Log.continueSession(...) called on it due to an error. Allows the subsession to be cleaned
267      * gracefully instead of being removed by the mSessionCleanupHandler forcefully later.
268      */
cancelSubsession(Session subsession)269     public synchronized void cancelSubsession(Session subsession) {
270         if (subsession == null) {
271             return;
272         }
273 
274         subsession.markSessionCompleted(Session.UNDEFINED);
275         endParentSessions(subsession);
276     }
277 
278     /**
279      * Starts the subsession that was created in Log.CreateSubsession. The Log.endSession() method
280      * must be called at the end of this method. The full session will complete when all
281      * subsessions are completed.
282      */
continueSession(Session subsession, String shortMethodName)283     public synchronized void continueSession(Session subsession, String shortMethodName) {
284         if (subsession == null) {
285             return;
286         }
287         resetStaleSessionTimer();
288         subsession.setShortMethodName(shortMethodName);
289         subsession.setExecutionStartTimeMs(System.currentTimeMillis());
290         Session parentSession = subsession.getParentSession();
291         if (parentSession == null) {
292             Log.i(LOGGING_TAG, "Log.continueSession was called with no session " +
293                     "active for method " + shortMethodName);
294             return;
295         }
296 
297         mSessionMapper.put(getCallingThreadId(), subsession);
298         if (!subsession.isStartedFromActiveSession()) {
299             Log.v(LOGGING_TAG, Session.CONTINUE_SUBSESSION);
300         } else {
301             Log.v(LOGGING_TAG, Session.CONTINUE_SUBSESSION +
302                     " (Invisible Subsession) with Method " + shortMethodName);
303         }
304     }
305 
306     /**
307      * Ends the current session/subsession. Must be called after a Log.startSession(...) and
308      * Log.continueSession(...) call.
309      */
endSession()310     public synchronized void endSession() {
311         int threadId = getCallingThreadId();
312         Session completedSession = mSessionMapper.get(threadId);
313         if (completedSession == null) {
314             Log.w(LOGGING_TAG, "Log.endSession was called with no session active.");
315             return;
316         }
317 
318         completedSession.markSessionCompleted(System.currentTimeMillis());
319         if (!completedSession.isStartedFromActiveSession()) {
320             Log.v(LOGGING_TAG, Session.END_SUBSESSION + " (dur: " +
321                     completedSession.getLocalExecutionTime() + " mS)");
322         } else {
323             Log.v(LOGGING_TAG, Session.END_SUBSESSION +
324                     " (Invisible Subsession) (dur: " + completedSession.getLocalExecutionTime() +
325                     " ms)");
326         }
327         // Remove after completed so that reference still exists for logging the end events
328         Session parentSession = completedSession.getParentSession();
329         mSessionMapper.remove(threadId);
330         endParentSessions(completedSession);
331         // If this subsession was started from a parent session using Log.startSession, return the
332         // ThreadID back to the parent after completion.
333         if (parentSession != null && !parentSession.isSessionCompleted() &&
334                 completedSession.isStartedFromActiveSession()) {
335             mSessionMapper.put(threadId, parentSession);
336         }
337     }
338 
339     // Recursively deletes all complete parent sessions of the current subsession if it is a leaf.
endParentSessions(Session subsession)340     private void endParentSessions(Session subsession) {
341         // Session is not completed or not currently a leaf, so we can not remove because a child is
342         // still running
343         if (!subsession.isSessionCompleted() || subsession.getChildSessions().size() != 0) {
344             return;
345         }
346         Session parentSession = subsession.getParentSession();
347         if (parentSession != null) {
348             subsession.setParentSession(null);
349             parentSession.removeChild(subsession);
350             // Report the child session of the external session as being complete to the listeners,
351             // not the external session itself.
352             if (parentSession.isExternal()) {
353                 long fullSessionTimeMs =
354                         System.currentTimeMillis() - subsession.getExecutionStartTimeMilliseconds();
355                 notifySessionCompleteListeners(subsession.getShortMethodName(), fullSessionTimeMs);
356             }
357             endParentSessions(parentSession);
358         } else {
359             // All of the subsessions have been completed and it is time to report on the full
360             // running time of the session.
361             long fullSessionTimeMs =
362                     System.currentTimeMillis() - subsession.getExecutionStartTimeMilliseconds();
363             Log.d(LOGGING_TAG, Session.END_SESSION + " (dur: " + fullSessionTimeMs
364                     + " ms): " + subsession.toString());
365             if (!subsession.isExternal()) {
366                 notifySessionCompleteListeners(subsession.getShortMethodName(), fullSessionTimeMs);
367             }
368         }
369     }
370 
notifySessionCompleteListeners(String methodName, long sessionTimeMs)371     private void notifySessionCompleteListeners(String methodName, long sessionTimeMs) {
372         for (ISessionListener l : mSessionListeners) {
373             l.sessionComplete(methodName, sessionTimeMs);
374         }
375     }
376 
getSessionId()377     public String getSessionId() {
378         Session currentSession = mSessionMapper.get(getCallingThreadId());
379         return currentSession != null ? currentSession.toString() : "";
380     }
381 
registerSessionListener(ISessionListener l)382     public synchronized void registerSessionListener(ISessionListener l) {
383         if (l != null) {
384             mSessionListeners.add(l);
385         }
386     }
387 
getNextSessionID()388     private synchronized String getNextSessionID() {
389         Integer nextId = sCodeEntryCounter++;
390         if (nextId >= SESSION_ID_ROLLOVER_THRESHOLD) {
391             restartSessionCounter();
392             nextId = sCodeEntryCounter++;
393         }
394         return getBase64Encoding(nextId);
395     }
396 
restartSessionCounter()397     private synchronized void restartSessionCounter() {
398         sCodeEntryCounter = 0;
399     }
400 
getBase64Encoding(int number)401     private String getBase64Encoding(int number) {
402         byte[] idByteArray = ByteBuffer.allocate(4).putInt(number).array();
403         idByteArray = Arrays.copyOfRange(idByteArray, 2, 4);
404         return Base64.encodeToString(idByteArray, Base64.NO_WRAP | Base64.NO_PADDING);
405     }
406 
getCallingThreadId()407     private int getCallingThreadId() {
408         return mCurrentThreadId.get();
409     }
410 
411     /**
412      * @return A String representation of the active sessions at the time that this method is
413      * called.
414      */
415     @VisibleForTesting
printActiveSessions()416     public synchronized String printActiveSessions() {
417         StringBuilder message = new StringBuilder();
418         for (ConcurrentHashMap.Entry<Integer, Session> entry : mSessionMapper.entrySet()) {
419             message.append(entry.getValue().printFullSessionTree());
420             message.append("\n");
421         }
422         return message.toString();
423     }
424 
425     @VisibleForTesting
cleanupStaleSessions(long timeoutMs)426     public synchronized void cleanupStaleSessions(long timeoutMs) {
427         String logMessage = "Stale Sessions Cleaned:\n";
428         boolean isSessionsStale = false;
429         long currentTimeMs = System.currentTimeMillis();
430         // Remove references that are in the Session Mapper (causing GC to occur) on
431         // sessions that are lasting longer than LOGGING_SESSION_TIMEOUT_MS.
432         // If this occurs, then there is most likely a Session active that never had
433         // Log.endSession called on it.
434         for (Iterator<ConcurrentHashMap.Entry<Integer, Session>> it =
435              mSessionMapper.entrySet().iterator(); it.hasNext(); ) {
436             ConcurrentHashMap.Entry<Integer, Session> entry = it.next();
437             Session session = entry.getValue();
438             if (currentTimeMs - session.getExecutionStartTimeMilliseconds() > timeoutMs) {
439                 it.remove();
440                 logMessage += session.printFullSessionTree() + "\n";
441                 isSessionsStale = true;
442             }
443         }
444         if (isSessionsStale) {
445             Log.w(LOGGING_TAG, logMessage);
446         } else {
447             Log.v(LOGGING_TAG, "No stale logging sessions needed to be cleaned...");
448         }
449     }
450 
451     /**
452      * Returns the amount of time after a Logging session has been started that Telecom is set to
453      * perform a sweep to check and make sure that the session is still not incomplete (stale).
454      */
getCleanupTimeout(Context context)455     private long getCleanupTimeout(Context context) {
456         return Settings.Secure.getLong(context.getContentResolver(), TIMEOUTS_PREFIX +
457                 "stale_session_cleanup_timeout_millis", DEFAULT_SESSION_TIMEOUT_MS);
458     }
459 }
460