Android: UsageStatsManager not returning correct daily results
Asked Answered
G

6

21

I'm attempting to query UsageStats from UsageStatsManager, with the aim of returning all app packages that were used daily and for how long.

The Code:

public static List<UsageStats> getUsageStatsList(Context context){
    UsageStatsManager usm = getUsageStatsManager(context);
    Calendar calendar = Calendar.getInstance();
    long endTime = calendar.getTimeInMillis();
    calendar.add(Calendar.DAY_OF_YEAR, -1);
    long startTime = calendar.getTimeInMillis();

    List<UsageStats> usageStatsList = usm.queryUsageStats(UsageStatsManager.INTERVAL_DAILY,startTime, endTime);
    return usageStatsList;
}

I have an alarm that fires daily just before midnight and query's usagestats and then stores the returned data. At first everything seemed to be working fine and I was getting package results and their active time, however I added a function that would check the results hourly and here is where I made a strange discovery.

The results from UsageStatsManagerseemed to be resetting at different times, instead of at midnight, which is what I would have expected considering I was using INTERVAL_DAILY as a search parameter.

From the data I saved the package 'time' results seem to be resetting at (Rough timings):

  • 3am
  • Midday
  • 3pm
  • Midnight

I realize that there is a correlation between when the package timings reset but is this meant to happen?

I've already seen the following thread and it's where I got a lot of my information from: How to use UsageStatsManager?

Consequently: Android UsageStatsManager producing wrong output? In the comments mentions that the data returned from queryUsageStats can't be trusted and random results are being returned.

Am I missing something simple or is UsageStatsManager not functioning correctly?

Greeley answered 26/3, 2016 at 17:30 Comment(1)
Hi! Did you find a solution to this? I'm having the same problem.Prickly
J
13

I too noticed this behaviour in API 21,UsageStats data is not maintained for sufficiently long in API 21. it works fine from API 22, If you check in android /data/system/usagestats, you will find limited entries in API 21, so its not reliable using it in API 21.

For API 21+, You will get the whole day usagestats while querying INTERVAL_DAILY according to UsageStatsManager API. If you want to query within hours of day you should use queryEvents and iterating them by your own logic.

I tried in the following way...

This is the modal class for capturing data for every app:

private class AppUsageInfo {
        Drawable appIcon;
        String appName, packageName;
        long timeInForeground;
        int launchCount;

        AppUsageInfo(String pName) {
            this.packageName=pName;
        }
}

List<AppUsageInfo> smallInfoList; //global var

here is the method, its easy, go with the flow:

void getUsageStatistics() {

UsageEvents.Event currentEvent;
List<UsageEvents.Event> allEvents = new ArrayList<>();
HashMap<String, AppUsageInfo> map = new HashMap <String, AppUsageInfo> ();

long currTime = System.currentTimeMillis();
long startTime currTime - 1000*3600*3; //querying past three hours

UsageStatsManager mUsageStatsManager =  (UsageStatsManager)
                    mContext.getSystemService(Context.USAGE_STATS_SERVICE);

        assert mUsageStatsManager != null;
UsageEvents usageEvents = mUsageStatsManager.queryEvents(usageQueryTodayBeginTime, currTime);

//capturing all events in a array to compare with next element

         while (usageEvents.hasNextEvent()) {
            currentEvent = new UsageEvents.Event();
            usageEvents.getNextEvent(currentEvent);
            if (currentEvent.getEventType() == UsageEvents.Event.MOVE_TO_FOREGROUND ||
                    currentEvent.getEventType() == UsageEvents.Event.MOVE_TO_BACKGROUND) {
                allEvents.add(currentEvent);
                String key = currentEvent.getPackageName();
// taking it into a collection to access by package name
                if (map.get(key)==null)
                    map.put(key,new AppUsageInfo(key));
            }
        }

//iterating through the arraylist 
         for (int i=0;i<allEvents.size()-1;i++){
            UsageEvents.Event E0=allEvents.get(i);
            UsageEvents.Event E1=allEvents.get(i+1);

//for launchCount of apps in time range
             if (!E0.getPackageName().equals(E1.getPackageName()) && E1.getEventType()==1){
// if true, E1 (launch event of an app) app launched
                 map.get(E1.getPackageName()).launchCount++;
             }

//for UsageTime of apps in time range
            if (E0.getEventType()==1 && E1.getEventType()==2
                    && E0.getClassName().equals(E1.getClassName())){
                long diff = E1.getTimeStamp()-E0.getTimeStamp();
                phoneUsageToday+=diff; //gloabl Long var for total usagetime in the timerange
                map.get(E0.getPackageName()).timeInForeground+= diff;
            }
        }
//transferred final data into modal class object
        smallInfoList = new ArrayList<>(map.values());

}
Joggle answered 28/7, 2017 at 18:45 Comment(1)
Do we need to send UTC timeInMillis for begin and endtime?Langston
J
9

