• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

snowplow / snowplow-android-tracker / #1052

pending completion
#1052

push

github-actions

web-flow
Fix crash with null pointer when accessing session (close #581)

PR #582

9 of 9 new or added lines in 3 files covered. (100.0%)

3497 of 4579 relevant lines covered (76.37%)

0.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.89
/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/internal/session/Session.java
1
/*
2
 * Copyright (c) 2015-2022 Snowplow Analytics Ltd. All rights reserved.
3
 *
4
 * This program is licensed to you under the Apache License Version 2.0,
5
 * and you may not use this file except in compliance with the Apache License Version 2.0.
6
 * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0.
7
 *
8
 * Unless required by applicable law or agreed to in writing,
9
 * software distributed under the Apache License Version 2.0 is distributed on an
10
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
 * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under.
12
 */
13

14
package com.snowplowanalytics.snowplow.internal.session;
15

16
import android.annotation.SuppressLint;
17
import android.content.Context;
18
import android.content.SharedPreferences;
19
import android.os.StrictMode;
20

21
import androidx.annotation.NonNull;
22
import androidx.annotation.Nullable;
23
import androidx.core.util.Consumer;
24

25
import com.snowplowanalytics.snowplow.internal.constants.Parameters;
26
import com.snowplowanalytics.snowplow.internal.constants.TrackerConstants;
27
import com.snowplowanalytics.snowplow.internal.tracker.Logger;
28
import com.snowplowanalytics.snowplow.internal.utils.Util;
29
import com.snowplowanalytics.snowplow.payload.SelfDescribingJson;
30
import com.snowplowanalytics.snowplow.tracker.SessionState;
31

32
import org.json.JSONException;
33
import org.json.JSONObject;
34

35
import java.util.HashMap;
36
import java.util.Iterator;
37
import java.util.Map;
38
import java.util.UUID;
39
import java.util.concurrent.TimeUnit;
40
import java.util.concurrent.atomic.AtomicBoolean;
41

42
/**
43
 * Component that generate a Session context
44
 * which gets appended to each event sent from
45
 * the Tracker and changes based on:
46
 * - Timeout of use while app is in foreground
47
 * - Timeout of use while app is in background
48
 *
49
 * Session data is maintained for the life of the
50
 * application being installed on a device.
51
 *
52
 * Essentially will update if it is not accessed within
53
 * a configurable timeout.
54
 */
55
public class Session {
56
    private static final String TAG = Session.class.getSimpleName();
1✔
57

58
    // Session Variables
59
    private String userId;
60
    private volatile int backgroundIndex = 0;
1✔
61
    private volatile int foregroundIndex = 0;
1✔
62
    private int eventIndex = 0;
1✔
63
    private SessionState state = null;
1✔
64

65
    // Variables to control Session Updates
66
    private final AtomicBoolean isBackground = new AtomicBoolean(false);
1✔
67
    private long lastSessionCheck;
68
    private final AtomicBoolean isNewSession = new AtomicBoolean(true);
1✔
69
    private volatile boolean isSessionCheckerEnabled;
70
    private long foregroundTimeout;
71
    private long backgroundTimeout;
72

73
    // Callbacks
74
    private Runnable foregroundTransitionCallback = null;
1✔
75
    private Runnable backgroundTransitionCallback = null;
1✔
76
    private Runnable foregroundTimeoutCallback = null;
1✔
77
    private Runnable backgroundTimeoutCallback = null;
1✔
78
    @Nullable
79
    public Consumer<SessionState> onSessionUpdate;
80

81
    // Session values persistence
82
    private SharedPreferences sharedPreferences;
83

84
    /**
85
     * Creates a new Session object which will
86
     * update itself overtime.
87
     *
88
     * @param context the android context
89
     * @param foregroundTimeout the amount of time that can elapse before the
90
     *                          session id is updated while the app is in the
91
     *                          foreground.
92
     * @param backgroundTimeout the amount of time that can elapse before the
93
     *                          session id is updated while the app is in the
94
     *                          background.
95
     * @param timeUnit the time units of the timeout measurements
96
     * @param namespace the namespace used by the session.
97
     * @param sessionCallbacks Called when the app change state or when timeout is triggered.
98
     */
99
    @NonNull
100
    public synchronized static Session getInstance(@NonNull Context context,
101
                                                   long foregroundTimeout,
102
                                                   long backgroundTimeout,
103
                                                   @NonNull TimeUnit timeUnit,
104
                                                   @Nullable String namespace,
105
                                                   @Nullable Runnable[] sessionCallbacks)
106
    {
107
        Session session = new Session(foregroundTimeout, backgroundTimeout, timeUnit, namespace, context);
1✔
108
        Runnable[] callbacks = {null, null, null, null};
1✔
109
        if (sessionCallbacks.length == 4) {
1✔
110
            callbacks = sessionCallbacks;
1✔
111
        }
112
        session.foregroundTransitionCallback = callbacks[0];
1✔
113
        session.backgroundTransitionCallback = callbacks[1];
1✔
114
        session.foregroundTimeoutCallback = callbacks[2];
1✔
115
        session.backgroundTimeoutCallback = callbacks[3];
1✔
116
        return session;
1✔
117
    }
118

119
    @SuppressLint("ApplySharedPref")
120
    public Session(long foregroundTimeout, long backgroundTimeout, @NonNull TimeUnit timeUnit, @Nullable String namespace, @NonNull Context context) {
1✔
121
        this.foregroundTimeout = timeUnit.toMillis(foregroundTimeout);
1✔
122
        this.backgroundTimeout = timeUnit.toMillis(backgroundTimeout);
1✔
123
        isSessionCheckerEnabled = true;
1✔
124

125
        String sessionVarsName = TrackerConstants.SNOWPLOW_SESSION_VARS;
1✔
126
        if (namespace != null && !namespace.isEmpty()) {
1✔
127
            String sessionVarsSuffix = namespace.replaceAll("[^a-zA-Z0-9_]+", "-");
1✔
128
            sessionVarsName = TrackerConstants.SNOWPLOW_SESSION_VARS + "_" + sessionVarsSuffix;
1✔
129
        }
130

131
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
1✔
132
        try {
133
            Map<String, Object> sessionInfo = getSessionMapFromLegacyTrackerV3(context, sessionVarsName);
1✔
134
            if (sessionInfo == null) {
1✔
135
                sessionInfo = getSessionMapFromLegacyTrackerV2(context, sessionVarsName);
1✔
136
                if (sessionInfo == null) {
1✔
137
                    try {
138
                        sessionInfo = getSessionMapFromLegacyTrackerV1(context);
×
139
                    } catch (Exception e) {
1✔
140
                        Logger.track(TAG, String.format("Exception occurred retrieving session info from file: %s", e), e);
1✔
141
                    }
×
142
                }
143
            }
144
            if (sessionInfo == null) {
1✔
145
                Logger.track(TAG, "No previous session info available");
1✔
146
            } else {
147
                state = SessionState.build(sessionInfo);
1✔
148
            }
149
            userId = retrieveUserId(context, state);
1✔
150
            sharedPreferences = context.getSharedPreferences(sessionVarsName, Context.MODE_PRIVATE);
1✔
151
            lastSessionCheck = System.currentTimeMillis();
1✔
152
        } finally {
153
            StrictMode.setThreadPolicy(oldPolicy);
1✔
154
        }
1✔
155
        Logger.v(TAG, "Tracker Session Object created.");
1✔
156
    }
1✔
157

158
    private synchronized static String retrieveUserId(Context context, SessionState state) {
159
        String userId = state != null ? state.getUserId() : Util.getUUIDString();
1✔
160
        // Session_UserID is available only if the session context is enabled.
161
        // In a future version we would like to make it available even if the session context is disabled.
162
        // For this reason, we store the Session_UserID in a separate storage (decoupled by session values)
163
        // calling it Installation_UserID in order to remark that it isn't related to the session context.
164
        // Although, for legacy, we need to copy its value in the Session_UserID of the session context
165
        // as the session context schema (and related data modelling) requires it.
166
        // For further details: https://discourse.snowplow.io/t/rfc-mobile-trackers-v2-0
167
        SharedPreferences generalPref = context.getSharedPreferences(TrackerConstants.SNOWPLOW_GENERAL_VARS, Context.MODE_PRIVATE);
1✔
168
        String storedUserId = generalPref.getString(TrackerConstants.INSTALLATION_USER_ID, null);
1✔
169
        if (storedUserId != null) {
1✔
170
            userId = storedUserId;
1✔
171
        } else {
172
            generalPref.edit()
1✔
173
                    .putString(TrackerConstants.INSTALLATION_USER_ID, userId)
1✔
174
                    .commit();
1✔
175
        }
176
        return userId;
1✔
177
    }
178

179
    /**
180
     * Returns the session context
181
     *
182
     * @return a SelfDescribingJson containing the session context
183
     */
184
    @Nullable
185
    public synchronized SelfDescribingJson getSessionContext(@NonNull String eventId, long eventTimestamp, boolean userAnonymisation) {
186
        Logger.v(TAG, "Getting session context...");
1✔
187
        if (isSessionCheckerEnabled) {
1✔
188
            if (shouldUpdateSession()) {
1✔
189
                Logger.d(TAG, "Update session information.");
1✔
190
                updateSession(eventId, eventTimestamp);
1✔
191

192
                if (isBackground.get()) { // timed out in background
1✔
193
                    this.executeEventCallback(backgroundTimeoutCallback);
1✔
194
                } else { // timed out in foreground
195
                    this.executeEventCallback(foregroundTimeoutCallback);
1✔
196
                }
197
            }
198
            lastSessionCheck = System.currentTimeMillis();
1✔
199
        }
200

201
        SessionState state = getState();
1✔
202
        if (state == null) { return null; }
1✔
203

204
        eventIndex += 1;
1✔
205

206
        Map<String, Object> sessionValues = state.getSessionValues();
1✔
207
        Map<String, Object> sessionCopy = new HashMap<>(sessionValues);
1✔
208
        sessionCopy.put(Parameters.SESSION_EVENT_INDEX, eventIndex);
1✔
209
        if (userAnonymisation) {
1✔
210
            sessionCopy.put(Parameters.SESSION_USER_ID, "00000000-0000-0000-0000-000000000000");
1✔
211
            sessionCopy.put(Parameters.SESSION_PREVIOUS_ID, null);
1✔
212
        }
213

214
        return new SelfDescribingJson(TrackerConstants.SESSION_SCHEMA, sessionCopy);
1✔
215
    }
216

217
    private boolean shouldUpdateSession() {
218
        if (isNewSession.get()) {
1✔
219
            return true;
1✔
220
        }
221
        long now = System.currentTimeMillis();
1✔
222
        long timeout = isBackground.get() ? backgroundTimeout : foregroundTimeout;
1✔
223
        return now < lastSessionCheck || now - lastSessionCheck > timeout;
1✔
224
    }
225

226
    private synchronized void updateSession(String eventId, long eventTimestamp) {
227
        isNewSession.set(false);
1✔
228
        String currentSessionId = Util.getUUIDString();
1✔
229
        String eventTimestampDateTime = Util.getDateTimeFromTimestamp(eventTimestamp);
1✔
230

231
        int sessionIndex = 1;
1✔
232
        eventIndex = 0;
1✔
233
        String previousSessionId = null;
1✔
234
        String storage = "LOCAL_STORAGE";
1✔
235
        if (state != null) {
1✔
236
            sessionIndex = state.getSessionIndex() + 1;
1✔
237
            previousSessionId = state.getSessionId();
1✔
238
            storage = state.getStorage();
1✔
239
        }
240
        state = new SessionState(eventId, eventTimestampDateTime, currentSessionId, previousSessionId, sessionIndex, userId, storage);
1✔
241
        storeSessionState(state);
1✔
242
        callOnSessionUpdateCallback(state);
1✔
243
    }
1✔
244

245
    private void storeSessionState(SessionState state) {
246
        JSONObject jsonObject = new JSONObject(state.getSessionValues());
1✔
247
        String jsonString = jsonObject.toString();
1✔
248
        SharedPreferences.Editor editor = sharedPreferences.edit();
1✔
249
        editor.putString(TrackerConstants.SESSION_STATE, jsonString);
1✔
250
        editor.apply();
1✔
251
    }
1✔
252

253
    private void callOnSessionUpdateCallback(SessionState state) {
254
        if (onSessionUpdate != null) {
1✔
255
            Thread thread = new Thread(new Runnable() {
1✔
256
                @Override
257
                public void run() {
258
                    onSessionUpdate.accept(state);
1✔
259
                }
1✔
260
            });
261
            thread.setDaemon(true);
1✔
262
            thread.start();
1✔
263
        }
264
    }
1✔
265

266
    private void executeEventCallback(Runnable callback) {
267
        if (callback == null) return;
1✔
268
        try {
269
            callback.run();
×
270
        } catch (Exception e) {
×
271
            Logger.e(TAG, "Session event callback failed");
×
272
        }
×
273
    }
×
274

275
    public void startNewSession() {
276
        isNewSession.set(true);
1✔
277
    }
1✔
278

279
    /**
280
     * Updates the session timeout and the indexes.
281
     * Note: Internal use only.
282
     * @param isBackground whether or not the application moved to background.
283
     */
284
    public void setBackground(boolean isBackground) {
285
        if (!this.isBackground.compareAndSet(!isBackground, isBackground)) {
1✔
286
            return;
×
287
        }
288

289
        if (!isBackground) {
1✔
290
            Logger.d(TAG, "Application moved to foreground");
1✔
291
            this.executeEventCallback(this.foregroundTransitionCallback);
1✔
292
            try {
293
                setIsSuspended(false);
1✔
294
            } catch (Exception e) {
×
295
                Logger.e(TAG, "Could not resume checking as tracker not setup. Exception: %s", e);
×
296
            }
1✔
297
            foregroundIndex++;
1✔
298
        } else {
299
            Logger.d(TAG, "Application moved to background");
1✔
300
            this.executeEventCallback(this.backgroundTransitionCallback);
1✔
301
            backgroundIndex++;
1✔
302
        }
303
    }
1✔
304

305
    public boolean isBackground() {
306
        return isBackground.get();
1✔
307
    }
308

309
    /**
310
     * Changes the truth of isSuspended
311
     * @param isSuspended whether the session tracking is suspended,
312
     *                    i.e. calls to update session are ignored,
313
     *                    but access time is changed to current time.
314
     *
315
     */
316
    public void setIsSuspended(boolean isSuspended) {
317
        Logger.d(TAG, "Session is suspended: %s", isSuspended);
1✔
318
        isSessionCheckerEnabled = !isSuspended;
1✔
319
    }
1✔
320

321
    public int getBackgroundIndex() {
322
        return backgroundIndex;
1✔
323
    }
324

325
    public int getForegroundIndex() {
326
        return foregroundIndex;
1✔
327
    }
328

329
    /**
330
     * Gets the session information from a file.
331
     *
332
     * @return a map or null.
333
     */
334
    @Nullable
335
    private Map<String, Object> getSessionMapFromLegacyTrackerV1(@NonNull Context context) {
336
        Map<String, Object> sessionMap = FileStore.getMapFromFile(
1✔
337
                TrackerConstants.SNOWPLOW_SESSION_VARS,
338
                context);
339
        sessionMap.put(Parameters.SESSION_FIRST_ID, "");
×
340
        sessionMap.put(Parameters.SESSION_PREVIOUS_ID, null);
×
341
        sessionMap.put(Parameters.SESSION_STORAGE, "LOCAL_STORAGE");
×
342
        return sessionMap;
×
343
    }
344

345
    @Nullable
346
    private Map<String, Object> getSessionMapFromLegacyTrackerV2(@NonNull Context context, @NonNull String sessionVarsName) {
347
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
1✔
348
        try {
349
            SharedPreferences sharedPreferences = context.getSharedPreferences(sessionVarsName, Context.MODE_PRIVATE);
1✔
350
            if (!sharedPreferences.contains(Parameters.SESSION_ID)) {
1✔
351
                sharedPreferences = context.getSharedPreferences(TrackerConstants.SNOWPLOW_SESSION_VARS, Context.MODE_PRIVATE);
1✔
352
                if (!sharedPreferences.contains(Parameters.SESSION_ID)) {
1✔
353
                    return null;
1✔
354
                }
355
            }
356
            // Create map used as initial session state
357
            Map<String, Object> sessionMap = new HashMap<>();
1✔
358
            String sessionId = sharedPreferences.getString(Parameters.SESSION_ID, null);
1✔
359
            if (sessionId == null) return null;
1✔
360
            sessionMap.put(Parameters.SESSION_ID, sessionId);
1✔
361

362
            String userId = sharedPreferences.getString(Parameters.SESSION_USER_ID, null);
1✔
363
            if (userId == null) return null;
1✔
364
            sessionMap.put(Parameters.SESSION_USER_ID, userId);
1✔
365

366
            int sessionIndex = sharedPreferences.getInt(Parameters.SESSION_INDEX, 0);
1✔
367
            sessionMap.put(Parameters.SESSION_INDEX, sessionIndex);
1✔
368

369
            sessionMap.put(Parameters.SESSION_FIRST_ID, "");
1✔
370
            sessionMap.put(Parameters.SESSION_PREVIOUS_ID, null);
1✔
371
            sessionMap.put(Parameters.SESSION_STORAGE, "LOCAL_STORAGE");
1✔
372
            return sessionMap;
1✔
373
        } finally {
374
            StrictMode.setThreadPolicy(oldPolicy);
1✔
375
        }
×
376
    }
377

378
    @Nullable
379
    private Map<String, Object> getSessionMapFromLegacyTrackerV3(@NonNull Context context, @NonNull String sessionVarsName) {
380
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
1✔
381
        try {
382
            SharedPreferences sharedPreferences = context.getSharedPreferences(sessionVarsName, Context.MODE_PRIVATE);
1✔
383
            if (!sharedPreferences.contains(TrackerConstants.SESSION_STATE)) {
1✔
384
                return null;
1✔
385
            }
386
            Map<String, Object> sessionMap = new HashMap<>();
1✔
387
            String jsonString = sharedPreferences.getString(TrackerConstants.SESSION_STATE, null);
1✔
388
            JSONObject jsonObject = new JSONObject(jsonString);
1✔
389
            Iterator<String> iterator = jsonObject.keys();
1✔
390
            while (iterator.hasNext()) {
1✔
391
                String key = iterator.next();
1✔
392
                Object value = jsonObject.get(key);
1✔
393
                sessionMap.put(key, value);
1✔
394
            }
1✔
395
            return sessionMap;
1✔
396
        } catch (JSONException e) {
×
397
            e.printStackTrace();
×
398
        } finally {
399
            StrictMode.setThreadPolicy(oldPolicy);
1✔
400
        }
×
401
        return null;
×
402
    }
403

404
    /**
405
     * @return the session index
406
     */
407
    public int getSessionIndex() { //$ to remove
408
        return this.state.getSessionIndex();
1✔
409
    }
410

411
    /**
412
     * @return the user id
413
     */
414
    @NonNull
415
    public String getUserId() {
416
        return this.userId;
1✔
417
    }
418

419
    /**
420
     * @return the session state
421
     */
422
    @Nullable
423
    public SessionState getState() {
424
        return state;
1✔
425
    }
426

427
    /**
428
     * Set foreground timeout
429
     */
430
    public void setForegroundTimeout(long foregroundTimeout) {
431
        this.foregroundTimeout = foregroundTimeout;
×
432
    }
×
433

434
    /**
435
     * @return the foreground session timeout
436
     */
437
    public long getForegroundTimeout() {
438
        return this.foregroundTimeout;
1✔
439
    }
440

441
    /**
442
     * Set background timeout
443
     */
444
    public void setBackgroundTimeout(long backgroundTimeout) {
445
        this.backgroundTimeout = backgroundTimeout;
×
446
    }
×
447

448
    /**
449
     * @return the background session timeout
450
     */
451
    public long getBackgroundTimeout() {
452
        return this.backgroundTimeout;
1✔
453
    }
454
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc