001// Licensed under the Apache License, Version 2.0 (the "License");
002// you may not use this file except in compliance with the License.
003// You may obtain a copy of the License at
004//
005//     http://www.apache.org/licenses/LICENSE-2.0
006//
007// Unless required by applicable law or agreed to in writing, software
008// distributed under the License is distributed on an "AS IS" BASIS,
009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
010// See the License for the specific language governing permissions and
011// limitations under the License.
012
013package org.apache.tapestry5.internal.services;
014
015import org.apache.tapestry5.beanmodel.services.*;
016import org.apache.tapestry5.commons.util.CollectionFactory;
017import org.apache.tapestry5.http.Link;
018import org.apache.tapestry5.http.services.Request;
019import org.apache.tapestry5.http.services.SessionPersistedObjectAnalyzer;
020import org.apache.tapestry5.ioc.ScopeConstants;
021import org.apache.tapestry5.ioc.annotations.Scope;
022import org.apache.tapestry5.ioc.internal.util.InternalUtils;
023import org.apache.tapestry5.services.ClientDataEncoder;
024import org.apache.tapestry5.services.ClientDataSink;
025import org.apache.tapestry5.services.PersistentFieldChange;
026
027import java.io.ObjectInputStream;
028import java.io.ObjectOutputStream;
029import java.io.Serializable;
030import java.util.Collection;
031import java.util.Collections;
032import java.util.Map;
033
034/**
035 * Manages client-persistent values on behalf of a {@link ClientPersistentFieldStorageImpl}. Some effort is made to
036 * ensure that we don't uncessarily convert between objects and Base64 (the encoding used to record the value on the
037 * client).
038 */
039@Scope(ScopeConstants.PERTHREAD)
040public class ClientPersistentFieldStorageImpl implements ClientPersistentFieldStorage
041{
042    static final String PARAMETER_NAME = "t:state:client";
043
044    private static class Key implements Serializable
045    {
046        private static final long serialVersionUID = -2741540370081645945L;
047
048        private final String pageName;
049
050        private final String componentId;
051
052        private final String fieldName;
053
054        Key(String pageName, String componentId, String fieldName)
055        {
056            this.pageName = pageName;
057            this.componentId = componentId;
058            this.fieldName = fieldName;
059        }
060
061        public boolean matches(String pageName)
062        {
063            return this.pageName.equals(pageName);
064        }
065
066        public PersistentFieldChange toChange(Object value)
067        {
068            return new PersistentFieldChangeImpl(componentId == null ? "" : componentId,
069                    fieldName, value);
070        }
071
072        @Override
073        public int hashCode()
074        {
075            final int PRIME = 31;
076
077            int result = 1;
078
079            result = PRIME * result + ((componentId == null) ? 0 : componentId.hashCode());
080
081            // fieldName and pageName are never null
082
083            result = PRIME * result + fieldName.hashCode();
084            result = PRIME * result + pageName.hashCode();
085
086            return result;
087        }
088
089        @Override
090        public boolean equals(Object obj)
091        {
092            if (this == obj) return true;
093            if (obj == null) return false;
094            if (getClass() != obj.getClass()) return false;
095            final Key other = (Key) obj;
096
097            // fieldName and pageName are never null
098
099            if (!fieldName.equals(other.fieldName)) return false;
100            if (!pageName.equals(other.pageName)) return false;
101
102            if (componentId == null)
103            {
104                if (other.componentId != null) return false;
105            } else if (!componentId.equals(other.componentId)) return false;
106
107            return true;
108        }
109    }
110
111    private final ClientDataEncoder clientDataEncoder;
112
113    private final SessionPersistedObjectAnalyzer analyzer;
114
115    private final Map<Key, Object> persistedValues = CollectionFactory.newMap();
116
117    private String clientData;
118
119    private boolean mapUptoDate = false;
120
121    public ClientPersistentFieldStorageImpl(Request request, ClientDataEncoder clientDataEncoder, SessionPersistedObjectAnalyzer analyzer)
122    {
123        this.clientDataEncoder = clientDataEncoder;
124        this.analyzer = analyzer;
125
126        // This, here, is the problem of TAPESTRY-2501; this call can predate
127        // the check to set the character set based on meta data of the page.
128
129        String value = request.getParameter(PARAMETER_NAME);
130
131        // MIME can encode to a '+' character; the browser converts that to a space; we convert it
132        // back.
133
134        clientData = value == null ? null : value.replace(' ', '+');
135    }
136
137    public void updateLink(Link link)
138    {
139        refreshClientData();
140
141        if (clientData != null) link.addParameter(PARAMETER_NAME, clientData);
142    }
143
144    public Collection<PersistentFieldChange> gatherFieldChanges(String pageName)
145    {
146        refreshMap();
147
148        if (persistedValues.isEmpty()) return Collections.emptyList();
149
150        Collection<PersistentFieldChange> result = CollectionFactory.newList();
151
152        for (Map.Entry<Key, Object> e : persistedValues.entrySet())
153        {
154            Key key = e.getKey();
155
156            if (key.matches(pageName)) result.add(key.toChange(e.getValue()));
157        }
158
159        return result;
160    }
161
162    public void discardChanges(String pageName)
163    {
164        refreshMap();
165
166        Collection<Key> removedKeys = CollectionFactory.newList();
167
168        for (Key key : persistedValues.keySet())
169        {
170            if (key.pageName.equals(pageName)) removedKeys.add(key);
171        }
172
173        for (Key key : removedKeys)
174        {
175            persistedValues.remove(key);
176            clientData = null;
177        }
178    }
179
180    public void postChange(String pageName, String componentId, String fieldName, Object newValue)
181    {
182        refreshMap();
183
184        Key key = new Key(pageName, componentId, fieldName);
185
186        if (newValue == null)
187            persistedValues.remove(key);
188        else
189        {
190            if (!Serializable.class.isInstance(newValue))
191                throw new IllegalArgumentException(String.format("State persisted on the client must be serializable, but %s does not implement the Serializable interface.", newValue));
192
193            persistedValues.put(key, newValue);
194        }
195
196        clientData = null;
197    }
198
199    /**
200     * Refreshes the _persistedValues map if it is not up to date.
201     */
202    @SuppressWarnings("unchecked")
203    private void refreshMap()
204    {
205        if (mapUptoDate) return;
206
207        // Parse the client data to form the map.
208
209        restoreMapFromClientData();
210
211        mapUptoDate = true;
212    }
213
214    /**
215     * Restores the _persistedValues map from the client data provided in the incoming Request.
216     */
217    private void restoreMapFromClientData()
218    {
219        persistedValues.clear();
220
221        if (clientData == null) return;
222
223        ObjectInputStream in = null;
224
225        try
226        {
227            in = clientDataEncoder.decodeClientData(clientData);
228
229            int count = in.readInt();
230
231            for (int i = 0; i < count; i++)
232            {
233                Key key = (Key) in.readObject();
234                Object value = in.readObject();
235
236                persistedValues.put(key, value);
237            }
238        } catch (Exception ex)
239        {
240            throw new RuntimeException("Serialized client state was corrupted. This may indicate that too much state is being stored, which can cause the encoded string to be truncated by the client web browser.", ex);
241        } finally
242        {
243            InternalUtils.close(in);
244        }
245    }
246
247    private void refreshClientData()
248    {
249        // TAP5-2269: Even in the absense of a change to a persistent field, a mutable persistent object
250        // may have changed.
251
252        if (clientData != null)
253        {
254            for (Object value : persistedValues.values())
255            {
256                if (analyzer.checkAndResetDirtyState(value))
257                {
258                    clientData = null;
259                    break;
260                }
261            }
262        }
263
264        // Client data will be null after a change to the map, or if there was no client data in the
265        // request. In any other case where the client data is non-null, it is by definition
266        // up-to date (since it is reset to null any time there's a change to the map).
267
268        if (clientData != null) return;
269
270        // Very typical: we're refreshing the client data but haven't created the map yet, and there
271        // was no value in the request. Leave it as null.
272
273        if (!mapUptoDate) return;
274
275        // Null is also appropriate when the persisted values are empty.
276
277        if (persistedValues.isEmpty()) return;
278
279        // Otherwise, time to update clientData from persistedValues
280
281        ClientDataSink sink = clientDataEncoder.createSink();
282
283        ObjectOutputStream os = sink.getObjectOutputStream();
284
285        try
286        {
287            os.writeInt(persistedValues.size());
288
289            for (Map.Entry<Key, Object> e : persistedValues.entrySet())
290            {
291                os.writeObject(e.getKey());
292                os.writeObject(e.getValue());
293            }
294        } catch (Exception ex)
295        {
296            throw new RuntimeException(ex.getMessage(), ex);
297        } finally
298        {
299            InternalUtils.close(os);
300        }
301
302        clientData = sink.getClientData();
303    }
304}