I agree with what is said in that comment you mentioned about queryUsageStats not being a trusted source. I've been with playing with the UsageStatsManager for a little while and it returns inconsistent results based on the time of day. I have found that using the UsageEvents and manually calculating the necessary info to be much more trustworthy (at least for daily stats), as they are points in time and don't have any weird calculating errors that would produce different outputs for the same input depending on the time of day.

I used @Vishal's proposed solution to come up with my own:

/**
 * Returns the stats for the [date] (defaults to today) 
 */
fun getDailyStats(date: LocalDate = LocalDate.now()): List<Stat> {
    // The timezones we'll need 
    val utc = ZoneId.of("UTC")
    val defaultZone = ZoneId.systemDefault()

    // Set the starting and ending times to be midnight in UTC time
    val startDate = date.atStartOfDay(defaultZone).withZoneSameInstant(utc)
    val start = startDate.toInstant().toEpochMilli()
    val end = startDate.plusDays(1).toInstant().toEpochMilli()

    // This will keep a map of all of the events per package name 
    val sortedEvents = mutableMapOf<String, MutableList<UsageEvents.Event>>()

    // Query the list of events that has happened within that time frame
    val systemEvents = usageManager.queryEvents(start, end)
    while (systemEvents.hasNextEvent()) {
        val event = UsageEvents.Event()
        systemEvents.getNextEvent(event)

        // Get the list of events for the package name, create one if it doesn't exist
        val packageEvents = sortedEvents[event.packageName] ?: mutableListOf()
        packageEvents.add(event)
        sortedEvents[event.packageName] = packageEvents
    }

    // This will keep a list of our final stats
    val stats = mutableListOf<Stat>()

    // Go through the events by package name
    sortedEvents.forEach { packageName, events ->
        // Keep track of the current start and end times
        var startTime = 0L
        var endTime = 0L
        // Keep track of the total usage time for this app
        var totalTime = 0L
        // Keep track of the start times for this app 
        val startTimes = mutableListOf<ZonedDateTime>()
        events.forEach {
            if (it.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
                // App was moved to the foreground: set the start time
                startTime = it.timeStamp
                // Add the start time within this timezone to the list
                startTimes.add(Instant.ofEpochMilli(startTime).atZone(utc)
                        .withZoneSameInstant(defaultZone))
            } else if (it.eventType == UsageEvents.Event.MOVE_TO_BACKGROUND) {
                // App was moved to background: set the end time
                endTime = it.timeStamp
            }

            // If there's an end time with no start time, this might mean that
            //  The app was started on the previous day, so take midnight 
            //  As the start time 
            if (startTime == 0L && endTime != 0L) {
                startTime = start
            }

            // If both start and end are defined, we have a session
            if (startTime != 0L && endTime != 0L) {
                // Add the session time to the total time
                totalTime += endTime - startTime
                // Reset the start/end times to 0
                startTime = 0L
                endTime = 0L
            }
        }

        // If there is a start time without an end time, this might mean that
        //  the app was used past midnight, so take (midnight - 1 second) 
        //  as the end time
        if (startTime != 0L && endTime == 0L) {
            totalTime += end - 1000 - startTime
        }
        stats.add(Stat(packageName, totalTime, startTimes))
    }
    return stats
}

// Helper class to keep track of all of the stats 
class Stat(val packageName: String, val totalTime: Long, val startTimes: List<ZonedDateTime>)

A couple of observations:

  • The timestamps that the Events have are in UTC, which is why I convert my start/end query times to UTC from my default time zone, and why I convert the start time back on each event. This one got me for a while...
  • This takes into account the edge cases where an app was in the foreground before the beginning of the day (i.e. the user opened an app before midnight) or went to the background after the end of the say (i.e. the user still had an app in the foreground past 11:59 PM on that day). Disclaimer: I haven't actually tested these edge cases yet.
  • In the case that the user uses an app past midnight, I opted with using 11:59:59 PM as the end time. You can obviously change this to be 1 millisecond off of midnight, or simply midnight depending on how you choose to calculate this. Simply remove the - 1000 and replace with whatever you want.
  • In my use case I needed total foreground time + start times, which is why I collect that information. However, you can tweak the Stat class and the code to capture whatever info you need. You can keep track of end times, or number of times an app was launched in a day if needed for example.
  • I am using the Java 8 time library here because it was easier to deal with dates. To use this in Android, I use the ThreeTenABP library.

I hope this helps!

Jeannettejeannie answered 1/6, 2018 at 16:52 Comment(2)
If an app has crashed, the MOVE_TO_FOREGROUND event will be followed by an ACTIVITY_STOPPED event. This is worth testing it as well, else the app will be seen as running for a very long time :)Sheilahshekel
@Sheilahshekel and @Jeannettejeannie : The comment which you've added above is true. But how to add condition for ACTIVITY_STOPPED in above code. Please guide.Lederer
V
4

i guess i found what is happening there. First i wrote below code,

 public String getDaily(String appPackageName, long startTime, long endTime)
 {
    List<UsageStats> usageStatsList = usageStatsManager.queryUsageStats(
                     UsageStatsManager.INTERVAL_DAILY, startTime,endTime);

    String x="";
    for(int i=0; i<usageStatsList.size(); i++) {

        UsageStats stat = usageStatsList.get(i);
        if(stat.getPackageName().equals(appPackageName))
            x=x+i+"-"+stat.getPackageName()+"-"
            +converLongToTimeChar(stat.getTotalTimeInForeground())+"\n";
    }

    return x;
}
public String converLongToTimeChar(long usedTime) {
    String hour="", min="", sec="";

    int h=(int)(usedTime/1000/60/60);
    if (h!=0)
        hour = h+"h ";

    int m=(int)((usedTime/1000/60) % 60);
    if (m!=0)
        min = m+"m ";

    int s=(int)((usedTime/1000) % 60);
    if (s==0 && (h!=0 || m!=0))
        sec="";
    else
        sec = s+"s";

    return hour+min+sec;
}

(today date is 03.08.2017 00:25:14) and when i sent ("package name",02.08.2017 00.00.00, 03.08.2017 00.00.00); to method, (I sent this dates with calendar, you can search in google, how to set dates like that) I got this input;

  46-'apppackagename'-9m 31s
  154-'apppackagename'-22m 38s

then i sent ("package name",03.08.2017 00.00.00, 04.08.2017 00.00.00); to method, I got this input;

  25-'apppackagename'-22m 38s

And i used app which i sent in method about 1 min. Again i sent method output is:

02:08:2017-03.08.2017

  46-'apppackagename'-9m 31s
  154-'apppackagename'-23m 32s

03:08:2017-04.08.2017

  25-'apppackagename'-23m 32s

As you see they increased both. After i see that i waited untill 03.00 am, I used app about 5 min and i got these outputs.

02:08:2017-03.08.2017

  46-'apppackagename'-9m 31s
  154-'apppackagename'-23m 32s

03:08:2017-04.08.2017

  25-'apppackagename'-23m 32s
  50-'apppackagename'-4m 48s

To conclude, you should control before day and its last foregroundrunningtime. İf it is same with that days first foreground time. You should eliminate that time and return sum of the others. (Even if i dont know that strange system.) New day's counter starting after 03:00 am.

I hope it will be helpful to you.

Vshaped answered 3/8, 2017 at 11:7 Comment(3)
Whoever made this api was pure evil. Thank you for sharing this.Custumal
@IlToro I glad, you think that it is benefical. However i highly recommend using UsageStats.Events to calculate usage time. It provides many details of usage stats as [time] [packagename] [foreground | background].Vshaped
You are right. At first reading UsageStats from 3AM to 3AM seemed fine. I even wrote a loop to find the newest package among the clones you may get (if you query multiple days by mistake). After some days of testing though started to be messy again. So I think this API is simply unreliable.Custumal
H
3

