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