I have quiet the same problem and also opened a issue with Google for this. Please have a look at https://issuetracker.google.com/issues/118564471 if this corresponds to what you are describing.

Hollie answered 17/12, 2018 at 13:30 Comment(0)
H
0

I wrote a cleaner and safer queryEvents function inspired by @Vishal Sharma, @jguerinet, and @Floarian answers.

Starting with Android Q, Foreground Service events can also be recorded. Especially if you want to calculate the time spent in the background, you can now do it.

Create an AppUsageStats class:

public class AppUsageStats {
    private final long lastTimeUsedMillis;
    private final long totalTimeInForegroundMillis;
    private final long lastTimeForegroundServiceUsedMillis;
    private final long totalTimeForegroundServiceUsedMillis;

    public AppUsageStats(
            long lastTimeUsedMillis,
            long totalTimeInForegroundMillis,
            long lastTimeForegroundServiceUsedMillis,
            long totalTimeForegroundServiceUsedMillis
    ) {
        this.lastTimeUsedMillis = lastTimeUsedMillis;
        this.totalTimeInForegroundMillis = totalTimeInForegroundMillis;
        this.lastTimeForegroundServiceUsedMillis = lastTimeForegroundServiceUsedMillis;
        this.totalTimeForegroundServiceUsedMillis = totalTimeForegroundServiceUsedMillis;
    }

    public long getLastTimeUsedMillis() {
        return lastTimeUsedMillis;
    }

    public long getTotalTimeInForegroundMillis() {
        return totalTimeInForegroundMillis;
    }

    @RequiresApi(Build.VERSION_CODES.Q)
    public long getLastTimeForegroundServiceUsedMillis() {
        return lastTimeForegroundServiceUsedMillis;
    }

    @RequiresApi(Build.VERSION_CODES.Q)
    public long getTotalTimeForegroundServiceUsedMillis() {
        return totalTimeForegroundServiceUsedMillis;
    }
}

Create an AppUsageStatsBucket class to store Foreground Service data:

public class AppUsageStatsBucket {
    private long startMillis;
    private long endMillis;
    private long totalTime;

    public AppUsageStatsBucket() {
        this.startMillis = 0L;
        this.endMillis = 0L;
        this.totalTime = 0L;
    }

    public long getStartMillis() {
        return startMillis;
    }

    public void setStartMillis(long startMillis) {
        this.startMillis = startMillis;
    }

    public long getEndMillis() {
        return endMillis;
    }

    public void setEndMillis(long endMillis) {
        this.endMillis = endMillis;
    }

    public long getTotalTime() {
        return totalTime;
    }

    public void addTotalTime() {
        this.totalTime += endMillis - startMillis;
    }

    public void setTotalTime(long totalTime) {
        this.totalTime = totalTime;
    }
}

Create a UsageStatsSelection Enum class:

public enum UsageStatsSelection {
    HOURLY(
            UsageStatsManager.INTERVAL_DAILY,
            System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1L),
            System.currentTimeMillis()
    ),
    DAILY(
            UsageStatsManager.INTERVAL_DAILY,
            0L,
            System.currentTimeMillis()
    ),
    WEEKLY(
            UsageStatsManager.INTERVAL_WEEKLY,
            0L,
            System.currentTimeMillis()
    ),
    MONTHLY(
            UsageStatsManager.INTERVAL_MONTHLY,
            0L,
            System.currentTimeMillis()
    ),
    YEARLY(
            UsageStatsManager.INTERVAL_YEARLY,
            0L,
            System.currentTimeMillis()
    );

    private final int usageStatsInterval;
    private final long beginTime;
    private final long endTime;

    UsageStatsSelection(int usageStatsInterval, long beginTime, long endTime) {
        this.usageStatsInterval = usageStatsInterval;
        this.beginTime = beginTime;
        this.endTime = endTime;
    }

    public int getUsageStatsInterval() {
        return usageStatsInterval;
    }

    public long getBeginTime() {
        return beginTime;
    }

    public long getEndTime() {
        return endTime;
    }
}

Since it is not possible to get all days for queryEvents (see: queryEvents(), Query for events in the given time range. Events are only kept by the system for a few days.), we will use queryEvents to get only daily events. We will also use the queryUsageStats() function to get the Weekly, Monthly and Yearly usage data.

@NonNull
public static Map<String, AppUsageStats> queryUsageStats(
        Context context,
        @NonNull UsageStatsSelection statsSelection
) {
    final UsageStatsManager usageStatsManager = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE);
    final Map<String, AppUsageStats> appUsageStatsHashMap = new HashMap<>();
    switch (statsSelection) {
        case HOURLY:
            final UsageEvents events = usageStatsManager.queryEvents(
                    statsSelection.getBeginTime(),
                    statsSelection.getEndTime()
            );
            Map<String, List<UsageEvents.Event>> eventsMap = new HashMap<>();
            UsageEvents.Event currentEvent;
            while (events.hasNextEvent()) {
                currentEvent = new UsageEvents.Event();
                if (events.getNextEvent(currentEvent)) {
                    switch (currentEvent.getEventType()) {
                        case UsageEvents.Event.ACTIVITY_RESUMED:
                        case UsageEvents.Event.ACTIVITY_PAUSED:
                        case UsageEvents.Event.ACTIVITY_STOPPED:
                        case UsageEvents.Event.FOREGROUND_SERVICE_START:
                        case UsageEvents.Event.FOREGROUND_SERVICE_STOP:
                            List<UsageEvents.Event> packageEvents = eventsMap.get(currentEvent.getPackageName());
                            if (packageEvents == null) {
                                packageEvents = new ArrayList<>(Collections.singletonList(currentEvent));
                            } else {
                                packageEvents.add(currentEvent);
                            }
                            eventsMap.put(currentEvent.getPackageName(), packageEvents);
                            break;
                    }
                }
            }

            for (Map.Entry<String, List<UsageEvents.Event>> entry : eventsMap.entrySet()) {
                final AppUsageStatsBucket foregroundBucket = new AppUsageStatsBucket();
                final Map<String, AppUsageStatsBucket> backgroundBucketMap = new HashMap<>();
                for (int pos = 0; pos < entry.getValue().size(); pos++) {
                    final UsageEvents.Event event = entry.getValue().get(pos);
                    AppUsageStatsBucket backgroundBucket = backgroundBucketMap.get(event.getClassName());
                    if (backgroundBucket == null) {
                        backgroundBucket = new AppUsageStatsBucket();
                        backgroundBucketMap.put(event.getClassName(), backgroundBucket);
                    }
                    switch (event.getEventType()) {
                        case UsageEvents.Event.ACTIVITY_RESUMED:
                            foregroundBucket.setStartMillis(event.getTimeStamp());
                            break;
                        case UsageEvents.Event.ACTIVITY_PAUSED:
                        case UsageEvents.Event.ACTIVITY_STOPPED:
                            if (foregroundBucket.getStartMillis() >= foregroundBucket.getEndMillis()) {
                                if (foregroundBucket.getStartMillis() == 0L) {
                                    foregroundBucket.setStartMillis(statsSelection.getBeginTime());
                                }
                                foregroundBucket.setEndMillis(event.getTimeStamp());
                                foregroundBucket.addTotalTime();
                            }
                            break;
                        case UsageEvents.Event.FOREGROUND_SERVICE_START:
                            backgroundBucket.setStartMillis(event.getTimeStamp());
                            break;
                        case UsageEvents.Event.FOREGROUND_SERVICE_STOP:
                            if (backgroundBucket.getStartMillis() >= backgroundBucket.getEndMillis()) {
                                if (backgroundBucket.getStartMillis() == 0L) {
                                    backgroundBucket.setStartMillis(statsSelection.getBeginTime());
                                }
                                backgroundBucket.setEndMillis(event.getTimeStamp());
                                backgroundBucket.addTotalTime();
                            }
                            break;
                    }
                    if (pos == entry.getValue().size() - 1) {
                        if (foregroundBucket.getStartMillis() > foregroundBucket.getEndMillis()) {
                            foregroundBucket.setEndMillis(statsSelection.getEndTime());
                            foregroundBucket.addTotalTime();
                        }
                        if (backgroundBucket.getStartMillis() > backgroundBucket.getEndMillis()) {
                            backgroundBucket.setEndMillis(statsSelection.getEndTime());
                            backgroundBucket.addTotalTime();
                        }
                    }
                }

                final long foregroundEnd = foregroundBucket.getEndMillis();
                final long totalTimeForeground = foregroundBucket.getTotalTime();
                final long backgroundEnd = backgroundBucketMap.values()
                        .stream()
                        .mapToLong(AppUsageStatsBucket::getEndMillis)
                        .max()
                        .orElse(0L);
                final long totalTimeBackground = backgroundBucketMap.values()
                        .stream()
                        .mapToLong(AppUsageStatsBucket::getTotalTime)
                        .sum();

                appUsageStatsHashMap.put(entry.getKey(), new AppUsageStats(
                        Math.max(foregroundEnd, backgroundEnd),
                        totalTimeForeground,
                        backgroundEnd,
                        totalTimeBackground
                ));
            }
            break;
        default:
            final List<UsageStats> usageStats = usageStatsManager
                .queryUsageStats(
                        statsSelection.getUsageStatsInterval(),
                        statsSelection.getBeginTime(),
                        statsSelection.getEndTime()
                );
            appUsageStatsHashMap.putAll(usageStats.parallelStream()
                    .collect(Collectors.toMap(
                            UsageStats::getPackageName,
                            stats -> new AppUsageStats(
                                    stats.getLastTimeUsed(),
                                    stats.getTotalTimeInForeground(),
                                    Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
                                            ? stats.getLastTimeForegroundServiceUsed()
                                            : 0,
                                    Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
                                            ? stats.getTotalTimeForegroundServiceUsed()
                                            : 0
                            ),
                            (oldValue, newValue) -> newValue
                    )));
            break;
    }
    return appUsageStatsHashMap;
}

The result will be returned as Package Name and AppUsageStats list.

Hapte answered 19/3, 2023 at 10:9 Comment(0)
M
-1

When dealing with Android app usage statistics, you may encounter a situation where queryUsageStats does not provide accurate or complete information, particularly for fields like totalTimeInForeground. However, queryAndAggregateUsageStats can resolve this issue by providing more reliable data. Here’s a detailed explanation of the problem and how to use queryAndAggregateUsageStats effectively.

Problem with queryUsageStats

The queryUsageStats method from UsageStatsManager can sometimes return incomplete or incorrect information, particularly for certain fields such as totalTimeInForeground. This can be due to various reasons such as:

Inconsistent Data Aggregation: queryUsageStats retrieves raw usage statistics for the specified time interval, which may not be aggregated correctly. Data Granularity: The data returned might be too granular, requiring additional processing to get meaningful results. Solution: Using queryAndAggregateUsageStats The queryAndAggregateUsageStats method aggregates the usage statistics for a specified time range, providing more accurate and meaningful results. This method returns a map where the key is the package name and the value is the aggregated UsageStats object.

How to Use queryAndAggregateUsageStats Here is an example to illustrate how to use queryAndAggregateUsageStats effectively:

UsageStatsManager usageStatsManager = (UsageStatsManager) 
context.getSystemService(Context.USAGE_STATS_SERVICE);

// Define the time range for the query
long startTime = ...; // Start time in milliseconds
long endTime = ...;   // End time in milliseconds

// Query aggregated usage stats
Map<String, UsageStats> aggregatedStats = usageStatsManager.queryAndAggregateUsageStats(startTime, endTime);

// Iterate through the aggregated stats
for (Map.Entry<String, UsageStats> entry : aggregatedStats.entrySet()) {
    String packageName = entry.getKey();
    UsageStats usageStats = entry.getValue();

    long totalTimeInForeground = usageStats.getTotalTimeInForeground();
    long lastTimeUsed = usageStats.getLastTimeUsed();

    // Print or process the usage stats as needed
    Log.d("UsageStats", "Package: " + packageName + ", TotalTimeInForeground: " + totalTimeInForeground + ", LastTimeUsed: " + lastTimeUsed);
}

Benefits of queryAndAggregateUsageStats Accuracy: Aggregated data provides a more accurate picture of app usage. Simplified Processing: Aggregation simplifies the data processing, as it reduces the need for manual aggregation of raw statistics. Performance: Reduces the overhead of processing large datasets manually.

Mcbroom answered 13/6 at 11:20 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.